die tenant admin oauth authentifizierung wurde implementiert und funktioniert jetzt. Zudem wurde das marketing frontend dashboard implementiert.
This commit is contained in:
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useForm } from '@inertiajs/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
const ProfileAccount = () => {
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post('/profile/account');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account bearbeiten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" value={data.name} onChange={(e) => setData('name', e.target.value)} />
|
||||
{errors.name && <p className="text-red-500 text-sm">{errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" value={data.email} onChange={(e) => setData('email', e.target.value)} />
|
||||
{errors.email && <p className="text-red-500 text-sm">{errors.email}</p>}
|
||||
</div>
|
||||
<Button type="submit" disabled={processing}>Speichern</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileAccount;
|
||||
@@ -1,38 +1,375 @@
|
||||
import React from 'react';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import Account from './Account';
|
||||
import Orders from './Orders';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { Head, Form, Link, usePage } from '@inertiajs/react';
|
||||
import { CalendarClock, CheckCircle2, MailWarning, ReceiptText } from 'lucide-react';
|
||||
|
||||
const ProfileIndex = () => {
|
||||
const { user } = usePage().props as any;
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
|
||||
import InputError from '@/components/input-error';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mein Profil</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>Hallo, {user.name}!</p>
|
||||
<p>Email: {user.email}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Tabs defaultValue="account" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
<TabsTrigger value="orders">Bestellungen</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account">
|
||||
<Account />
|
||||
</TabsContent>
|
||||
<TabsContent value="orders">
|
||||
<Orders />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { send as resendVerificationRoute } from '@/routes/verification';
|
||||
|
||||
type ProfilePageProps = {
|
||||
userData: {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
username?: string | null;
|
||||
preferredLocale?: string | null;
|
||||
emailVerifiedAt: string | null;
|
||||
mustVerifyEmail: boolean;
|
||||
};
|
||||
tenant: {
|
||||
id: number;
|
||||
name: string;
|
||||
eventCreditsBalance: number | null;
|
||||
subscriptionStatus: string | null;
|
||||
subscriptionExpiresAt: string | null;
|
||||
activePackage: {
|
||||
name: string;
|
||||
price: number | null;
|
||||
expiresAt: string | null;
|
||||
remainingEvents: number | null;
|
||||
} | null;
|
||||
} | null;
|
||||
purchases: Array<{
|
||||
id: number;
|
||||
packageName: string;
|
||||
price: number | null;
|
||||
purchasedAt: string | null;
|
||||
type: string | null;
|
||||
provider: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default ProfileIndex;
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Profil',
|
||||
href: '/profile',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProfileIndex() {
|
||||
const { userData, tenant, purchases, supportedLocales, locale } = usePage<SharedData & ProfilePageProps>().props;
|
||||
|
||||
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}), [locale]);
|
||||
|
||||
const currencyFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 2,
|
||||
}), [locale]);
|
||||
|
||||
const registrationDate = useMemo(() => {
|
||||
if (!userData.emailVerifiedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return dateFormatter.format(new Date(userData.emailVerifiedAt));
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}, [userData.emailVerifiedAt, dateFormatter]);
|
||||
|
||||
const formatDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—');
|
||||
const formatPrice = (price: number | null) => (price === null ? '—' : currencyFormatter.format(price));
|
||||
|
||||
const localeOptions = (supportedLocales ?? ['de', 'en']).map((value) => ({
|
||||
label: value.toUpperCase(),
|
||||
value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Profil" />
|
||||
|
||||
<div className="flex flex-1 flex-col gap-6 pb-12">
|
||||
<Card className="border-border/60 bg-gradient-to-br from-background to-muted/50 shadow-sm">
|
||||
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-semibold">Hallo, {userData.name || userData.email}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hier verwaltest du deine Zugangsdaten, sprichst mit uns in deiner Lieblingssprache und behältst alle Buchungen im Blick.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||
<Badge variant={userData.emailVerifiedAt ? 'secondary' : 'outline'} className="flex items-center gap-2">
|
||||
{userData.emailVerifiedAt ? <CheckCircle2 className="size-4 text-emerald-500" /> : <MailWarning className="size-4 text-amber-500" />}
|
||||
{userData.emailVerifiedAt ? 'E-Mail bestätigt' : 'Bestätigung ausstehend'}
|
||||
</Badge>
|
||||
{registrationDate && (
|
||||
<p className="text-xs text-muted-foreground">Aktiv seit {registrationDate}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{!userData.emailVerifiedAt && userData.mustVerifyEmail && (
|
||||
<CardContent>
|
||||
<Alert variant="warning" className="border-amber-300 bg-amber-100/80 text-amber-900 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-50">
|
||||
<MailWarning className="size-5" />
|
||||
<div>
|
||||
<AlertTitle>E-Mail-Bestätigung ausstehend</AlertTitle>
|
||||
<AlertDescription>
|
||||
Bestätige deine E-Mail-Adresse, damit wir dich über Uploads, Rechnungen und Event-Updates informieren können.
|
||||
<div className="mt-3">
|
||||
<Link href={resendVerificationRoute()} as="button" method="post" className="text-sm font-medium underline underline-offset-4">
|
||||
Bestätigungslink erneut senden
|
||||
</Link>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<AccountForm userData={userData} localeOptions={localeOptions} />
|
||||
</Card>
|
||||
<Card>
|
||||
<PasswordForm />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle>Abonnements & Pakete</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hier findest du die wichtigsten Daten zu deinem aktuellen Paket und deinen letzten Buchungen.
|
||||
</p>
|
||||
</div>
|
||||
{tenant?.activePackage ? (
|
||||
<Badge variant="outline" className="flex items-center gap-2">
|
||||
<CalendarClock className="size-4" /> Läuft bis {formatDate(tenant.activePackage.expiresAt)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Kein aktives Paket</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{tenant?.activePackage ? (
|
||||
<div className="grid gap-3 rounded-lg border border-dashed border-muted-foreground/30 bg-muted/30 p-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-base font-medium leading-tight">{tenant.activePackage.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">{tenant.eventCreditsBalance ?? 0} Credits verfügbar · {tenant.activePackage.remainingEvents ?? 0} Events inklusive</p>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Status</span>
|
||||
<span className="font-medium capitalize">{tenant.subscriptionStatus ?? 'aktiv'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Verlängerung</span>
|
||||
<span>{formatDate(tenant.subscriptionExpiresAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Preis</span>
|
||||
<span>{formatPrice(tenant.activePackage.price)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Alert className="border border-dashed border-muted-foreground/40 bg-muted/20">
|
||||
<AlertDescription className="text-sm text-muted-foreground">
|
||||
Du hast aktuell kein aktives Paket. Sichere dir jetzt Credits oder ein Komplettpaket, um neue Events zu planen.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<ReceiptText className="size-4" /> Letzte Buchungen
|
||||
</div>
|
||||
{purchases.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/30 p-6 text-sm text-muted-foreground text-center">
|
||||
Noch keine Buchungen vorhanden. Schaue im Dashboard vorbei, um passende Pakete zu finden.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Paket</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Typ</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Anbieter</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Datum</TableHead>
|
||||
<TableHead className="text-right">Preis</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{purchases.map((purchase) => (
|
||||
<TableRow key={purchase.id}>
|
||||
<TableCell className="font-medium">{purchase.packageName}</TableCell>
|
||||
<TableCell className="hidden capitalize text-muted-foreground sm:table-cell">{purchase.type ?? '—'}</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground md:table-cell">{purchase.provider ?? 'Checkout'}</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground md:table-cell">{formatDate(purchase.purchasedAt)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatPrice(purchase.price)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountForm({ userData, localeOptions }: { userData: ProfilePageProps['userData']; localeOptions: Array<{ label: string; value: string }> }) {
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle>Profilinformationen</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Aktualisiere deine Kontaktdaten und die Standardsprache für E-Mails und Oberfläche.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
{...ProfileController.update.form()}
|
||||
options={{ preserveScroll: true }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{({ processing, recentlySuccessful, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Vollständiger Name</Label>
|
||||
<Input id="name" name="name" defaultValue={userData.name ?? ''} autoComplete="name" placeholder="Max Mustermann" />
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">E-Mail-Adresse</Label>
|
||||
<Input id="email" type="email" name="email" defaultValue={userData.email ?? ''} autoComplete="username" />
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">Benutzername</Label>
|
||||
<Input id="username" name="username" defaultValue={userData.username ?? ''} autoComplete="username" placeholder="mein-eventname" />
|
||||
<InputError message={errors.username} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="preferred_locale">Bevorzugte Sprache</Label>
|
||||
<select
|
||||
id="preferred_locale"
|
||||
name="preferred_locale"
|
||||
defaultValue={userData.preferredLocale ?? localeOptions[0]?.value ?? 'de'}
|
||||
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{localeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<InputError message={errors.preferred_locale} />
|
||||
</div>
|
||||
|
||||
<CardFooter className="px-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button disabled={processing}>Änderungen speichern</Button>
|
||||
<Transition
|
||||
show={recentlySuccessful}
|
||||
enter="transition ease-in-out"
|
||||
enterFrom="opacity-0"
|
||||
leave="transition ease-in-out"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">Gespeichert</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordForm() {
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
const currentPasswordInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle>Sicherheit & Passwort</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Vergebe ein starkes Passwort, um dein Konto bestmöglich zu schützen.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
{...PasswordController.update.form()}
|
||||
options={{ preserveScroll: true }}
|
||||
resetOnError={['password', 'password_confirmation', 'current_password']}
|
||||
resetOnSuccess
|
||||
onError={(errors) => {
|
||||
if (errors.password) {
|
||||
passwordInputRef.current?.focus();
|
||||
}
|
||||
if (errors.current_password) {
|
||||
currentPasswordInputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
{({ errors, processing, recentlySuccessful }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="current_password">Aktuelles Passwort</Label>
|
||||
<Input id="current_password" name="current_password" type="password" ref={currentPasswordInputRef} autoComplete="current-password" />
|
||||
<InputError message={errors.current_password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Neues Passwort</Label>
|
||||
<Input id="password" name="password" type="password" ref={passwordInputRef} autoComplete="new-password" />
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">Bestätigung</Label>
|
||||
<Input id="password_confirmation" name="password_confirmation" type="password" autoComplete="new-password" />
|
||||
<InputError message={errors.password_confirmation} />
|
||||
</div>
|
||||
|
||||
<CardFooter className="px-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button disabled={processing}>Passwort speichern</Button>
|
||||
<Transition
|
||||
show={recentlySuccessful}
|
||||
enter="transition ease-in-out"
|
||||
enterFrom="opacity-0"
|
||||
leave="transition ease-in-out"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">Aktualisiert</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Purchase {
|
||||
id: number;
|
||||
created_at: string;
|
||||
package: {
|
||||
name: string;
|
||||
price: number;
|
||||
};
|
||||
status: string;
|
||||
}
|
||||
|
||||
const ProfileOrders = () => {
|
||||
const page = usePage<{ purchases?: Purchase[] }>();
|
||||
const purchases = page.props.purchases ?? [];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bestellungen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Paket</TableHead>
|
||||
<TableHead>Preis</TableHead>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{purchases.map((purchase) => (
|
||||
<TableRow key={purchase.id}>
|
||||
<TableCell>{purchase.package.name}</TableCell>
|
||||
<TableCell>{purchase.package.price} €</TableCell>
|
||||
<TableCell>{format(new Date(purchase.created_at), 'dd.MM.yyyy')}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={purchase.status === 'completed' ? 'default' : 'secondary'}>
|
||||
{purchase.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{purchases.length === 0 && (
|
||||
<p className="text-center text-muted-foreground py-8">Keine Bestellungen gefunden.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileOrders;
|
||||
Reference in New Issue
Block a user