die tenant admin oauth authentifizierung wurde implementiert und funktioniert jetzt. Zudem wurde das marketing frontend dashboard implementiert.
This commit is contained in:
@@ -5,6 +5,24 @@ import i18n from './i18n';
|
||||
|
||||
type JsonValue = Record<string, unknown>;
|
||||
|
||||
export type TenantAccountProfile = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
preferred_locale: string | null;
|
||||
email_verified: boolean;
|
||||
email_verified_at: string | null;
|
||||
};
|
||||
|
||||
export type UpdateTenantProfilePayload = {
|
||||
name: string;
|
||||
email: string;
|
||||
preferred_locale?: string | null;
|
||||
current_password?: string;
|
||||
password?: string;
|
||||
password_confirmation?: string;
|
||||
};
|
||||
|
||||
export type EventQrInviteLayout = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -353,10 +371,16 @@ async function jsonOrThrow<T>(response: Response, message: string, options: Json
|
||||
const errorCode = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.code === 'string'
|
||||
? errorPayload.code
|
||||
: undefined;
|
||||
const errorMeta = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.meta === 'object'
|
||||
let errorMeta = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.meta === 'object'
|
||||
? errorPayload.meta as Record<string, unknown>
|
||||
: undefined;
|
||||
|
||||
if (!errorMeta && body && typeof body === 'object' && 'errors' in body && typeof body.errors === 'object') {
|
||||
errorMeta = {
|
||||
errors: body.errors as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
if (!options.suppressToast) {
|
||||
emitApiErrorEvent({ message: errorMessage, status, code: errorCode, meta: errorMeta });
|
||||
}
|
||||
@@ -1102,6 +1126,49 @@ export async function getNotificationPreferences(): Promise<NotificationPreferen
|
||||
};
|
||||
}
|
||||
|
||||
type ProfileResponse = {
|
||||
data?: TenantAccountProfile;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function fetchTenantProfile(): Promise<TenantAccountProfile> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/profile');
|
||||
const payload = await jsonOrThrow<ProfileResponse>(
|
||||
response,
|
||||
i18n.t('settings.profile.errors.load', 'Profil konnte nicht geladen werden.'),
|
||||
{ suppressToast: true }
|
||||
);
|
||||
|
||||
if (!payload.data) {
|
||||
throw new Error('Profilantwort war leer.');
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
export async function updateTenantProfile(payload: UpdateTenantProfilePayload): Promise<TenantAccountProfile> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const json = await jsonOrThrow<ProfileResponse>(
|
||||
response,
|
||||
i18n.t('settings.profile.errors.update', 'Profil konnte nicht aktualisiert werden.'),
|
||||
{ suppressToast: true }
|
||||
);
|
||||
|
||||
if (!json.data) {
|
||||
throw new Error('Profilantwort war leer.');
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export async function updateNotificationPreferences(
|
||||
preferences: NotificationPreferences
|
||||
): Promise<NotificationPreferenceResponse> {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
registerAuthFailureHandler,
|
||||
startOAuthFlow,
|
||||
} from './tokens';
|
||||
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
|
||||
|
||||
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||
|
||||
@@ -86,17 +86,34 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}, [handleAuthFailure, refreshProfile]);
|
||||
|
||||
const login = React.useCallback((redirectPath?: string) => {
|
||||
const target = redirectPath ?? window.location.pathname + window.location.search;
|
||||
const sanitizedTarget = redirectPath && redirectPath.trim() !== '' ? redirectPath : ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
const target = sanitizedTarget.startsWith('/') ? sanitizedTarget : `/${sanitizedTarget}`;
|
||||
startOAuthFlow(target);
|
||||
}, []);
|
||||
|
||||
const logout = React.useCallback(({ redirect }: { redirect?: string } = {}) => {
|
||||
clearTokens();
|
||||
clearOAuthSession();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
if (redirect) {
|
||||
window.location.href = redirect;
|
||||
const logout = React.useCallback(async ({ redirect }: { redirect?: string } = {}) => {
|
||||
try {
|
||||
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content;
|
||||
await fetch('/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to notify backend about logout', error);
|
||||
}
|
||||
} finally {
|
||||
clearTokens();
|
||||
clearOAuthSession();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
if (redirect) {
|
||||
window.location.href = redirect;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@ export const adminPath = (suffix = ''): string => `${ADMIN_BASE_PATH}${suffix}`;
|
||||
|
||||
export const ADMIN_PUBLIC_LANDING_PATH = ADMIN_BASE_PATH;
|
||||
export const ADMIN_HOME_PATH = adminPath('/dashboard');
|
||||
export const ADMIN_DEFAULT_AFTER_LOGIN_PATH = adminPath('/events');
|
||||
export const ADMIN_LOGIN_PATH = adminPath('/login');
|
||||
export const ADMIN_LOGIN_START_PATH = adminPath('/start');
|
||||
export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
|
||||
export const ADMIN_EVENTS_PATH = adminPath('/events');
|
||||
export const ADMIN_SETTINGS_PATH = adminPath('/settings');
|
||||
export const ADMIN_PROFILE_PATH = adminPath('/settings/profile');
|
||||
export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement');
|
||||
export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string =>
|
||||
`${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`;
|
||||
|
||||
@@ -10,6 +10,8 @@ import deOnboarding from './locales/de/onboarding.json';
|
||||
import enOnboarding from './locales/en/onboarding.json';
|
||||
import deManagement from './locales/de/management.json';
|
||||
import enManagement from './locales/en/management.json';
|
||||
import deSettings from './locales/de/settings.json';
|
||||
import enSettings from './locales/en/settings.json';
|
||||
import deAuth from './locales/de/auth.json';
|
||||
import enAuth from './locales/en/auth.json';
|
||||
|
||||
@@ -21,6 +23,7 @@ const resources = {
|
||||
dashboard: deDashboard,
|
||||
onboarding: deOnboarding,
|
||||
management: deManagement,
|
||||
settings: deSettings,
|
||||
auth: deAuth,
|
||||
},
|
||||
en: {
|
||||
@@ -28,6 +31,7 @@ const resources = {
|
||||
dashboard: enDashboard,
|
||||
onboarding: enOnboarding,
|
||||
management: enManagement,
|
||||
settings: enSettings,
|
||||
auth: enAuth,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -13,10 +13,25 @@
|
||||
"lead": "Du meldest dich über unseren sicheren OAuth-Login an und landest direkt im Event-Dashboard.",
|
||||
"panel_title": "Melde dich an",
|
||||
"panel_copy": "Logge dich mit deinem Fotospiel-Adminzugang ein. Wir schützen dein Konto mit OAuth 2.1 und klaren Rollenrechten.",
|
||||
"actions_title": "Wähle deine Anmeldemethode",
|
||||
"actions_copy": "Greife sicher per OAuth oder mit deinem Google-Konto auf das Tenant-Dashboard zu.",
|
||||
"cta": "Mit Fotospiel-Login fortfahren",
|
||||
"google_cta": "Mit Google anmelden",
|
||||
"open_account_login": "Konto-Login öffnen",
|
||||
"loading": "Bitte warten …",
|
||||
"oauth_error_title": "Login aktuell nicht möglich",
|
||||
"oauth_error": "Anmeldung fehlgeschlagen: {{message}}",
|
||||
"oauth_errors": {
|
||||
"login_required": "Bitte melde dich zuerst in deinem Fotospiel-Konto an.",
|
||||
"invalid_request": "Die Login-Anfrage war ungültig. Bitte versuche es erneut.",
|
||||
"invalid_client": "Die verknüpfte Tenant-App wurde nicht gefunden. Wende dich an den Support, falls das Problem bleibt.",
|
||||
"invalid_redirect": "Die angegebene Weiterleitungsadresse ist für diese App nicht hinterlegt.",
|
||||
"invalid_scope": "Die App fordert Berechtigungen an, die nicht freigegeben sind.",
|
||||
"tenant_mismatch": "Du hast keinen Zugriff auf den Tenant, der diese Anmeldung angefordert hat.",
|
||||
"google_failed": "Die Anmeldung mit Google war nicht erfolgreich. Bitte versuche es erneut oder wähle eine andere Methode.",
|
||||
"google_no_match": "Wir konnten dieses Google-Konto keinem Tenant-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an."
|
||||
},
|
||||
"return_hint": "Nach dem Anmelden leiten wir dich automatisch zurück.",
|
||||
"support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.",
|
||||
"appearance_label": "Darstellung"
|
||||
}
|
||||
|
||||
51
resources/js/admin/i18n/locales/de/settings.json
Normal file
51
resources/js/admin/i18n/locales/de/settings.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"subtitle": "Verwalte deine Kontodaten und Zugangsdaten.",
|
||||
"sections": {
|
||||
"account": {
|
||||
"heading": "Account-Informationen",
|
||||
"description": "Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an."
|
||||
},
|
||||
"password": {
|
||||
"heading": "Passwort ändern",
|
||||
"description": "Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.",
|
||||
"hint": "Nutze mindestens 8 Zeichen und kombiniere Buchstaben sowie Zahlen für mehr Sicherheit."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"name": "Anzeigename",
|
||||
"email": "E-Mail-Adresse",
|
||||
"locale": "Bevorzugte Sprache",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
"passwordConfirmation": "Passwort bestätigen"
|
||||
},
|
||||
"placeholders": {
|
||||
"name": "z. B. Hochzeitsplanung Schmidt",
|
||||
"locale": "Systemsprache verwenden"
|
||||
},
|
||||
"locale": {
|
||||
"auto": "Automatisch"
|
||||
},
|
||||
"status": {
|
||||
"emailVerified": "E-Mail bestätigt",
|
||||
"emailNotVerified": "Bestätigung erforderlich",
|
||||
"verifiedHint": "Bestätigt am {{date}}.",
|
||||
"unverifiedHint": "Bei Änderung der E-Mail senden wir dir automatisch eine neue Bestätigung."
|
||||
},
|
||||
"actions": {
|
||||
"save": "Speichern",
|
||||
"updatePassword": "Passwort aktualisieren",
|
||||
"openProfile": "Profil bearbeiten"
|
||||
},
|
||||
"toasts": {
|
||||
"updated": "Profil wurde aktualisiert.",
|
||||
"passwordChanged": "Passwort wurde aktualisiert."
|
||||
},
|
||||
"errors": {
|
||||
"load": "Profil konnte nicht geladen werden.",
|
||||
"update": "Profil konnte nicht aktualisiert werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,25 @@
|
||||
"lead": "Use our secure OAuth login and land directly in the event dashboard.",
|
||||
"panel_title": "Sign in",
|
||||
"panel_copy": "Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.",
|
||||
"actions_title": "Choose your sign-in method",
|
||||
"actions_copy": "Access the tenant dashboard securely with OAuth or your Google account.",
|
||||
"cta": "Continue with Fotospiel login",
|
||||
"google_cta": "Continue with Google",
|
||||
"open_account_login": "Open account login",
|
||||
"loading": "Signing you in …",
|
||||
"oauth_error_title": "Login not possible right now",
|
||||
"oauth_error": "Sign-in failed: {{message}}",
|
||||
"oauth_errors": {
|
||||
"login_required": "Please sign in to your Fotospiel account before continuing.",
|
||||
"invalid_request": "The login request was invalid. Please try again.",
|
||||
"invalid_client": "We couldn’t find the linked tenant app. Please contact support if this persists.",
|
||||
"invalid_redirect": "The redirect address is not registered for this app.",
|
||||
"invalid_scope": "The app asked for permissions it cannot receive.",
|
||||
"tenant_mismatch": "You don’t have access to the tenant that requested this login.",
|
||||
"google_failed": "Google sign-in was not successful. Please try again or use another method.",
|
||||
"google_no_match": "We couldn’t link this Google account to a tenant admin. Please sign in with Fotospiel credentials."
|
||||
},
|
||||
"return_hint": "After signing in you’ll be brought back automatically.",
|
||||
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",
|
||||
"appearance_label": "Appearance"
|
||||
}
|
||||
|
||||
51
resources/js/admin/i18n/locales/en/settings.json
Normal file
51
resources/js/admin/i18n/locales/en/settings.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"subtitle": "Manage your account details and credentials.",
|
||||
"sections": {
|
||||
"account": {
|
||||
"heading": "Account information",
|
||||
"description": "Update your name, email address, and interface language."
|
||||
},
|
||||
"password": {
|
||||
"heading": "Change password",
|
||||
"description": "Choose a strong password to protect admin access.",
|
||||
"hint": "Use at least 8 characters and mix letters and numbers for higher security."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"name": "Display name",
|
||||
"email": "Email address",
|
||||
"locale": "Preferred language",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"passwordConfirmation": "Confirm password"
|
||||
},
|
||||
"placeholders": {
|
||||
"name": "e.g. Event Planning Smith",
|
||||
"locale": "Use system language"
|
||||
},
|
||||
"locale": {
|
||||
"auto": "Automatic"
|
||||
},
|
||||
"status": {
|
||||
"emailVerified": "Email verified",
|
||||
"emailNotVerified": "Verification required",
|
||||
"verifiedHint": "Verified on {{date}}.",
|
||||
"unverifiedHint": "We'll send another verification email when you change the address."
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"updatePassword": "Update password",
|
||||
"openProfile": "Edit profile"
|
||||
},
|
||||
"toasts": {
|
||||
"updated": "Profile updated successfully.",
|
||||
"passwordChanged": "Password updated."
|
||||
},
|
||||
"errors": {
|
||||
"load": "Unable to load your profile.",
|
||||
"update": "Could not update your profile."
|
||||
}
|
||||
}
|
||||
}
|
||||
172
resources/js/admin/lib/returnTo.ts
Normal file
172
resources/js/admin/lib/returnTo.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { ADMIN_LOGIN_PATH, ADMIN_LOGIN_START_PATH } from '../constants';
|
||||
|
||||
const LAST_DESTINATION_KEY = 'tenant.oauth.lastDestination';
|
||||
|
||||
function ensureLeadingSlash(target: string): string {
|
||||
if (!target) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(target)) {
|
||||
return target;
|
||||
}
|
||||
|
||||
return target.startsWith('/') ? target : `/${target}`;
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: string): string {
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(value);
|
||||
|
||||
let binary = '';
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '');
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): string | null {
|
||||
try {
|
||||
const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), '=');
|
||||
const normalized = padded.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = atob(normalized);
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
return decoder.decode(bytes);
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Failed to decode return_to parameter', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeReturnTo(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return base64UrlEncode(trimmed);
|
||||
}
|
||||
|
||||
export function decodeReturnTo(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return base64UrlDecode(value);
|
||||
}
|
||||
|
||||
export interface ReturnTargetResolution {
|
||||
finalTarget: string;
|
||||
encodedFinal: string;
|
||||
}
|
||||
|
||||
export function resolveReturnTarget(raw: string | null, fallback: string): ReturnTargetResolution {
|
||||
const normalizedFallback = ensureLeadingSlash(fallback);
|
||||
|
||||
if (!raw) {
|
||||
const encoded = encodeReturnTo(normalizedFallback);
|
||||
return { finalTarget: normalizedFallback, encodedFinal: encoded };
|
||||
}
|
||||
|
||||
const decodedPrimary = decodeReturnTo(raw);
|
||||
if (!decodedPrimary) {
|
||||
const encoded = encodeReturnTo(normalizedFallback);
|
||||
return { finalTarget: normalizedFallback, encodedFinal: encoded };
|
||||
}
|
||||
|
||||
const normalizedPrimary = decodedPrimary.trim();
|
||||
|
||||
const wrapperPaths = [ADMIN_LOGIN_START_PATH, ADMIN_LOGIN_PATH];
|
||||
for (const wrapper of wrapperPaths) {
|
||||
if (normalizedPrimary.startsWith(wrapper)) {
|
||||
try {
|
||||
const url = new URL(normalizedPrimary, window.location.origin);
|
||||
const innerRaw = url.searchParams.get('return_to');
|
||||
if (!innerRaw) {
|
||||
const encoded = encodeReturnTo(normalizedFallback);
|
||||
return { finalTarget: normalizedFallback, encodedFinal: encoded };
|
||||
}
|
||||
|
||||
return resolveReturnTarget(innerRaw, normalizedFallback);
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Failed to parse return_to chain', error);
|
||||
const encoded = encodeReturnTo(normalizedFallback);
|
||||
return { finalTarget: normalizedFallback, encodedFinal: encoded };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalTarget = ensureLeadingSlash(normalizedPrimary);
|
||||
const encodedFinal = encodeReturnTo(finalTarget);
|
||||
|
||||
return { finalTarget, encodedFinal };
|
||||
}
|
||||
|
||||
export function buildAdminOAuthStartPath(targetPath: string, encodedTarget?: string): string {
|
||||
const sanitizedTarget = ensureLeadingSlash(targetPath);
|
||||
const encoded = encodedTarget ?? encodeReturnTo(sanitizedTarget);
|
||||
const url = new URL(ADMIN_LOGIN_START_PATH, window.location.origin);
|
||||
url.searchParams.set('return_to', encoded);
|
||||
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
function resolveLocale(): string {
|
||||
const raw = document.documentElement.lang || 'de';
|
||||
const normalized = raw.toLowerCase();
|
||||
|
||||
if (normalized.includes('-')) {
|
||||
return normalized.split('-')[0] || 'de';
|
||||
}
|
||||
|
||||
return normalized || 'de';
|
||||
}
|
||||
|
||||
export function buildMarketingLoginUrl(returnPath: string): string {
|
||||
const sanitizedPath = ensureLeadingSlash(returnPath);
|
||||
const encoded = encodeReturnTo(sanitizedPath);
|
||||
const locale = resolveLocale();
|
||||
const loginPath = `/${locale}/login`;
|
||||
const url = new URL(loginPath, window.location.origin);
|
||||
url.searchParams.set('return_to', encoded);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function storeLastDestination(path: string): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitized = ensureLeadingSlash(path.trim());
|
||||
try {
|
||||
window.sessionStorage.setItem(LAST_DESTINATION_KEY, sanitized);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to store last destination', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeLastDestination(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = window.sessionStorage.getItem(LAST_DESTINATION_KEY);
|
||||
if (value) {
|
||||
window.sessionStorage.removeItem(LAST_DESTINATION_KEY);
|
||||
return ensureLeadingSlash(value);
|
||||
}
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to read last destination', error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_HOME_PATH } from '../constants';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const { completeLogin } = useAuth();
|
||||
@@ -19,7 +19,7 @@ export default function AuthCallbackPage() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
completeLogin(params)
|
||||
.then((redirectTo) => {
|
||||
navigate(redirectTo ?? ADMIN_HOME_PATH, { replace: true });
|
||||
navigate(redirectTo ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH, { replace: true });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Auth] Callback processing failed', err);
|
||||
@@ -40,4 +40,3 @@ export default function AuthCallbackPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,204 +1,223 @@
|
||||
import React from 'react';
|
||||
import { Location, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sparkles, ShieldCheck, Images, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_HOME_PATH } from '../constants';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, encodeReturnTo, resolveReturnTarget, storeLastDestination } from '../lib/returnTo';
|
||||
|
||||
interface LocationState {
|
||||
from?: Location;
|
||||
}
|
||||
|
||||
const featureIcons = [Sparkles, ShieldCheck, Images];
|
||||
|
||||
export default function LoginPage(): JSX.Element {
|
||||
const { status, login } = useAuth();
|
||||
const { t } = useTranslation('auth');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
|
||||
const oauthError = searchParams.get('error');
|
||||
const oauthErrorDescription = searchParams.get('error_description');
|
||||
const rawReturnTo = searchParams.get('return_to');
|
||||
const { finalTarget, encodedFinal } = React.useMemo(
|
||||
() => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH),
|
||||
[rawReturnTo]
|
||||
);
|
||||
|
||||
const resolvedErrorMessage = React.useMemo(() => {
|
||||
if (!oauthError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
login_required: t('login.oauth_errors.login_required'),
|
||||
invalid_request: t('login.oauth_errors.invalid_request'),
|
||||
invalid_client: t('login.oauth_errors.invalid_client'),
|
||||
invalid_redirect: t('login.oauth_errors.invalid_redirect'),
|
||||
invalid_scope: t('login.oauth_errors.invalid_scope'),
|
||||
tenant_mismatch: t('login.oauth_errors.tenant_mismatch'),
|
||||
google_failed: t('login.oauth_errors.google_failed'),
|
||||
google_no_match: t('login.oauth_errors.google_no_match'),
|
||||
};
|
||||
|
||||
return errorMap[oauthError] ?? oauthErrorDescription ?? oauthError;
|
||||
}, [oauthError, oauthErrorDescription, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
navigate(ADMIN_HOME_PATH, { replace: true });
|
||||
navigate(finalTarget, { replace: true });
|
||||
}
|
||||
}, [status, navigate]);
|
||||
}, [finalTarget, navigate, status]);
|
||||
|
||||
const redirectTarget = React.useMemo(() => {
|
||||
if (finalTarget) {
|
||||
return finalTarget;
|
||||
}
|
||||
|
||||
const state = location.state as LocationState | null;
|
||||
if (state?.from) {
|
||||
const from = state.from;
|
||||
const search = from.search ?? '';
|
||||
const hash = from.hash ?? '';
|
||||
return `${from.pathname}${search}${hash}`;
|
||||
const path = `${from.pathname}${search}${hash}`;
|
||||
|
||||
return path.startsWith('/') ? path : `/${path}`;
|
||||
}
|
||||
return ADMIN_HOME_PATH;
|
||||
}, [location.state]);
|
||||
|
||||
const featureList = React.useMemo(() => {
|
||||
const raw = t('login.features', { returnObjects: true }) as unknown;
|
||||
if (!Array.isArray(raw)) {
|
||||
return [] as Array<{ text: string; Icon: typeof Sparkles }>;
|
||||
}
|
||||
return (raw as string[]).map((entry, index) => ({
|
||||
text: entry,
|
||||
Icon: featureIcons[index % featureIcons.length],
|
||||
}));
|
||||
}, [t]);
|
||||
|
||||
const heroTagline = t('login.hero_tagline', 'Stay in control, stay relaxed');
|
||||
const heroTitle = t('login.hero_title', 'Your cockpit for every Fotospiel event');
|
||||
const heroSubtitle = t('login.hero_subtitle', 'Moderation, uploads, and communication come together in one calm workspace — on desktop and mobile.');
|
||||
const panelTitle = t('login.panel_title', t('login.title', 'Event Admin'));
|
||||
const leadCopy = t('login.lead', 'Use our secure OAuth login and land directly in the event dashboard.');
|
||||
const panelCopy = t('login.panel_copy', 'Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.');
|
||||
const supportCopy = t('login.support', "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.");
|
||||
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
}, [finalTarget, location.state]);
|
||||
|
||||
const shouldOpenAccountLogin = oauthError === 'login_required';
|
||||
const isLoading = status === 'loading';
|
||||
const hasAutoStartedRef = React.useRef(false);
|
||||
const oauthStartPath = React.useMemo(
|
||||
() => buildAdminOAuthStartPath(redirectTarget, encodedFinal),
|
||||
[encodedFinal, redirectTarget]
|
||||
);
|
||||
const marketingLoginUrl = React.useMemo(() => buildMarketingLoginUrl(oauthStartPath), [oauthStartPath]);
|
||||
|
||||
const hasRedirectedRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!shouldOpenAccountLogin || hasRedirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasRedirectedRef.current = true;
|
||||
window.location.replace(marketingLoginUrl);
|
||||
}, [marketingLoginUrl, shouldOpenAccountLogin]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status !== 'unauthenticated' || oauthError || hasAutoStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasAutoStartedRef.current = true;
|
||||
storeLastDestination(redirectTarget);
|
||||
login(redirectTarget);
|
||||
}, [login, oauthError, redirectTarget, status]);
|
||||
|
||||
const googleHref = React.useMemo(() => {
|
||||
const target = new URL('/event-admin/auth/google', window.location.origin);
|
||||
target.searchParams.set('return_to', encodeReturnTo(oauthStartPath));
|
||||
|
||||
return target.toString();
|
||||
}, [oauthStartPath]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-svh overflow-hidden bg-slate-950 text-white">
|
||||
<div className="relative min-h-svh overflow-hidden bg-slate-950 px-4 py-16 text-white">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.35),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.35),_transparent_55%)] blur-3xl"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-950 via-gray-950/70 to-[#1a0f1f]" aria-hidden />
|
||||
|
||||
<div className="relative z-10 flex min-h-svh flex-col">
|
||||
<header className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 pt-10 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/15 shadow-lg shadow-black/10 backdrop-blur">
|
||||
<AppLogoIcon className="h-7 w-7 text-white" />
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-white/70">{t('login.badge', 'Fotospiel Event Admin')}</p>
|
||||
<p className="text-lg font-semibold">Fotospiel</p>
|
||||
</div>
|
||||
</div>
|
||||
<AppearanceToggleDropdown />
|
||||
</header>
|
||||
<div className="relative z-10 mx-auto flex w-full max-w-md flex-col items-center gap-8">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/15 shadow-lg shadow-black/20">
|
||||
<AppLogoIcon className="h-7 w-7 text-white" />
|
||||
</span>
|
||||
<h1 className="text-2xl font-semibold">{t('login.panel_title', t('login.title', 'Event Admin'))}</h1>
|
||||
<p className="max-w-sm text-sm text-white/70">
|
||||
{t('login.panel_copy', 'Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col px-4 pb-16 pt-12 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 space-y-5 text-center md:hidden">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/25 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-white/70">
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden />
|
||||
{heroTagline}
|
||||
</span>
|
||||
<h1 className="text-3xl font-semibold leading-tight sm:text-4xl">{heroTitle}</h1>
|
||||
<p className="mx-auto max-w-xl text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
|
||||
{resolvedErrorMessage ? (
|
||||
<Alert className="w-full border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
|
||||
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
|
||||
<AlertDescription>{resolvedErrorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="w-full space-y-4 rounded-3xl border border-white/10 bg-white/95 p-8 text-slate-900 shadow-2xl shadow-rose-400/10 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-50">
|
||||
<div className="space-y-2 text-left">
|
||||
<h2 className="text-xl font-semibold">{t('login.actions_title', 'Choose your sign-in method')}</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-300">
|
||||
{t('login.actions_copy', 'Access the tenant dashboard securely with OAuth or your Google account.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 gap-10 md:grid-cols-[1.08fr_1fr]" data-testid="tenant-login-layout">
|
||||
<section className="relative hidden h-full flex-col justify-between gap-10 overflow-hidden rounded-3xl border border-white/15 bg-gradient-to-br from-[#1d1937] via-[#2a1134] to-[#ff5f87] p-10 md:flex">
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 opacity-40">
|
||||
<div className="absolute -inset-16 bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.5),_transparent_55%),radial-gradient(circle_at_bottom_left,_rgba(236,72,153,0.35),_transparent_60%)]" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
size="lg"
|
||||
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-8 py-3 text-base font-semibold text-white shadow-lg shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] focus-visible:ring-4 focus-visible:ring-rose-400/40"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (shouldOpenAccountLogin) {
|
||||
window.location.href = marketingLoginUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
<div className="relative z-10 flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.45em] text-white/70">
|
||||
<Sparkles className="h-4 w-4" aria-hidden />
|
||||
<span className="font-sans-marketing">{heroTagline}</span>
|
||||
</div>
|
||||
storeLastDestination(redirectTarget);
|
||||
login(redirectTarget);
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t('login.loading', 'Signing you in …')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{shouldOpenAccountLogin
|
||||
? t('login.open_account_login')
|
||||
: t('login.cta', 'Continue with Fotospiel login')}
|
||||
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-display text-3xl leading-tight sm:text-4xl">{heroTitle}</h2>
|
||||
<p className="text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
|
||||
</div>
|
||||
|
||||
{featureList.length ? (
|
||||
<ul className="space-y-4">
|
||||
{featureList.map(({ text, Icon }, index) => (
|
||||
<li key={`login-feature-desktop-${index}`} className="flex items-start gap-3">
|
||||
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15 backdrop-blur">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<span className="space-y-1">
|
||||
<p className="text-sm font-semibold tracking-tight sm:text-base">{text}</p>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="relative z-10 flex items-center gap-2 text-xs font-medium text-white/75">
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
{leadCopy}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="relative">
|
||||
<div className="absolute inset-0 -translate-y-4 translate-x-4 scale-95 rounded-3xl bg-white/20 opacity-45 blur-2xl" aria-hidden />
|
||||
<div className="relative flex h-full flex-col justify-between gap-6 overflow-hidden rounded-3xl border border-white/15 bg-white/95 p-8 text-slate-900 shadow-2xl shadow-rose-400/20 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-50">
|
||||
<div className="space-y-3">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200/50 bg-rose-50/70 px-3 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-rose-500/80 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200/80">
|
||||
{t('login.badge', 'Fotospiel Event Admin')}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold">{panelTitle}</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">{panelCopy}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{oauthError ? (
|
||||
<Alert className="border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
|
||||
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
|
||||
<AlertDescription>{t('login.oauth_error', { message: oauthError })}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-8 py-3 text-base font-semibold text-white shadow-lg shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] focus-visible:ring-4 focus-visible:ring-rose-400/40"
|
||||
disabled={isLoading}
|
||||
onClick={() => login(redirectTarget)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t('login.loading', 'Signing you in …')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('login.cta', 'Continue with Fotospiel login')}
|
||||
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2 text-xs leading-relaxed text-slate-500 dark:text-slate-300">
|
||||
<p>{leadCopy}</p>
|
||||
<p>{supportCopy}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="flex w-full items-center justify-center gap-3 rounded-full border-slate-200 bg-white text-slate-900 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-50 dark:hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
window.location.href = googleHref;
|
||||
}}
|
||||
>
|
||||
<GoogleIcon className="h-5 w-5" />
|
||||
{t('login.google_cta', 'Continue with Google')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{featureList.length ? (
|
||||
<div className="mt-10 grid gap-4 md:hidden">
|
||||
{featureList.map(({ text, Icon }, index) => (
|
||||
<div
|
||||
key={`login-feature-mobile-${index}`}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/15 bg-white/10 p-4 text-sm text-white/85 shadow-lg shadow-black/15 backdrop-blur"
|
||||
>
|
||||
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-300">
|
||||
{t('login.support', "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{redirectTarget !== ADMIN_DEFAULT_AFTER_LOGIN_PATH ? (
|
||||
<p className="text-center text-xs text-white/50">{t('login.return_hint')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleIcon({ className }: { className?: string }): JSX.Element {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M23.52 12.272c0-.851-.076-1.67-.217-2.455H12v4.639h6.44a5.51 5.51 0 0 1-2.393 3.622v3.01h3.88c2.271-2.093 3.593-5.18 3.593-8.816Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 24c3.24 0 5.957-1.073 7.943-2.912l-3.88-3.01c-1.073.72-2.446 1.147-4.063 1.147-3.124 0-5.773-2.111-6.717-4.954H1.245v3.11C3.221 21.64 7.272 24 12 24Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.283 14.27a7.2 7.2 0 0 1 0-4.54V6.62H1.245a11.996 11.996 0 0 0 0 10.76l4.038-3.11Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 4.75c1.761 0 3.344.606 4.595 1.794l3.447-3.447C17.957 1.012 15.24 0 12 0 7.272 0 3.221 2.36 1.245 6.62l4.038 3.11C6.227 6.861 8.876 4.75 12 4.75Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
55
resources/js/admin/pages/LoginStartPage.tsx
Normal file
55
resources/js/admin/pages/LoginStartPage.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, resolveReturnTarget, storeLastDestination } from '../lib/returnTo';
|
||||
|
||||
export default function LoginStartPage(): JSX.Element {
|
||||
const { status, login } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
|
||||
const rawReturnTo = searchParams.get('return_to');
|
||||
const { finalTarget, encodedFinal } = React.useMemo(
|
||||
() => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH),
|
||||
[rawReturnTo]
|
||||
);
|
||||
|
||||
const [hasStarted, setHasStarted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
navigate(finalTarget, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasStarted || status === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasStarted(true);
|
||||
storeLastDestination(finalTarget);
|
||||
login(finalTarget);
|
||||
}, [finalTarget, hasStarted, login, navigate, status]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status !== 'unauthenticated' || !hasStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oauthStartPath = buildAdminOAuthStartPath(finalTarget, encodedFinal);
|
||||
const marketingLoginUrl = buildMarketingLoginUrl(oauthStartPath);
|
||||
window.location.replace(marketingLoginUrl);
|
||||
}, [encodedFinal, finalTarget, hasStarted, status]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 p-6 text-center text-white/70">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" aria-hidden />
|
||||
<p className="text-sm font-medium">Melde dich an …</p>
|
||||
<p className="max-w-xs text-xs text-white/50">Wir verbinden dich automatisch mit deinem Event-Dashboard.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
421
resources/js/admin/pages/ProfilePage.tsx
Normal file
421
resources/js/admin/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import React from 'react';
|
||||
import { Loader2, ShieldCheck, ShieldX, Mail, User as UserIcon, Globe, Lock } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { useAuth } from '../auth/context';
|
||||
import {
|
||||
fetchTenantProfile,
|
||||
updateTenantProfile,
|
||||
type TenantAccountProfile,
|
||||
type UpdateTenantProfilePayload,
|
||||
} from '../api';
|
||||
import { getApiErrorMessage, isApiError } from '../lib/apiError';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
type FieldErrors = Record<string, string>;
|
||||
|
||||
function extractFieldErrors(error: unknown): FieldErrors {
|
||||
if (isApiError(error) && error.meta && typeof error.meta.errors === 'object') {
|
||||
const entries = error.meta.errors as Record<string, unknown>;
|
||||
const mapped: FieldErrors = {};
|
||||
|
||||
Object.entries(entries).forEach(([key, value]) => {
|
||||
if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string') {
|
||||
mapped[key] = value[0];
|
||||
}
|
||||
});
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
const DEFAULT_LOCALES = ['de', 'en'];
|
||||
const AUTO_LOCALE_OPTION = '__auto__';
|
||||
|
||||
export default function ProfilePage(): JSX.Element {
|
||||
const { t } = useTranslation(['settings', 'common']);
|
||||
const { refreshProfile } = useAuth();
|
||||
|
||||
const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
const [infoForm, setInfoForm] = React.useState({
|
||||
name: '',
|
||||
email: '',
|
||||
preferred_locale: '',
|
||||
});
|
||||
|
||||
const [passwordForm, setPasswordForm] = React.useState({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const [infoErrors, setInfoErrors] = React.useState<FieldErrors>({});
|
||||
const [passwordErrors, setPasswordErrors] = React.useState<FieldErrors>({});
|
||||
const [savingInfo, setSavingInfo] = React.useState(false);
|
||||
const [savingPassword, setSavingPassword] = React.useState(false);
|
||||
|
||||
const availableLocales = React.useMemo(() => {
|
||||
const candidates = new Set(DEFAULT_LOCALES);
|
||||
if (typeof document !== 'undefined') {
|
||||
const lang = document.documentElement.lang;
|
||||
if (lang) {
|
||||
const short = lang.toLowerCase().split('-')[0];
|
||||
candidates.add(short);
|
||||
}
|
||||
}
|
||||
if (profile?.preferred_locale) {
|
||||
candidates.add(profile.preferred_locale.toLowerCase());
|
||||
}
|
||||
return Array.from(candidates).sort();
|
||||
}, [profile?.preferred_locale]);
|
||||
|
||||
const selectedLocale = infoForm.preferred_locale && infoForm.preferred_locale !== '' ? infoForm.preferred_locale : AUTO_LOCALE_OPTION;
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadProfile(): Promise<void> {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchTenantProfile();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setProfile(data);
|
||||
setInfoForm({
|
||||
name: data.name ?? '',
|
||||
email: data.email ?? '',
|
||||
preferred_locale: data.preferred_locale ?? '',
|
||||
});
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
toast.error(getApiErrorMessage(error, t('settings:profile.errors.load', 'Profil konnte nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadProfile();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const handleInfoSubmit = React.useCallback(
|
||||
async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setInfoErrors({});
|
||||
setSavingInfo(true);
|
||||
|
||||
const payload: UpdateTenantProfilePayload = {
|
||||
name: infoForm.name,
|
||||
email: infoForm.email,
|
||||
preferred_locale: infoForm.preferred_locale || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const updated = await updateTenantProfile(payload);
|
||||
setProfile(updated);
|
||||
toast.success(t('settings:profile.toasts.updated', 'Profil wurde aktualisiert.'));
|
||||
setInfoForm({
|
||||
name: updated.name ?? '',
|
||||
email: updated.email ?? '',
|
||||
preferred_locale: updated.preferred_locale ?? '',
|
||||
});
|
||||
setPasswordForm((prev) => ({ ...prev, current_password: '' }));
|
||||
await refreshProfile();
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, t('settings:profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
|
||||
toast.error(message);
|
||||
const fieldErrors = extractFieldErrors(error);
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
setInfoErrors(fieldErrors);
|
||||
}
|
||||
} finally {
|
||||
setSavingInfo(false);
|
||||
}
|
||||
},
|
||||
[infoForm.email, infoForm.name, infoForm.preferred_locale, refreshProfile, t]
|
||||
);
|
||||
|
||||
const handlePasswordSubmit = React.useCallback(
|
||||
async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPasswordErrors({});
|
||||
setSavingPassword(true);
|
||||
|
||||
const payload: UpdateTenantProfilePayload = {
|
||||
name: infoForm.name,
|
||||
email: infoForm.email,
|
||||
preferred_locale: infoForm.preferred_locale || null,
|
||||
current_password: passwordForm.current_password || undefined,
|
||||
password: passwordForm.password || undefined,
|
||||
password_confirmation: passwordForm.password_confirmation || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const updated = await updateTenantProfile(payload);
|
||||
setProfile(updated);
|
||||
toast.success(t('settings:profile.toasts.passwordChanged', 'Passwort wurde aktualisiert.'));
|
||||
setPasswordForm({ current_password: '', password: '', password_confirmation: '' });
|
||||
await refreshProfile();
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, t('settings:profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
|
||||
toast.error(message);
|
||||
const fieldErrors = extractFieldErrors(error);
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
setPasswordErrors(fieldErrors);
|
||||
}
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
},
|
||||
[infoForm.email, infoForm.name, infoForm.preferred_locale, passwordForm, refreshProfile, t]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout title={t('settings:profile.title', 'Profil')} subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}>
|
||||
<div className="flex min-h-[320px] items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t('common:loading', 'Wird geladen …')}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<AdminLayout title={t('settings:profile.title', 'Profil')} subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}>
|
||||
<div className="rounded-3xl border border-rose-200/60 bg-rose-50/70 p-8 text-center text-sm text-rose-600">
|
||||
{t('settings:profile.errors.load', 'Profil konnte nicht geladen werden.')}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('settings:profile.title', 'Profil')}
|
||||
subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}
|
||||
>
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-rose-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<UserIcon className="h-5 w-5 text-rose-500" />
|
||||
{t('settings:profile.sections.account.heading', 'Account-Informationen')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('settings:profile.sections.account.description', 'Passe Name, E-Mail und Sprache deiner Admin-Oberfläche an.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleInfoSubmit} className="space-y-6">
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
|
||||
<UserIcon className="h-4 w-4 text-rose-500" />
|
||||
{t('settings:profile.fields.name', 'Anzeigename')}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={infoForm.name}
|
||||
onChange={(event) => setInfoForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder={t('settings:profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')}
|
||||
/>
|
||||
{infoErrors.name && <p className="text-sm text-rose-500">{infoErrors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-email" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
|
||||
<Mail className="h-4 w-4 text-rose-500" />
|
||||
{t('settings:profile.fields.email', 'E-Mail-Adresse')}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
value={infoForm.email}
|
||||
onChange={(event) => setInfoForm((prev) => ({ ...prev, email: event.target.value }))}
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
{infoErrors.email && <p className="text-sm text-rose-500">{infoErrors.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-locale" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
|
||||
<Globe className="h-4 w-4 text-rose-500" />
|
||||
{t('settings:profile.fields.locale', 'Bevorzugte Sprache')}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedLocale}
|
||||
onValueChange={(value) =>
|
||||
setInfoForm((prev) => ({ ...prev, preferred_locale: value === AUTO_LOCALE_OPTION ? '' : value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="profile-locale" aria-label={t('settings:profile.fields.locale', 'Bevorzugte Sprache')}>
|
||||
<SelectValue placeholder={t('settings:profile.placeholders.locale', 'Systemsprache verwenden')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AUTO_LOCALE_OPTION}>{t('settings:profile.locale.auto', 'Automatisch')}</SelectItem>
|
||||
{availableLocales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale} className="capitalize">
|
||||
{locale}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{infoErrors.preferred_locale && <p className="text-sm text-rose-500">{infoErrors.preferred_locale}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-sm font-semibold text-slate-800">
|
||||
{profile.email_verified ? (
|
||||
<>
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-500" />
|
||||
{t('settings:profile.status.emailVerified', 'E-Mail bestätigt')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldX className="h-4 w-4 text-rose-500" />
|
||||
{t('settings:profile.status.emailNotVerified', 'Bestätigung erforderlich')}
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
<div className="rounded-2xl border border-slate-200/70 bg-slate-50/70 p-3 text-sm text-slate-600">
|
||||
{profile.email_verified
|
||||
? t('settings:profile.status.verifiedHint', 'Bestätigt am {{date}}.', {
|
||||
date: profile.email_verified_at ? new Date(profile.email_verified_at).toLocaleString() : '',
|
||||
})
|
||||
: t('settings:profile.status.unverifiedHint', 'Wir senden dir eine neue Bestätigung, sobald du die E-Mail änderst.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="submit" disabled={savingInfo} className="flex items-center gap-2">
|
||||
{savingInfo && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('settings:profile.actions.save', 'Speichern')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
setInfoForm({
|
||||
name: profile.name ?? '',
|
||||
email: profile.email ?? '',
|
||||
preferred_locale: profile.preferred_locale ?? '',
|
||||
});
|
||||
setInfoErrors({});
|
||||
}}
|
||||
>
|
||||
{t('common:actions.reset', 'Zurücksetzen')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-indigo-100/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Lock className="h-5 w-5 text-indigo-500" />
|
||||
{t('settings:profile.sections.password.heading', 'Passwort ändern')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('settings:profile.sections.password.description', 'Wähle ein sicheres Passwort, um dein Admin-Konto zu schützen.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-6">
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-current-password" className="text-sm font-semibold text-slate-800">
|
||||
{t('settings:profile.fields.currentPassword', 'Aktuelles Passwort')}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={passwordForm.current_password}
|
||||
onChange={(event) => setPasswordForm((prev) => ({ ...prev, current_password: event.target.value }))}
|
||||
/>
|
||||
{passwordErrors.current_password && <p className="text-sm text-rose-500">{passwordErrors.current_password}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-new-password" className="text-sm font-semibold text-slate-800">
|
||||
{t('settings:profile.fields.newPassword', 'Neues Passwort')}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={passwordForm.password}
|
||||
onChange={(event) => setPasswordForm((prev) => ({ ...prev, password: event.target.value }))}
|
||||
/>
|
||||
{passwordErrors.password && <p className="text-sm text-rose-500">{passwordErrors.password}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-password-confirmation" className="text-sm font-semibold text-slate-800">
|
||||
{t('settings:profile.fields.passwordConfirmation', 'Passwort bestätigen')}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-password-confirmation"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={passwordForm.password_confirmation}
|
||||
onChange={(event) => setPasswordForm((prev) => ({ ...prev, password_confirmation: event.target.value }))}
|
||||
/>
|
||||
{passwordErrors.password_confirmation && <p className="text-sm text-rose-500">{passwordErrors.password_confirmation}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200/70 bg-slate-50/70 p-4 text-xs text-slate-600">
|
||||
{t('settings:profile.sections.password.hint', 'Dein Passwort muss mindestens 8 Zeichen lang sein und eine Mischung aus Buchstaben und Zahlen enthalten.')}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="submit" variant="secondary" disabled={savingPassword} className="flex items-center gap-2">
|
||||
{savingPassword && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('settings:profile.actions.updatePassword', 'Passwort aktualisieren')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setPasswordForm({ current_password: '', password: '', password_confirmation: '' });
|
||||
setPasswordErrors({});
|
||||
}}
|
||||
>
|
||||
{t('common:actions.reset', 'Zurücksetzen')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, LogOut, Palette } from 'lucide-react';
|
||||
import { AlertTriangle, LogOut, Palette, UserCog } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
@@ -10,7 +10,8 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_LOGIN_PATH } from '../constants';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_PROFILE_PATH } from '../constants';
|
||||
import { buildAdminOAuthStartPath, buildMarketingLoginUrl } from '../lib/returnTo';
|
||||
import {
|
||||
getNotificationPreferences,
|
||||
updateNotificationPreferences,
|
||||
@@ -33,7 +34,10 @@ export default function SettingsPage() {
|
||||
const [notificationMeta, setNotificationMeta] = React.useState<NotificationPreferencesMeta | null>(null);
|
||||
|
||||
function handleLogout() {
|
||||
logout({ redirect: ADMIN_LOGIN_PATH });
|
||||
const targetPath = buildAdminOAuthStartPath(ADMIN_EVENTS_PATH);
|
||||
let marketingUrl = buildMarketingLoginUrl(targetPath);
|
||||
marketingUrl += marketingUrl.includes('?') ? '&reset-auth=1' : '?reset-auth=1';
|
||||
logout({ redirect: marketingUrl });
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -101,6 +105,9 @@ export default function SettingsPage() {
|
||||
<Button variant="destructive" onClick={handleLogout} className="flex items-center gap-2">
|
||||
<LogOut className="h-4 w-4" /> Abmelden
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => navigate(ADMIN_PROFILE_PATH)} className="flex items-center gap-2">
|
||||
<UserCog className="h-4 w-4" /> {t('settings.profile.actions.openProfile', 'Profil bearbeiten')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => navigate(-1)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Sparkles, Users, Camera, Heart, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ADMIN_HOME_PATH } from '../constants';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, resolveReturnTarget } from '../lib/returnTo';
|
||||
|
||||
const highlights = [
|
||||
{
|
||||
@@ -27,8 +25,23 @@ const highlights = [
|
||||
];
|
||||
|
||||
export default function WelcomeTeaserPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const [isRedirecting, setIsRedirecting] = React.useState(false);
|
||||
|
||||
const handleLoginRedirect = React.useCallback(() => {
|
||||
if (isRedirecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRedirecting(true);
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const rawReturnTo = params.get('return_to');
|
||||
const { finalTarget, encodedFinal } = resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH);
|
||||
const oauthStartPath = buildAdminOAuthStartPath(finalTarget, encodedFinal);
|
||||
const marketingLoginUrl = buildMarketingLoginUrl(oauthStartPath);
|
||||
|
||||
window.location.href = marketingLoginUrl;
|
||||
}, [isRedirecting]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 via-white to-sky-50 text-slate-800">
|
||||
@@ -48,9 +61,10 @@ export default function WelcomeTeaserPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex items-center justify-center gap-2 rounded-full border border-transparent bg-white px-6 py-3 text-base font-semibold text-rose-500 shadow-sm transition hover:border-rose-200 hover:text-rose-600"
|
||||
onClick={() => login(ADMIN_HOME_PATH)}
|
||||
onClick={handleLoginRedirect}
|
||||
disabled={isRedirecting}
|
||||
>
|
||||
Ich habe bereits Zugang
|
||||
{isRedirecting ? 'Weiterleitung ...' : 'Ich habe bereits Zugang'}
|
||||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -18,14 +18,19 @@ import TaskCollectionsPage from './pages/TaskCollectionsPage';
|
||||
import EmotionsPage from './pages/EmotionsPage';
|
||||
import AuthCallbackPage from './pages/AuthCallbackPage';
|
||||
import WelcomeTeaserPage from './pages/WelcomeTeaserPage';
|
||||
import LoginStartPage from './pages/LoginStartPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import LogoutPage from './pages/LogoutPage';
|
||||
import { useAuth } from './auth/context';
|
||||
import {
|
||||
ADMIN_BASE_PATH,
|
||||
ADMIN_DEFAULT_AFTER_LOGIN_PATH,
|
||||
ADMIN_HOME_PATH,
|
||||
ADMIN_LOGIN_PATH,
|
||||
ADMIN_LOGIN_START_PATH,
|
||||
ADMIN_PUBLIC_LANDING_PATH,
|
||||
} from './constants';
|
||||
import { consumeLastDestination } from './lib/returnTo';
|
||||
import WelcomeLandingPage from './onboarding/pages/WelcomeLandingPage';
|
||||
import WelcomePackagesPage from './onboarding/pages/WelcomePackagesPage';
|
||||
import WelcomeEventSetupPage from './onboarding/pages/WelcomeEventSetupPage';
|
||||
@@ -44,7 +49,7 @@ function RequireAuth() {
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <Navigate to={ADMIN_LOGIN_PATH} state={{ from: location }} replace />;
|
||||
return <Navigate to={ADMIN_LOGIN_START_PATH} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
@@ -52,6 +57,7 @@ function RequireAuth() {
|
||||
|
||||
function LandingGate() {
|
||||
const { status } = useAuth();
|
||||
const lastDestinationRef = React.useRef<string | null>(null);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
@@ -62,7 +68,11 @@ function LandingGate() {
|
||||
}
|
||||
|
||||
if (status === 'authenticated') {
|
||||
return <Navigate to={ADMIN_HOME_PATH} replace />;
|
||||
if (lastDestinationRef.current === null) {
|
||||
lastDestinationRef.current = consumeLastDestination() ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
}
|
||||
|
||||
return <Navigate to={lastDestinationRef.current ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
|
||||
}
|
||||
|
||||
return <WelcomeTeaserPage />;
|
||||
@@ -75,6 +85,7 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <LandingGate /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'start', element: <LoginStartPage /> },
|
||||
{ path: 'logout', element: <LogoutPage /> },
|
||||
{ path: 'auth/callback', element: <AuthCallbackPage /> },
|
||||
{
|
||||
@@ -96,6 +107,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'emotions', element: <EmotionsPage /> },
|
||||
{ path: 'billing', element: <BillingPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
{ path: 'settings/profile', element: <ProfilePage /> },
|
||||
{ path: 'welcome', element: <WelcomeLandingPage /> },
|
||||
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
|
||||
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> },
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, Sid
|
||||
import { dashboard } from '@/routes';
|
||||
import { type NavItem } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { BookOpen, Folder, LayoutGrid } from 'lucide-react';
|
||||
import { BookOpen, Folder, LayoutGrid, UserRound } from 'lucide-react';
|
||||
import AppLogo from './app-logo';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
@@ -14,6 +14,11 @@ const mainNavItems: NavItem[] = [
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Profil',
|
||||
href: '/profile',
|
||||
icon: UserRound,
|
||||
},
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import AuthLayoutTemplate from '@/layouts/auth/auth-simple-layout';
|
||||
|
||||
export default function AuthLayout({ children, title, description, ...props }: { children: React.ReactNode; title: string; description: string }) {
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
name?: string;
|
||||
logoSrc?: string;
|
||||
logoAlt?: string;
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children, title, description, name, logoSrc, logoAlt }: AuthLayoutProps) {
|
||||
return (
|
||||
<AuthLayoutTemplate title={title} description={description} {...props}>
|
||||
<AuthLayoutTemplate title={title} description={description} name={name} logoSrc={logoSrc} logoAlt={logoAlt}>
|
||||
{children}
|
||||
</AuthLayoutTemplate>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { home, packages } from '@/routes';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Sparkles, Camera, ShieldCheck } from 'lucide-react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
@@ -10,10 +10,14 @@ interface AuthLayoutProps {
|
||||
name?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
logoSrc?: string;
|
||||
logoAlt?: string;
|
||||
}
|
||||
|
||||
export default function AuthSimpleLayout({ children, title, description }: PropsWithChildren<AuthLayoutProps>) {
|
||||
export default function AuthSimpleLayout({ children, title, description, name, logoSrc, logoAlt }: PropsWithChildren<AuthLayoutProps>) {
|
||||
const { t } = useTranslation('auth');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const brandLabel = name ?? 'Fotospiel';
|
||||
|
||||
const highlights = [
|
||||
{
|
||||
@@ -85,7 +89,7 @@ export default function AuthSimpleLayout({ children, title, description }: Props
|
||||
<p>{t('login.hero_footer.subline', 'Entdecke unsere Packages und erlebe Fotospiel live.')}</p>
|
||||
</div>
|
||||
<Button asChild variant="secondary" className="h-10 rounded-full bg-white px-5 text-sm font-semibold text-gray-900 shadow-md shadow-white/30 transition hover:bg-white/90">
|
||||
<Link href={packages()}>{t('login.hero_footer.cta', 'Packages entdecken')}</Link>
|
||||
<Link href={localizedPath('/packages')}>{t('login.hero_footer.cta', 'Packages entdecken')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,12 +98,19 @@ export default function AuthSimpleLayout({ children, title, description }: Props
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-pink-400 via-fuchsia-400 to-sky-400" aria-hidden />
|
||||
<div className="relative z-10 flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<Link href={home()} className="group flex flex-col items-center gap-3 font-medium">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-[#ff8ab4] to-[#a855f7] shadow-lg shadow-pink-400/40 transition duration-300 group-hover:scale-105">
|
||||
<AppLogoIcon className="size-8 fill-white" aria-hidden />
|
||||
</span>
|
||||
<span className="text-2xl font-semibold font-display text-gray-900 dark:text-white">Fotospiel</span>
|
||||
<span className="sr-only">{title}</span>
|
||||
<Link href={localizedPath('/')} className="group flex flex-col items-center gap-3 font-medium">
|
||||
{logoSrc ? (
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt={logoAlt ?? brandLabel}
|
||||
className="h-16 w-auto transition duration-300 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-[#ff8ab4] to-[#a855f7] shadow-lg shadow-pink-400/40 transition duration-300 group-hover:scale-105">
|
||||
<AppLogoIcon className="size-8 fill-white" aria-hidden />
|
||||
</span>
|
||||
)}
|
||||
<span className="text-2xl font-semibold font-display text-gray-900 dark:text-white">{brandLabel}</span>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import InputError from '@/components/input-error';
|
||||
@@ -8,6 +9,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import AppLayout from '@/layouts/app/AppLayout';
|
||||
import { register } from '@/routes';
|
||||
import { request } from '@/routes/password';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
@@ -19,12 +21,15 @@ interface LoginProps {
|
||||
|
||||
export default function Login({ status, canResetPassword }: LoginProps) {
|
||||
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
||||
const [rawReturnTo, setRawReturnTo] = useState<string | null>(null);
|
||||
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
const { data, setData, post, processing, errors, clearErrors } = useForm({
|
||||
email: '',
|
||||
login: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
return_to: '',
|
||||
});
|
||||
|
||||
const submit = (e: FormEvent<HTMLFormElement>) => {
|
||||
@@ -38,6 +43,19 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
||||
const errorKeys = Object.keys(errors);
|
||||
const hasErrors = errorKeys.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
setRawReturnTo(searchParams.get('return_to'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setData('return_to', rawReturnTo ?? '');
|
||||
}, [rawReturnTo, setData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTriedSubmit) {
|
||||
return;
|
||||
@@ -56,42 +74,70 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
||||
}
|
||||
}, [errors, hasTriedSubmit]);
|
||||
|
||||
const googleHref = useMemo(() => {
|
||||
if (!rawReturnTo) {
|
||||
return '/event-admin/auth/google';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
return_to: rawReturnTo,
|
||||
});
|
||||
|
||||
return `/event-admin/auth/google?${params.toString()}`;
|
||||
}, [rawReturnTo]);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRedirectingToGoogle(true);
|
||||
window.location.href = googleHref;
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title={t('login.title')} description={t('login.description')}>
|
||||
<AuthLayout
|
||||
title={t('login.title')}
|
||||
description={t('login.description')}
|
||||
name={t('login.brand', t('login.title'))}
|
||||
logoSrc="/logo-transparent-lg.png"
|
||||
logoAlt={t('login.logo_alt', 'Die Fotospiel.App')}
|
||||
>
|
||||
<Head title={t('login.title')} />
|
||||
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="relative flex flex-col gap-6 overflow-hidden rounded-3xl border border-gray-200/70 bg-white/80 p-6 shadow-xl shadow-rose-200/40 backdrop-blur-sm transition dark:border-gray-800/80 dark:bg-gray-900/70"
|
||||
>
|
||||
<input type="hidden" name="return_to" value={data.return_to ?? ''} />
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-pink-400/80 via-rose-400/70 to-sky-400/70" aria-hidden />
|
||||
|
||||
<div className="grid gap-6 pt-2 sm:pt-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email" className="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
<Label htmlFor="login" className="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{t('login.email')}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
id="login"
|
||||
type="text"
|
||||
name="login"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="email"
|
||||
autoComplete="username"
|
||||
placeholder={t('login.email_placeholder')}
|
||||
value={data.email}
|
||||
value={data.login}
|
||||
onChange={(e) => {
|
||||
setData('email', e.target.value);
|
||||
if (errors.email) {
|
||||
clearErrors('email');
|
||||
setData('login', e.target.value);
|
||||
if (errors.login) {
|
||||
clearErrors('login');
|
||||
}
|
||||
}}
|
||||
className="h-12 rounded-xl border-gray-200/80 bg-white/90 px-4 text-base shadow-inner shadow-gray-200/40 focus-visible:ring-[#ff8bb1]/40 dark:border-gray-800/70 dark:bg-gray-900/60 dark:text-gray-100"
|
||||
/>
|
||||
<InputError
|
||||
key={`error-email`}
|
||||
message={errors.email}
|
||||
key={`error-login`}
|
||||
message={errors.login}
|
||||
className="text-sm font-medium text-rose-600 dark:text-rose-400"
|
||||
/>
|
||||
</div>
|
||||
@@ -178,6 +224,32 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
|
||||
<span className="h-px flex-1 bg-gray-200 dark:bg-gray-800" aria-hidden />
|
||||
{t('login.oauth_divider', 'oder')}
|
||||
<span className="h-px flex-1 bg-gray-200 dark:bg-gray-800" aria-hidden />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={processing || isRedirectingToGoogle}
|
||||
className="flex h-12 w-full items-center justify-center gap-3 rounded-xl border-gray-200/80 bg-white/90 text-sm font-semibold text-gray-700 shadow-inner shadow-gray-200/40 transition hover:bg-white dark:border-gray-800/70 dark:bg-gray-900/60 dark:text-gray-100 dark:hover:bg-gray-900/80"
|
||||
>
|
||||
{isRedirectingToGoogle ? (
|
||||
<LoaderCircle className="h-5 w-5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<GoogleIcon className="h-5 w-5" />
|
||||
)}
|
||||
<span>{t('login.google_cta')}</span>
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground dark:text-gray-300">
|
||||
{t('login.google_helper', 'Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200/60 bg-gray-50/80 p-4 text-center text-sm text-muted-foreground shadow-inner dark:border-gray-800/70 dark:bg-gray-900/60">
|
||||
{t('login.no_account')}{' '}
|
||||
<TextLink
|
||||
@@ -192,3 +264,28 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Login.layout = (page: ReactNode) => <AppLayout header={<></>}>{page}</AppLayout>;
|
||||
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M23.52 12.272c0-.851-.076-1.67-.217-2.455H12v4.639h6.44a5.51 5.51 0 0 1-2.393 3.622v3.01h3.88c2.271-2.093 3.593-5.18 3.593-8.816Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 24c3.24 0 5.957-1.073 7.943-2.912l-3.88-3.01c-1.073.72-2.446 1.147-4.063 1.147-3.124 0-5.773-2.111-6.717-4.954H1.245v3.11C3.221 21.64 7.272 24 12 24Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.283 14.27a7.2 7.2 0 0 1 0-4.54V6.62H1.245a11.996 11.996 0 0 0 0 10.76l4.038-3.11Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 4.75c1.761 0 3.344.606 4.595 1.794l3.447-3.447C17.957 1.012 15.24 0 12 0 7.272 0 3.221 2.36 1.245 6.62l4.038 3.11C6.227 6.861 8.876 4.75 12 4.75Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,85 @@
|
||||
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CalendarDays, Camera, ClipboardList, Package, Sparkles, TrendingUp, UserRound, Key } from 'lucide-react';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { dashboard } from '@/routes';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { edit as passwordSettings } from '@/routes/password';
|
||||
import profileRoutes from '@/routes/profile';
|
||||
import { send as resendVerificationRoute } from '@/routes/verification';
|
||||
import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
|
||||
type DashboardMetrics = {
|
||||
total_events: number;
|
||||
active_events: number;
|
||||
published_events: number;
|
||||
events_with_tasks: number;
|
||||
upcoming_events: number;
|
||||
new_photos: number;
|
||||
task_progress: number;
|
||||
credit_balance: number | null;
|
||||
active_package: {
|
||||
name: string;
|
||||
expires_at: string | null;
|
||||
remaining_events: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type DashboardEvent = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
status: string | null;
|
||||
isActive: boolean;
|
||||
date: string | null;
|
||||
photosCount: number;
|
||||
tasksCount: number;
|
||||
joinTokensCount: number;
|
||||
};
|
||||
|
||||
type DashboardPurchase = {
|
||||
id: number;
|
||||
packageName: string;
|
||||
price: number | null;
|
||||
purchasedAt: string | null;
|
||||
type: string | null;
|
||||
provider: string | null;
|
||||
};
|
||||
|
||||
type TenantSummary = {
|
||||
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;
|
||||
|
||||
type DashboardPageProps = {
|
||||
metrics: DashboardMetrics | null;
|
||||
upcomingEvents: DashboardEvent[];
|
||||
recentPurchases: DashboardPurchase[];
|
||||
latestPurchase: DashboardPurchase | null;
|
||||
tenant: TenantSummary;
|
||||
emailVerification: {
|
||||
mustVerify: boolean;
|
||||
verified: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
@@ -12,24 +89,393 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale } = usePage<SharedData & DashboardPageProps>().props;
|
||||
const [verificationSent, setVerificationSent] = useState(false);
|
||||
const [sendingVerification, setSendingVerification] = useState(false);
|
||||
|
||||
const needsEmailVerification = emailVerification.mustVerify && !emailVerification.verified;
|
||||
|
||||
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}), [locale]);
|
||||
|
||||
const currencyFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 2,
|
||||
}), [locale]);
|
||||
|
||||
const taskProgress = metrics?.task_progress ?? 0;
|
||||
|
||||
const checklistItems = [
|
||||
{
|
||||
key: 'verify-email',
|
||||
title: 'E-Mail-Adresse bestätigen',
|
||||
description: 'Bestätige deine Adresse, um Einladungen zu versenden und Benachrichtigungen zu erhalten.',
|
||||
done: !needsEmailVerification,
|
||||
},
|
||||
{
|
||||
key: 'create-event',
|
||||
title: 'Dein erstes Event erstellen',
|
||||
description: 'Starte mit einem Event-Blueprint und passe Agenda, Uploadregeln und Branding an.',
|
||||
done: (metrics?.total_events ?? 0) > 0,
|
||||
},
|
||||
{
|
||||
key: 'publish-event',
|
||||
title: 'Event veröffentlichen',
|
||||
description: 'Schalte dein Event frei, damit Gäste über den Link Fotos hochladen können.',
|
||||
done: (metrics?.published_events ?? 0) > 0,
|
||||
},
|
||||
{
|
||||
key: 'invite-guests',
|
||||
title: 'Gästelink teilen',
|
||||
description: 'Nutze QR-Code oder Link, um Gäste einzuladen und erste Uploads zu sammeln.',
|
||||
done: upcomingEvents.some((event) => event.joinTokensCount > 0),
|
||||
},
|
||||
{
|
||||
key: 'collect-photos',
|
||||
title: 'Fotos sammeln',
|
||||
description: 'Spare Zeit bei der Nachbereitung: neue Uploads erscheinen direkt in deinem Event.',
|
||||
done: (metrics?.new_photos ?? 0) > 0,
|
||||
},
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
key: 'tenant-admin',
|
||||
label: 'Event-Admin öffnen',
|
||||
description: 'Detaillierte Eventverwaltung, Moderation und Live-Features.',
|
||||
href: '/event-admin',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
key: 'profile',
|
||||
label: 'Profil verwalten',
|
||||
description: 'Kontaktinformationen, Sprache und E-Mail-Adresse anpassen.',
|
||||
href: profileRoutes.index().url,
|
||||
icon: UserRound,
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: 'Passwort aktualisieren',
|
||||
description: 'Sichere dein Konto mit einem aktuellen Passwort.',
|
||||
href: passwordSettings().url,
|
||||
icon: Key,
|
||||
},
|
||||
{
|
||||
key: 'packages',
|
||||
label: 'Pakete entdecken',
|
||||
description: 'Mehr Events oder Speicher buchen – du bleibst flexibel.',
|
||||
href: `/${locale ?? 'de'}/packages`,
|
||||
icon: Package,
|
||||
},
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{
|
||||
key: 'active-events',
|
||||
label: 'Aktive Events',
|
||||
value: metrics?.active_events ?? 0,
|
||||
description: metrics?.active_events
|
||||
? 'Events sind live und für Gäste sichtbar.'
|
||||
: 'Noch kein Event veröffentlicht – starte heute!',
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
key: 'upcoming-events',
|
||||
label: 'Bevorstehende Events',
|
||||
value: metrics?.upcoming_events ?? 0,
|
||||
description: metrics?.upcoming_events
|
||||
? 'Planung läuft – behalte Checklisten und Aufgaben im Blick.'
|
||||
: 'Lass dich vom Assistenten beim Planen unterstützen.',
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
key: 'new-photos',
|
||||
label: 'Neue Fotos (7 Tage)',
|
||||
value: metrics?.new_photos ?? 0,
|
||||
description: metrics && metrics.new_photos > 0
|
||||
? 'Frisch eingetroffene Erinnerungen deiner Gäste.'
|
||||
: 'Sammle erste Uploads über QR-Code oder Direktlink.',
|
||||
icon: Camera,
|
||||
},
|
||||
{
|
||||
key: 'credit-balance',
|
||||
label: 'Event Credits',
|
||||
value: tenant?.eventCreditsBalance ?? 0,
|
||||
description: tenant?.eventCreditsBalance
|
||||
? 'Verfügbare Credits für neue Events.'
|
||||
: 'Buche Pakete oder Credits, um weitere Events zu planen.',
|
||||
icon: Package,
|
||||
extra: metrics?.active_package?.remaining_events ?? null,
|
||||
},
|
||||
{
|
||||
key: 'task-progress',
|
||||
label: 'Event-Checkliste',
|
||||
value: `${taskProgress}%`,
|
||||
description: taskProgress > 0
|
||||
? 'Starker Fortschritt! Halte deine Aufgabenliste aktuell.'
|
||||
: 'Nutze Aufgaben und Vorlagen für einen strukturierten Ablauf.',
|
||||
icon: ClipboardList,
|
||||
},
|
||||
];
|
||||
|
||||
const handleResendVerification = () => {
|
||||
setSendingVerification(true);
|
||||
setVerificationSent(false);
|
||||
|
||||
router.post(resendVerificationRoute(), {}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => setVerificationSent(true),
|
||||
onFinish: () => setSendingVerification(false),
|
||||
});
|
||||
};
|
||||
|
||||
const renderPrice = (price: number | null) => {
|
||||
if (price === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
try {
|
||||
return currencyFormatter.format(price);
|
||||
} catch (error) {
|
||||
return `${price.toFixed(2)} €`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—');
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Dashboard" />
|
||||
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
|
||||
<div className="mt-8 flex flex-1 flex-col gap-8 pb-16">
|
||||
{needsEmailVerification && (
|
||||
<Alert variant="warning" className="border-amber-300 bg-amber-100/80 text-amber-950 dark:border-amber-600 dark:bg-amber-900/30 dark:text-amber-100">
|
||||
<AlertTriangle className="size-5" />
|
||||
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<AlertTitle>Bitte bestätige deine E-Mail-Adresse</AlertTitle>
|
||||
<AlertDescription>
|
||||
Du kannst alle Funktionen erst vollständig nutzen, sobald deine E-Mail-Adresse bestätigt ist.
|
||||
Prüfe dein Postfach oder fordere einen neuen Link an.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:items-end">
|
||||
<Button size="sm" variant="outline" onClick={handleResendVerification} disabled={sendingVerification}>
|
||||
{sendingVerification ? 'Sende...' : 'Link erneut senden'}
|
||||
</Button>
|
||||
{verificationSent && <span className="text-xs text-muted-foreground">Wir haben dir gerade einen neuen Bestätigungslink geschickt.</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.key} className="bg-gradient-to-br from-background to-muted/60 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 pb-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
|
||||
<div className="mt-2 text-3xl font-semibold">{stat.value}</div>
|
||||
</div>
|
||||
<span className="rounded-full border bg-background/70 p-2 text-muted-foreground">
|
||||
<stat.icon className="size-5" />
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>{stat.description}</p>
|
||||
{stat.key === 'credit-balance' && stat.extra !== null && (
|
||||
<div className="text-xs text-muted-foreground">{stat.extra} weitere Events im aktuellen Paket enthalten.</div>
|
||||
)}
|
||||
{stat.key === 'task-progress' && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={taskProgress} />
|
||||
<span className="text-xs text-muted-foreground">{taskProgress}% deiner Event-Checkliste erledigt.</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Bevorstehende Events</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Status, Uploads und Aufgaben deiner nächsten Events im Überblick.</p>
|
||||
</div>
|
||||
<Badge variant={upcomingEvents.length > 0 ? 'secondary' : 'outline'}>
|
||||
{upcomingEvents.length > 0 ? `${upcomingEvents.length} geplant` : 'Noch kein Event geplant'}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{upcomingEvents.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Plane dein erstes Event und begleite den gesamten Ablauf – vom Briefing bis zur Nachbereitung – direkt hier im Dashboard.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upcomingEvents.map((event) => (
|
||||
<div key={event.id} className="rounded-lg border border-border/60 bg-background/80 p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold leading-tight">{event.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(event.date)} · {event.status === 'published' || event.isActive ? 'Live' : 'In Vorbereitung'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-full bg-muted px-2 py-1">{event.photosCount} Fotos</span>
|
||||
<span className="rounded-full bg-muted px-2 py-1">{event.tasksCount} Aufgaben</span>
|
||||
<span className="rounded-full bg-muted px-2 py-1">{event.joinTokensCount} Links</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Nächstes Paket & Credits</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Behalte Laufzeiten und verfügbaren Umfang stets im Blick.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{tenant?.activePackage ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{tenant.activePackage.name}</span>
|
||||
<Badge variant="outline">{tenant.activePackage.remainingEvents ?? 0} Events übrig</Badge>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Läuft ab</span>
|
||||
<span>{formatDate(tenant.activePackage.expiresAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Preis</span>
|
||||
<span>{renderPrice(tenant.activePackage.price)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{latestPurchase && (
|
||||
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
|
||||
Zuletzt gebucht am {formatDate(latestPurchase.purchasedAt)} via {latestPurchase.provider?.toUpperCase() ?? 'Checkout'}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground">
|
||||
Noch kein aktives Paket. <Link href={`/${locale ?? 'de'}/packages`} className="font-medium underline underline-offset-4">Jetzt Paket auswählen</Link> und direkt Events planen.
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Event Credits insgesamt</span>
|
||||
<span className="font-medium">{tenant?.eventCreditsBalance ?? 0}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Credits werden bei neuen Events automatisch verbraucht. Zusätzliche Kontingente kannst du jederzeit buchen.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dein Start in 5 Schritten</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Folge den wichtigsten Schritten, um dein Event reibungslos aufzusetzen.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-3">
|
||||
{checklistItems.map((item) => (
|
||||
<div key={item.key} className="flex gap-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<Checkbox checked={item.done} className="mt-1" readOnly />
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-tight">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Aktuelle Buchungen</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Verfolge deine gebuchten Pakete und Erweiterungen.</p>
|
||||
</div>
|
||||
<Badge variant="outline">{recentPurchases.length} Einträge</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentPurchases.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/30 p-6 text-center text-sm text-muted-foreground">
|
||||
Noch keine Buchungen sichtbar. Starte jetzt und sichere dir dein erstes Kontingent.
|
||||
</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>
|
||||
{recentPurchases.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">{renderPrice(purchase.price)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Schnellzugriff</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{quickActions.map((action) => (
|
||||
<div key={action.key} className="flex flex-col justify-between gap-3 rounded-lg border border-border/60 bg-background/60 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded-full bg-muted p-2 text-muted-foreground">
|
||||
<action.icon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{action.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{action.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={action.href} prefetch>
|
||||
Weiter
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
1
resources/js/types/index.d.ts
vendored
1
resources/js/types/index.d.ts
vendored
@@ -28,6 +28,7 @@ export interface SharedData {
|
||||
auth: Auth;
|
||||
sidebarOpen: boolean;
|
||||
supportedLocales?: string[];
|
||||
locale?: string;
|
||||
security?: {
|
||||
csp?: {
|
||||
scriptNonce?: string;
|
||||
|
||||
Reference in New Issue
Block a user