- Tenant-Admin-PWA: Neues /event-admin/welcome Onboarding mit WelcomeHero, Packages-, Order-Summary- und Event-Setup-Pages, Zustandsspeicher, Routing-Guard und Dashboard-CTA für Erstnutzer; Filament-/admin-Login via Custom-View behoben.
- Brand/Theming: Marketing-Farb- und Typographievariablen in `resources/css/app.css` eingeführt, AdminLayout, Dashboardkarten und Onboarding-Komponenten entsprechend angepasst; Dokumentation (`docs/todo/tenant-admin-onboarding-fusion.md`, `docs/changes/...`) aktualisiert. - Checkout & Payments: Checkout-, PayPal-Controller und Tests für integrierte Stripe/PayPal-Flows sowie Paket-Billing-Abläufe überarbeitet; neue PayPal SDK-Factory und Admin-API-Helper (`resources/js/admin/api.ts`) schaffen Grundlage für Billing/Members/Tasks-Seiten. - DX & Tests: Neue Playwright/E2E-Struktur (docs/testing/e2e.md, `tests/e2e/tenant-onboarding-flow.test.ts`, Utilities), E2E-Tenant-Seeder und zusätzliche Übersetzungen/Factories zur Unterstützung der neuen Flows. - Marketing-Kommunikation: Automatische Kontakt-Bestätigungsmail (`ContactConfirmation` + Blade-Template) implementiert; Guest-PWA unter `/event` erreichbar. - Nebensitzung: Blogsystem gefixt und umfassenden BlogPostSeeder für Beispielinhalte angelegt.
This commit is contained in:
@@ -38,6 +38,92 @@ export type EventStats = {
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type PaginationMeta = {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type PaginatedResult<T> = {
|
||||
data: T[];
|
||||
meta: PaginationMeta;
|
||||
};
|
||||
|
||||
export type DashboardSummary = {
|
||||
active_events: number;
|
||||
new_photos: number;
|
||||
task_progress: number;
|
||||
credit_balance?: number | null;
|
||||
upcoming_events?: number | null;
|
||||
active_package?: {
|
||||
name: string;
|
||||
expires_at?: string | null;
|
||||
remaining_events?: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type TenantPackageSummary = {
|
||||
id: number;
|
||||
package_id: number;
|
||||
package_name: string;
|
||||
active: boolean;
|
||||
used_events: number;
|
||||
remaining_events: number | null;
|
||||
price: number | null;
|
||||
currency: string | null;
|
||||
purchased_at: string | null;
|
||||
expires_at: string | null;
|
||||
package_limits: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type CreditBalance = {
|
||||
balance: number;
|
||||
free_event_granted_at?: string | null;
|
||||
};
|
||||
|
||||
export type CreditLedgerEntry = {
|
||||
id: number;
|
||||
delta: number;
|
||||
reason: string;
|
||||
note: string | null;
|
||||
related_purchase_id: number | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TenantTask = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent' | null;
|
||||
due_date: string | null;
|
||||
is_completed: boolean;
|
||||
collection_id: number | null;
|
||||
assigned_events_count: number;
|
||||
assigned_events?: TenantEvent[];
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type TaskPayload = Partial<{
|
||||
title: string;
|
||||
description: string | null;
|
||||
collection_id: number | null;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
due_date: string | null;
|
||||
is_completed: boolean;
|
||||
}>;
|
||||
|
||||
export type EventMember = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
role: 'tenant_admin' | 'member' | 'guest' | string;
|
||||
status?: 'pending' | 'active' | 'invited' | string;
|
||||
joined_at?: string | null;
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
|
||||
type EventListResponse = { data?: TenantEvent[] };
|
||||
type EventResponse = { data: TenantEvent };
|
||||
type CreatedEventResponse = { message: string; data: TenantEvent; balance: number };
|
||||
@@ -49,6 +135,7 @@ type EventSavePayload = {
|
||||
date?: string;
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
is_active?: boolean;
|
||||
package_id?: number;
|
||||
};
|
||||
|
||||
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
|
||||
@@ -69,6 +156,16 @@ async function safeJson(response: Response): Promise<JsonValue | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildPagination(payload: JsonValue | null, defaultCount: number): PaginationMeta {
|
||||
const meta = (payload?.meta as Partial<PaginationMeta>) ?? {};
|
||||
return {
|
||||
current_page: Number(meta.current_page ?? payload?.current_page ?? 1),
|
||||
last_page: Number(meta.last_page ?? payload?.last_page ?? 1),
|
||||
per_page: Number(meta.per_page ?? payload?.per_page ?? defaultCount ?? 0) || defaultCount || 0,
|
||||
total: Number(meta.total ?? payload?.total ?? defaultCount ?? 0) || defaultCount || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event: TenantEvent): TenantEvent {
|
||||
return {
|
||||
...event,
|
||||
@@ -93,6 +190,72 @@ function normalizePhoto(photo: TenantPhoto): TenantPhoto {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDashboard(payload: JsonValue | null): DashboardSummary | null {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
active_events: Number(payload.active_events ?? payload.activeEvents ?? 0),
|
||||
new_photos: Number(payload.new_photos ?? payload.newPhotos ?? 0),
|
||||
task_progress: Number(payload.task_progress ?? payload.taskProgress ?? 0),
|
||||
credit_balance: payload.credit_balance ?? payload.creditBalance ?? null,
|
||||
upcoming_events: payload.upcoming_events ?? payload.upcomingEvents ?? null,
|
||||
active_package: payload.active_package
|
||||
? {
|
||||
name: String(payload.active_package.name ?? 'Aktives Package'),
|
||||
expires_at: payload.active_package.expires_at ?? null,
|
||||
remaining_events: payload.active_package.remaining_events ?? payload.active_package.remainingEvents ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
|
||||
const packageData = pkg.package ?? {};
|
||||
return {
|
||||
id: Number(pkg.id ?? 0),
|
||||
package_id: Number(pkg.package_id ?? packageData.id ?? 0),
|
||||
package_name: String(packageData.name ?? pkg.package_name ?? 'Unbekanntes Package'),
|
||||
active: Boolean(pkg.active ?? false),
|
||||
used_events: Number(pkg.used_events ?? 0),
|
||||
remaining_events: pkg.remaining_events !== undefined ? Number(pkg.remaining_events) : null,
|
||||
price: packageData.price !== undefined ? Number(packageData.price) : pkg.price ?? null,
|
||||
currency: packageData.currency ?? pkg.currency ?? 'EUR',
|
||||
purchased_at: pkg.purchased_at ?? pkg.created_at ?? null,
|
||||
expires_at: pkg.expires_at ?? pkg.valid_until ?? null,
|
||||
package_limits: pkg.package_limits ?? packageData.limits ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTask(task: JsonValue): TenantTask {
|
||||
return {
|
||||
id: Number(task.id ?? 0),
|
||||
title: String(task.title ?? 'Ohne Titel'),
|
||||
description: task.description ?? null,
|
||||
priority: (task.priority ?? null) as TenantTask['priority'],
|
||||
due_date: task.due_date ?? null,
|
||||
is_completed: Boolean(task.is_completed ?? false),
|
||||
collection_id: task.collection_id ?? null,
|
||||
assigned_events_count: Number(task.assigned_events_count ?? 0),
|
||||
assigned_events: Array.isArray(task.assigned_events) ? task.assigned_events.map(normalizeEvent) : undefined,
|
||||
created_at: task.created_at ?? null,
|
||||
updated_at: task.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMember(member: JsonValue): EventMember {
|
||||
return {
|
||||
id: Number(member.id ?? 0),
|
||||
name: String(member.name ?? member.email ?? 'Unbekannt'),
|
||||
email: member.email ?? null,
|
||||
role: (member.role ?? 'member') as EventMember['role'],
|
||||
status: member.status ?? 'active',
|
||||
joined_at: member.joined_at ?? member.created_at ?? null,
|
||||
avatar_url: member.avatar_url ?? member.avatar ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function eventEndpoint(slug: string): string {
|
||||
return `/api/v1/tenant/events/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
@@ -194,3 +357,356 @@ export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustome
|
||||
const data = await jsonOrThrow<{ data: Package[] }>(response, 'Failed to load packages');
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
type TenantPackagesResponse = {
|
||||
data?: JsonValue[];
|
||||
active_package?: JsonValue | null;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type LedgerResponse = {
|
||||
data?: JsonValue[];
|
||||
meta?: Partial<PaginationMeta>;
|
||||
current_page?: number;
|
||||
last_page?: number;
|
||||
per_page?: number;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
type TaskCollectionResponse = {
|
||||
data?: JsonValue[];
|
||||
meta?: Partial<PaginationMeta>;
|
||||
current_page?: number;
|
||||
last_page?: number;
|
||||
per_page?: number;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
type MemberResponse = {
|
||||
data?: JsonValue[];
|
||||
meta?: Partial<PaginationMeta>;
|
||||
current_page?: number;
|
||||
last_page?: number;
|
||||
per_page?: number;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
async function fetchTenantPackagesEndpoint(): Promise<Response> {
|
||||
const first = await authorizedFetch('/api/v1/tenant/tenant/packages');
|
||||
if (first.status === 404) {
|
||||
return authorizedFetch('/api/v1/tenant/packages');
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
export async function getDashboardSummary(): Promise<DashboardSummary | null> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/dashboard');
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to load dashboard', response.status, payload);
|
||||
throw new Error('Failed to load dashboard');
|
||||
}
|
||||
const json = (await response.json()) as JsonValue;
|
||||
return normalizeDashboard(json);
|
||||
}
|
||||
|
||||
export async function getTenantPackagesOverview(): Promise<{
|
||||
packages: TenantPackageSummary[];
|
||||
activePackage: TenantPackageSummary | null;
|
||||
}> {
|
||||
const response = await fetchTenantPackagesEndpoint();
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to load tenant packages', response.status, payload);
|
||||
throw new Error('Failed to load tenant packages');
|
||||
}
|
||||
const data = (await response.json()) as TenantPackagesResponse;
|
||||
const packages = Array.isArray(data.data) ? data.data.map(normalizeTenantPackage) : [];
|
||||
const activePackage = data.active_package ? normalizeTenantPackage(data.active_package) : null;
|
||||
return { packages, activePackage };
|
||||
}
|
||||
|
||||
export async function getCreditBalance(): Promise<CreditBalance> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/credits/balance');
|
||||
if (response.status === 404) {
|
||||
return { balance: 0 };
|
||||
}
|
||||
const data = await jsonOrThrow<CreditBalance>(response, 'Failed to load credit balance');
|
||||
return { balance: Number(data.balance ?? 0), free_event_granted_at: data.free_event_granted_at ?? null };
|
||||
}
|
||||
|
||||
export async function getCreditLedger(page = 1): Promise<PaginatedResult<CreditLedgerEntry>> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/credits/ledger?page=${page}`);
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to load credit ledger', response.status, payload);
|
||||
throw new Error('Failed to load credit ledger');
|
||||
}
|
||||
const json = (await response.json()) as LedgerResponse;
|
||||
const entries = Array.isArray(json.data) ? json.data.map((entry) => ({
|
||||
id: Number(entry.id ?? 0),
|
||||
delta: Number(entry.delta ?? 0),
|
||||
reason: String(entry.reason ?? 'unknown'),
|
||||
note: entry.note ?? null,
|
||||
related_purchase_id: entry.related_purchase_id ?? null,
|
||||
created_at: entry.created_at ?? '',
|
||||
})) : [];
|
||||
return {
|
||||
data: entries,
|
||||
meta: buildPagination(json as JsonValue, entries.length),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTenantPackagePaymentIntent(packageId: number): Promise<string> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/packages/payment-intent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ package_id: packageId }),
|
||||
});
|
||||
|
||||
const data = await jsonOrThrow<{ client_secret: string }>(
|
||||
response,
|
||||
'Failed to create package payment intent'
|
||||
);
|
||||
|
||||
if (!data.client_secret) {
|
||||
throw new Error('Missing client secret in response');
|
||||
}
|
||||
|
||||
return data.client_secret;
|
||||
}
|
||||
|
||||
export async function completeTenantPackagePurchase(params: {
|
||||
packageId: number;
|
||||
paymentMethodId?: string;
|
||||
paypalOrderId?: string;
|
||||
}): Promise<void> {
|
||||
const { packageId, paymentMethodId, paypalOrderId } = params;
|
||||
const payload: Record<string, unknown> = { package_id: packageId };
|
||||
|
||||
if (paymentMethodId) {
|
||||
payload.payment_method_id = paymentMethodId;
|
||||
}
|
||||
|
||||
if (paypalOrderId) {
|
||||
payload.paypal_order_id = paypalOrderId;
|
||||
}
|
||||
|
||||
const response = await authorizedFetch('/api/v1/tenant/packages/complete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
await jsonOrThrow(response, 'Failed to complete package purchase');
|
||||
}
|
||||
|
||||
export async function assignFreeTenantPackage(packageId: number): Promise<void> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/packages/free', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ package_id: packageId }),
|
||||
});
|
||||
|
||||
await jsonOrThrow(response, 'Failed to assign free package');
|
||||
}
|
||||
|
||||
export async function createTenantPayPalOrder(packageId: number): Promise<string> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/packages/paypal-create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ package_id: packageId }),
|
||||
});
|
||||
|
||||
const data = await jsonOrThrow<{ orderID: string }>(response, 'Failed to create PayPal order');
|
||||
if (!data.orderID) {
|
||||
throw new Error('Missing PayPal order ID');
|
||||
}
|
||||
|
||||
return data.orderID;
|
||||
}
|
||||
|
||||
export async function captureTenantPayPalOrder(orderId: string): Promise<void> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/packages/paypal-capture', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ order_id: orderId }),
|
||||
});
|
||||
|
||||
await jsonOrThrow(response, 'Failed to capture PayPal order');
|
||||
}
|
||||
|
||||
export async function recordCreditPurchase(payload: {
|
||||
package_id: string;
|
||||
credits_added: number;
|
||||
platform?: string;
|
||||
transaction_id?: string;
|
||||
subscription_active?: boolean;
|
||||
}): Promise<CreditBalance> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/credits/purchase', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await jsonOrThrow<{ message: string; balance: number; subscription_active?: boolean }>(
|
||||
response,
|
||||
'Failed to record credit purchase'
|
||||
);
|
||||
return { balance: Number(data.balance ?? 0) };
|
||||
}
|
||||
|
||||
export async function syncCreditBalance(payload: {
|
||||
balance: number;
|
||||
subscription_active?: boolean;
|
||||
last_sync?: string;
|
||||
}): Promise<{ balance: number; subscription_active: boolean; server_time: string }> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/credits/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return jsonOrThrow(response, 'Failed to sync credit balance');
|
||||
}
|
||||
|
||||
export async function getTasks(params: { page?: number; per_page?: number; search?: string } = {}): Promise<PaginatedResult<TenantTask>> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set('page', String(params.page));
|
||||
if (params.per_page) searchParams.set('per_page', String(params.per_page));
|
||||
if (params.search) searchParams.set('search', params.search);
|
||||
|
||||
const response = await authorizedFetch(`/api/v1/tenant/tasks?${searchParams.toString()}`);
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to load tasks', response.status, payload);
|
||||
throw new Error('Failed to load tasks');
|
||||
}
|
||||
const json = (await response.json()) as TaskCollectionResponse;
|
||||
const tasks = Array.isArray(json.data) ? json.data.map(normalizeTask) : [];
|
||||
return {
|
||||
data: tasks,
|
||||
meta: buildPagination(json as JsonValue, tasks.length),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTask(payload: TaskPayload): Promise<TenantTask> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create task');
|
||||
return normalizeTask(data.data);
|
||||
}
|
||||
|
||||
export async function updateTask(taskId: number, payload: TaskPayload): Promise<TenantTask> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to update task');
|
||||
return normalizeTask(data.data);
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId: number): Promise<void> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/tasks/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to delete task', response.status, payload);
|
||||
throw new Error('Failed to delete task');
|
||||
}
|
||||
}
|
||||
|
||||
export async function assignTasksToEvent(eventId: number, taskIds: number[]): Promise<void> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/tasks/bulk-assign-event/${eventId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_ids: taskIds }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to assign tasks', response.status, payload);
|
||||
throw new Error('Failed to assign tasks');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEventTasks(eventId: number, page = 1): Promise<PaginatedResult<TenantTask>> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}?page=${page}`);
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to load event tasks', response.status, payload);
|
||||
throw new Error('Failed to load event tasks');
|
||||
}
|
||||
const json = (await response.json()) as TaskCollectionResponse;
|
||||
const tasks = Array.isArray(json.data) ? json.data.map(normalizeTask) : [];
|
||||
return {
|
||||
data: tasks,
|
||||
meta: buildPagination(json as JsonValue, tasks.length),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEventMembers(eventIdentifier: number | string, page = 1): Promise<PaginatedResult<EventMember>> {
|
||||
const response = await authorizedFetch(
|
||||
`/api/v1/tenant/events/${encodeURIComponent(String(eventIdentifier))}/members?page=${page}`
|
||||
);
|
||||
if (response.status === 404) {
|
||||
return { data: [], meta: { current_page: 1, last_page: 1, per_page: 0, total: 0 } };
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to load event members', response.status, payload);
|
||||
throw new Error('Failed to load event members');
|
||||
}
|
||||
const json = (await response.json()) as MemberResponse;
|
||||
const members = Array.isArray(json.data) ? json.data.map(normalizeMember) : [];
|
||||
return {
|
||||
data: members,
|
||||
meta: buildPagination(json as JsonValue, members.length),
|
||||
};
|
||||
}
|
||||
|
||||
export async function inviteEventMember(eventIdentifier: number | string, payload: { email: string; role?: string; name?: string }): Promise<EventMember> {
|
||||
const response = await authorizedFetch(
|
||||
`/api/v1/tenant/events/${encodeURIComponent(String(eventIdentifier))}/members`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
if (response.status === 404) {
|
||||
throw new Error('Mitgliederverwaltung ist fuer dieses Event noch nicht verfuegbar.');
|
||||
}
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to invite member');
|
||||
return normalizeMember(data.data);
|
||||
}
|
||||
|
||||
export async function removeEventMember(eventIdentifier: number | string, memberId: number): Promise<void> {
|
||||
const response = await authorizedFetch(
|
||||
`/api/v1/tenant/events/${encodeURIComponent(String(eventIdentifier))}/members/${memberId}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (response.status === 404) {
|
||||
throw new Error('Mitglied konnte nicht gefunden werden.');
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to remove member', response.status, payload);
|
||||
throw new Error('Failed to remove member');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_SETTINGS_PATH } from '../constants';
|
||||
import {
|
||||
ADMIN_HOME_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_TASKS_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
} from '../constants';
|
||||
|
||||
const navItems = [
|
||||
{ to: ADMIN_HOME_PATH, label: 'Dashboard', end: true },
|
||||
{ to: ADMIN_EVENTS_PATH, label: 'Events' },
|
||||
{ to: ADMIN_TASKS_PATH, label: 'Tasks' },
|
||||
{ to: ADMIN_BILLING_PATH, label: 'Billing' },
|
||||
{ to: ADMIN_SETTINGS_PATH, label: 'Einstellungen' },
|
||||
];
|
||||
|
||||
@@ -24,27 +33,28 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-100 via-amber-50 to-sky-100 text-slate-900">
|
||||
<header className="border-b border-white/60 bg-white/80 shadow-sm backdrop-blur-md">
|
||||
<div className="min-h-screen bg-brand-gradient text-brand-slate">
|
||||
<header className="border-b border-brand-rose-soft bg-brand-card/90 shadow-brand-primary backdrop-blur-md">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-6 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-pink-600">Fotospiel Tenant Admin</p>
|
||||
<h1 className="text-3xl font-semibold text-slate-900">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-slate-600">{subtitle}</p>}
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">Fotospiel Tenant Admin</p>
|
||||
<h1 className="font-display text-3xl font-semibold text-brand-slate">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm font-sans-marketing text-brand-navy/75">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap gap-2">{actions}</div>}
|
||||
</div>
|
||||
<nav className="mx-auto flex w-full max-w-6xl gap-3 px-6 pb-4 text-sm font-medium">
|
||||
<nav className="mx-auto flex w-full max-w-6xl gap-3 px-6 pb-4 text-sm font-medium text-brand-navy/80">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'rounded-full px-4 py-2 transition-colors',
|
||||
isActive
|
||||
? 'bg-pink-500 text-white shadow-md shadow-pink-500/30'
|
||||
: 'bg-white/70 text-slate-600 hover:bg-white hover:text-slate-900'
|
||||
? 'bg-brand-rose text-white shadow-md shadow-rose-400/40'
|
||||
: 'bg-white/70 text-brand-navy/80 hover:bg-white hover:text-brand-slate'
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -7,3 +7,17 @@ export const ADMIN_LOGIN_PATH = adminPath('/login');
|
||||
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_TASKS_PATH = adminPath('/tasks');
|
||||
export const ADMIN_BILLING_PATH = adminPath('/billing');
|
||||
export const ADMIN_PHOTOS_PATH = adminPath('/photos');
|
||||
export const ADMIN_WELCOME_BASE_PATH = adminPath('/welcome');
|
||||
export const ADMIN_WELCOME_PACKAGES_PATH = adminPath('/welcome/packages');
|
||||
export const ADMIN_WELCOME_SUMMARY_PATH = adminPath('/welcome/summary');
|
||||
export const ADMIN_WELCOME_EVENT_PATH = adminPath('/welcome/event');
|
||||
export const ADMIN_EVENT_CREATE_PATH = adminPath('/events/new');
|
||||
|
||||
export const ADMIN_EVENT_VIEW_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}`);
|
||||
export const ADMIN_EVENT_EDIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/edit`);
|
||||
export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photos`);
|
||||
export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`);
|
||||
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`);
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from './auth/context';
|
||||
import { router } from './router';
|
||||
import '../../css/app.css';
|
||||
import { initializeTheme } from '@/hooks/use-appearance.tsx';
|
||||
import { initializeTheme } from '@/hooks/use-appearance';
|
||||
import { OnboardingProgressProvider } from './onboarding';
|
||||
|
||||
initializeTheme();
|
||||
const rootEl = document.getElementById('root')!;
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<OnboardingProgressProvider>
|
||||
<RouterProvider router={router} />
|
||||
</OnboardingProgressProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface OnboardingAction {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
icon?: LucideIcon;
|
||||
variant?: 'primary' | 'secondary';
|
||||
disabled?: boolean;
|
||||
buttonLabel?: string;
|
||||
}
|
||||
|
||||
interface OnboardingCTAListProps {
|
||||
actions: OnboardingAction[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OnboardingCTAList({ actions, className }: OnboardingCTAListProps) {
|
||||
if (!actions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-4 md:grid-cols-2', className)}>
|
||||
{actions.map(({ id, label, description, href, onClick, icon: Icon, variant = 'primary', disabled, buttonLabel }) => (
|
||||
<div
|
||||
key={id}
|
||||
className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-5 shadow-brand-primary backdrop-blur"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && (
|
||||
<span className="flex size-10 items-center justify-center rounded-full bg-brand-rose-soft text-brand-rose shadow-inner">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
<span className="text-base font-semibold text-brand-slate">{label}</span>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-brand-navy/80">{description}</p>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
className={cn(
|
||||
'w-full rounded-full transition-all',
|
||||
variant === 'secondary'
|
||||
? 'bg-brand-gold text-brand-slate shadow-md shadow-amber-200/40 hover:bg-[var(--brand-gold-soft)]'
|
||||
: 'bg-brand-rose text-white shadow-md shadow-rose-400/30 hover:bg-[var(--brand-rose-strong)]'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
{...(href ? { asChild: true } : {})}
|
||||
>
|
||||
{href ? <a href={href}>{buttonLabel ?? label}</a> : buttonLabel ?? label}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
OnboardingCTAList.displayName = 'OnboardingCTAList';
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface HighlightItem {
|
||||
id: string;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
interface OnboardingHighlightsGridProps {
|
||||
items: HighlightItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OnboardingHighlightsGrid({ items, className }: OnboardingHighlightsGridProps) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-4 md:grid-cols-3', className)}>
|
||||
{items.map(({ id, icon: Icon, title, description, badge }) => (
|
||||
<Card
|
||||
key={id}
|
||||
className="relative overflow-hidden rounded-3xl border border-white/70 bg-white/90 shadow-xl shadow-rose-100/40"
|
||||
>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex size-12 items-center justify-center rounded-full bg-rose-100 text-rose-500 shadow-inner">
|
||||
<Icon className="size-6" />
|
||||
</span>
|
||||
{badge && (
|
||||
<span className="rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-rose-500">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-lg font-semibold text-slate-900">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-600">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
OnboardingHighlightsGrid.displayName = 'OnboardingHighlightsGrid';
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TenantWelcomeLayoutProps {
|
||||
eyebrow?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
headerAction?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TenantWelcomeLayout({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
headerAction,
|
||||
footer,
|
||||
children,
|
||||
}: TenantWelcomeLayoutProps) {
|
||||
React.useEffect(() => {
|
||||
document.body.classList.add('tenant-admin-theme', 'tenant-admin-welcome-theme');
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('tenant-admin-theme', 'tenant-admin-welcome-theme');
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-brand-gradient text-brand-slate transition-colors duration-500 ease-out">
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-5xl px-6 py-12 md:py-16 lg:px-10">
|
||||
<div className="flex w-full flex-col gap-10 rounded-[40px] border border-brand-rose-soft bg-brand-card p-8 shadow-brand-primary backdrop-blur-xl md:gap-14 md:p-14">
|
||||
<header className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-xl">
|
||||
{eyebrow && (
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">{eyebrow}</p>
|
||||
)}
|
||||
{title && (
|
||||
<h1 className="mt-2 font-display text-4xl font-semibold tracking-tight text-brand-slate md:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="mt-4 text-base font-sans-marketing text-brand-navy/80 md:text-lg">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{headerAction && <div className="flex shrink-0 items-center">{headerAction}</div>}
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 flex-col gap-8">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{footer && (
|
||||
<footer className="flex flex-col items-center gap-4 text-sm text-brand-navy/70 md:flex-row md:justify-between">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TenantWelcomeLayout.displayName = 'TenantWelcomeLayout';
|
||||
93
resources/js/admin/onboarding/components/WelcomeHero.tsx
Normal file
93
resources/js/admin/onboarding/components/WelcomeHero.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ActionProps {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
icon?: LucideIcon;
|
||||
variant?: 'default' | 'outline';
|
||||
}
|
||||
|
||||
export interface WelcomeHeroProps {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
scriptTitle?: string;
|
||||
description?: string;
|
||||
actions?: ActionProps[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WelcomeHero({
|
||||
eyebrow,
|
||||
title,
|
||||
scriptTitle,
|
||||
description,
|
||||
actions = [],
|
||||
className,
|
||||
}: WelcomeHeroProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'rounded-3xl border border-brand-rose-soft bg-brand-card p-8 shadow-brand-primary backdrop-blur-xl',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4 text-center md:space-y-6">
|
||||
{eyebrow && (
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose md:text-sm">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="font-display text-3xl font-semibold tracking-tight text-brand-slate md:text-4xl">
|
||||
{title}
|
||||
</h2>
|
||||
{scriptTitle && (
|
||||
<p className="font-script text-2xl text-brand-rose md:text-3xl">
|
||||
{scriptTitle}
|
||||
</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mx-auto max-w-2xl text-base font-sans-marketing text-brand-navy/80 md:text-lg">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{actions.length > 0 && (
|
||||
<div className="flex flex-col items-center gap-3 pt-4 md:flex-row md:justify-center">
|
||||
{actions.map(({ label, onClick, href, icon: Icon, variant = 'default' }) => (
|
||||
<Button
|
||||
key={label}
|
||||
size="lg"
|
||||
variant={variant === 'outline' ? 'outline' : 'default'}
|
||||
className={cn(
|
||||
'min-w-[220px] rounded-full px-6',
|
||||
variant === 'outline'
|
||||
? 'border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40'
|
||||
: 'bg-brand-rose text-white shadow-md shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]'
|
||||
)}
|
||||
onClick={onClick}
|
||||
{...(href ? { asChild: true } : {})}
|
||||
>
|
||||
{href ? (
|
||||
<a href={href} className="flex items-center justify-center gap-2">
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
WelcomeHero.displayName = 'WelcomeHero';
|
||||
65
resources/js/admin/onboarding/components/WelcomeStepCard.tsx
Normal file
65
resources/js/admin/onboarding/components/WelcomeStepCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface WelcomeStepCardProps {
|
||||
step: number;
|
||||
totalSteps: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WelcomeStepCard({
|
||||
step,
|
||||
totalSteps,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
}: WelcomeStepCardProps) {
|
||||
const progress = Math.min(Math.max(step, 1), totalSteps);
|
||||
const percent = totalSteps <= 1 ? 100 : Math.round((progress / totalSteps) * 100);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-3xl border border-brand-rose-soft bg-brand-card shadow-brand-primary',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-brand-rose-soft via-[var(--brand-gold-soft)] to-brand-sky-soft" />
|
||||
<CardHeader className="space-y-4 pt-8">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.4em] text-brand-rose">
|
||||
Step {progress} / {totalSteps}
|
||||
</span>
|
||||
<div className="w-28">
|
||||
<Progress value={percent} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
{Icon && (
|
||||
<span className="flex size-12 items-center justify-center rounded-full bg-brand-rose-soft text-brand-rose shadow-inner">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="font-display text-2xl font-semibold text-brand-slate md:text-3xl">{title}</CardTitle>
|
||||
{description && (
|
||||
<CardDescription className="text-base font-sans-marketing text-brand-navy/80">{description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pb-10">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
WelcomeStepCard.displayName = 'WelcomeStepCard';
|
||||
48
resources/js/admin/onboarding/hooks/useTenantPackages.ts
Normal file
48
resources/js/admin/onboarding/hooks/useTenantPackages.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { getTenantPackagesOverview, getPackages, Package, TenantPackageSummary } from '../../api';
|
||||
|
||||
export type TenantPackagesState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| {
|
||||
status: 'success';
|
||||
catalog: Package[];
|
||||
activePackage: TenantPackageSummary | null;
|
||||
purchasedPackages: TenantPackageSummary[];
|
||||
};
|
||||
|
||||
export function useTenantPackages(): TenantPackagesState {
|
||||
const [state, setState] = React.useState<TenantPackagesState>({ status: 'loading' });
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [tenantPackages, catalog] = await Promise.all([
|
||||
getTenantPackagesOverview(),
|
||||
getPackages('endcustomer'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
status: 'success',
|
||||
catalog,
|
||||
activePackage: tenantPackages.activePackage,
|
||||
purchasedPackages: tenantPackages.packages,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[useTenantPackages] Failed to fetch', error);
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
status: 'error',
|
||||
message: 'Pakete konnten nicht geladen werden. Bitte später erneut versuchen.',
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
7
resources/js/admin/onboarding/index.ts
Normal file
7
resources/js/admin/onboarding/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './store';
|
||||
export * from './components/TenantWelcomeLayout';
|
||||
export * from './components/WelcomeHero';
|
||||
export * from './components/WelcomeStepCard';
|
||||
export * from './components/OnboardingCTAList';
|
||||
export * from './components/OnboardingHighlightsGrid';
|
||||
export * from './hooks/useTenantPackages';
|
||||
116
resources/js/admin/onboarding/pages/WelcomeEventSetupPage.tsx
Normal file
116
resources/js/admin/onboarding/pages/WelcomeEventSetupPage.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ClipboardCheck, Sparkles, Globe, ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
TenantWelcomeLayout,
|
||||
WelcomeStepCard,
|
||||
OnboardingCTAList,
|
||||
useOnboardingProgress,
|
||||
} from '..';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENTS_PATH, ADMIN_HOME_PATH } from '../../constants';
|
||||
|
||||
export default function WelcomeEventSetupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { markStep } = useOnboardingProgress();
|
||||
|
||||
React.useEffect(() => {
|
||||
markStep({ lastStep: 'event-setup' });
|
||||
}, [markStep]);
|
||||
|
||||
return (
|
||||
<TenantWelcomeLayout
|
||||
eyebrow="Schritt 4"
|
||||
title="Bereite dein erstes Event vor"
|
||||
subtitle="F<>lle wenige Details aus, lade Co-Hosts ein und <20>ffne deine G<>stegalerie f<>r das gro<72>e Ereignis."
|
||||
>
|
||||
<WelcomeStepCard
|
||||
step={4}
|
||||
totalSteps={4}
|
||||
title="Event-Setup in Minuten"
|
||||
description="Wir f<>hren dich durch Name, Datum, Mood und Aufgaben. Danach kannst du Fotos moderieren und G<>ste live begleiten."
|
||||
icon={ClipboardCheck}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
id: 'story',
|
||||
title: 'Story & Stimmung',
|
||||
copy: 'W<>hle Bildsprache, Farben und Emotionskarten f<>r dein Event.',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
title: 'Team organisieren',
|
||||
copy: 'Lade Moderator*innen oder Fotograf*innen ein und teile Rollen zu.',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
id: 'launch',
|
||||
title: 'Go-Live vorbereiten',
|
||||
copy: 'Erstelle QR-Codes, teste die G<>stegalerie und kommuniziere den Ablauf.',
|
||||
icon: ArrowRight,
|
||||
},
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-5 shadow-brand-primary"
|
||||
>
|
||||
<span className="flex size-10 items-center justify-center rounded-full bg-brand-rose-soft text-brand-rose shadow-inner">
|
||||
<item.icon className="size-5" />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-brand-slate">{item.title}</h3>
|
||||
<p className="text-sm text-brand-navy/80">{item.copy}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col items-start gap-3 rounded-3xl border border-brand-rose-soft bg-brand-sky-soft/40 p-6 text-brand-navy">
|
||||
<h4 className="text-lg font-semibold text-brand-rose">Bereit f<EFBFBD>r dein erstes Event?</h4>
|
||||
<p className="text-sm text-brand-navy/80">
|
||||
Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die
|
||||
G<EFBFBD>stegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zur<EFBFBD>ckkehren.
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
className="mt-2 rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
|
||||
onClick={() => {
|
||||
markStep({ lastStep: 'event-create-intent' });
|
||||
navigate(ADMIN_EVENT_CREATE_PATH);
|
||||
}}
|
||||
>
|
||||
Event erstellen
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</WelcomeStepCard>
|
||||
|
||||
<OnboardingCTAList
|
||||
actions={[
|
||||
{
|
||||
id: 'back',
|
||||
label: 'Noch einmal Pakete pr<70>fen',
|
||||
description: 'Vergleiche Preise oder aktualisiere dein derzeitiges Paket.',
|
||||
buttonLabel: 'Zu Paketen',
|
||||
onClick: () => navigate(-1),
|
||||
variant: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Zum Dashboard',
|
||||
description: 'Springe ins Management, um bestehende Events zu bearbeiten.',
|
||||
buttonLabel: 'Dashboard <20>ffnen',
|
||||
onClick: () => navigate(ADMIN_HOME_PATH),
|
||||
},
|
||||
{
|
||||
id: 'events',
|
||||
label: 'Event<6E>bersicht',
|
||||
description: 'Behalte den <20>berblick <20>ber alle aktiven und archivierten Events.',
|
||||
buttonLabel: 'Eventliste',
|
||||
onClick: () => navigate(ADMIN_EVENTS_PATH),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TenantWelcomeLayout>
|
||||
);
|
||||
}
|
||||
114
resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx
Normal file
114
resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Sparkles, Users, Camera, CalendarDays, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
TenantWelcomeLayout,
|
||||
WelcomeHero,
|
||||
OnboardingHighlightsGrid,
|
||||
OnboardingCTAList,
|
||||
useOnboardingProgress,
|
||||
} from "..";
|
||||
import { ADMIN_WELCOME_PACKAGES_PATH, ADMIN_EVENTS_PATH } from "../../constants";
|
||||
|
||||
export default function WelcomeLandingPage() {
|
||||
const navigate = useNavigate();
|
||||
const { markStep } = useOnboardingProgress();
|
||||
|
||||
React.useEffect(() => {
|
||||
markStep({ welcomeSeen: true, lastStep: "landing" });
|
||||
}, [markStep]);
|
||||
|
||||
return (
|
||||
<TenantWelcomeLayout
|
||||
eyebrow="Fotospiel Tenant Admin"
|
||||
title="Willkommen im Event-Erlebnisstudio"
|
||||
subtitle="Starte mit einer inspirierten Einführung, sichere dir dein Event-Paket und kreiere die perfekte Gästegalerie – alles optimiert für mobile Hosts."
|
||||
footer={
|
||||
<>
|
||||
<span className="text-brand-navy/80">Schon vertraut mit Fotospiel?</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-brand-rose hover:text-[var(--brand-rose-strong)]"
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
>
|
||||
Direkt zum Dashboard
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<WelcomeHero
|
||||
eyebrow="Dein Event, deine Bühne"
|
||||
title="Gestalte das nächste Fotospiel Erlebnis"
|
||||
scriptTitle="Einmalig für Gäste, mühelos für dich."
|
||||
description="Mit nur wenigen Schritten führst du deine Gäste durch ein magisches Fotoabenteuer – inklusive Storytelling, Aufgaben und moderierter Galerie."
|
||||
actions={[
|
||||
{
|
||||
label: "Pakete entdecken",
|
||||
buttonLabel: "Pakete entdecken",
|
||||
onClick: () => navigate(ADMIN_WELCOME_PACKAGES_PATH),
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
label: "Events anzeigen",
|
||||
buttonLabel: "Bestehende Events anzeigen",
|
||||
onClick: () => navigate(ADMIN_EVENTS_PATH),
|
||||
icon: CalendarDays,
|
||||
variant: "outline",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<OnboardingHighlightsGrid
|
||||
items={[
|
||||
{
|
||||
id: "gallery",
|
||||
icon: Camera,
|
||||
title: "Premium Gästegalerie",
|
||||
description:
|
||||
"Kuratiere Fotos in Echtzeit, markiere Highlights und teile QR-Codes mit einem Tap.",
|
||||
badge: "Neu",
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
icon: Users,
|
||||
title: "Flexibles Team-Onboarding",
|
||||
description:
|
||||
"Lade Co-Hosts ein, weise Rollen zu und behalte den Überblick über Moderation und Aufgaben.",
|
||||
},
|
||||
{
|
||||
id: "sparkles",
|
||||
icon: Sparkles,
|
||||
title: "Storytelling in Etappen",
|
||||
description:
|
||||
"Geführte Aufgaben und Emotionskarten machen jedes Event zu einer erinnerungswürdigen Reise.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<OnboardingCTAList
|
||||
actions={[
|
||||
{
|
||||
id: "choose-package",
|
||||
label: "Dein Eventpaket auswählen",
|
||||
description:
|
||||
"Reserviere Credits oder Abos, um sofort Events zu aktivieren. Flexible Optionen für jede Eventgröße.",
|
||||
onClick: () => navigate(ADMIN_WELCOME_PACKAGES_PATH),
|
||||
icon: Sparkles,
|
||||
buttonLabel: "Weiter zu Paketen",
|
||||
},
|
||||
{
|
||||
id: "create-event",
|
||||
label: "Event vorbereiten",
|
||||
description:
|
||||
"Sammle Eventdetails, plane Aufgaben und sorge für einen reibungslosen Ablauf noch vor dem Tag des Events.",
|
||||
onClick: () => navigate(ADMIN_EVENTS_PATH),
|
||||
icon: CalendarDays,
|
||||
buttonLabel: "Zum Event-Manager",
|
||||
variant: "secondary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TenantWelcomeLayout>
|
||||
);
|
||||
}
|
||||
579
resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx
Normal file
579
resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx
Normal file
@@ -0,0 +1,579 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Receipt, ShieldCheck, ArrowRight, ArrowLeft, CreditCard, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
TenantWelcomeLayout,
|
||||
WelcomeStepCard,
|
||||
OnboardingCTAList,
|
||||
useOnboardingProgress,
|
||||
} from '..';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from '../../constants';
|
||||
import { useTenantPackages } from '../hooks/useTenantPackages';
|
||||
import {
|
||||
assignFreeTenantPackage,
|
||||
completeTenantPackagePurchase,
|
||||
createTenantPackagePaymentIntent,
|
||||
createTenantPayPalOrder,
|
||||
captureTenantPayPalOrder,
|
||||
} from '../../api';
|
||||
import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { PayPalScriptProvider, PayPalButtons } from '@paypal/react-paypal-js';
|
||||
|
||||
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? '';
|
||||
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? '';
|
||||
const stripePromise = stripePublishableKey ? loadStripe(stripePublishableKey) : null;
|
||||
|
||||
type StripeCheckoutProps = {
|
||||
clientSecret: string;
|
||||
packageId: number;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeCheckoutProps) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!stripe || !elements) {
|
||||
setError('Zahlungsmodul noch nicht bereit. Bitte lade die Seite neu.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const result = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: window.location.href,
|
||||
},
|
||||
redirect: 'if_required',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Zahlung fehlgeschlagen. Bitte erneut versuchen.');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentIntent = result.paymentIntent;
|
||||
const paymentMethodId =
|
||||
typeof paymentIntent?.payment_method === 'string'
|
||||
? paymentIntent.payment_method
|
||||
: typeof paymentIntent?.id === 'string'
|
||||
? paymentIntent.id
|
||||
: null;
|
||||
|
||||
if (!paymentMethodId) {
|
||||
setError('Zahlung konnte nicht bestätigt werden (fehlende Zahlungs-ID).');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await completeTenantPackagePurchase({
|
||||
packageId,
|
||||
paymentMethodId,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (purchaseError) {
|
||||
console.error('[Onboarding] Purchase completion failed', purchaseError);
|
||||
setError(
|
||||
purchaseError instanceof Error
|
||||
? purchaseError.message
|
||||
: 'Der Kauf wurde bei uns noch nicht verbucht. Bitte kontaktiere den Support mit deiner Zahlungsbestätigung.'
|
||||
);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-brand-slate">Kartenzahlung</p>
|
||||
<PaymentElement id="payment-element" />
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Zahlung fehlgeschlagen</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || !stripe || !elements}
|
||||
className="w-full rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)] disabled:opacity-60"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Zahlung wird bestätigt ...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="mr-2 size-4" />
|
||||
Jetzt bezahlen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-brand-navy/70">
|
||||
Sicherer Checkout über Stripe. Du erhältst eine Bestätigung, sobald der Kauf verbucht wurde.
|
||||
</p>
|
||||
<input type="hidden" value={clientSecret} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type PayPalCheckoutProps = {
|
||||
packageId: number;
|
||||
onSuccess: () => void;
|
||||
currency?: string;
|
||||
};
|
||||
|
||||
function PayPalCheckout({ packageId, onSuccess, currency = 'EUR' }: PayPalCheckoutProps) {
|
||||
const [status, setStatus] = React.useState<'idle' | 'creating' | 'capturing' | 'error' | 'success'>('idle');
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const handleCreateOrder = React.useCallback(async () => {
|
||||
try {
|
||||
setStatus('creating');
|
||||
const orderId = await createTenantPayPalOrder(packageId);
|
||||
setStatus('idle');
|
||||
setError(null);
|
||||
return orderId;
|
||||
} catch (err) {
|
||||
console.error('[Onboarding] PayPal create order failed', err);
|
||||
setStatus('error');
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'PayPal-Bestellung konnte nicht erstellt werden. Bitte versuche es erneut.'
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, [packageId]);
|
||||
|
||||
const handleApprove = React.useCallback(
|
||||
async (orderId: string) => {
|
||||
try {
|
||||
setStatus('capturing');
|
||||
await captureTenantPayPalOrder(orderId);
|
||||
setStatus('success');
|
||||
setError(null);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('[Onboarding] PayPal capture failed', err);
|
||||
setStatus('error');
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'PayPal-Zahlung konnte nicht abgeschlossen werden. Bitte kontaktiere den Support, falls der Betrag bereits abgebucht wurde.'
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[onSuccess]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
|
||||
<p className="text-sm font-medium text-brand-slate">PayPal</p>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>PayPal-Fehler</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<PayPalButtons
|
||||
style={{ layout: 'vertical' }}
|
||||
forceReRender={[packageId, currency]}
|
||||
createOrder={async () => handleCreateOrder()}
|
||||
onApprove={async (data) => {
|
||||
if (!data.orderID) {
|
||||
setError('PayPal hat keine Order-ID geliefert.');
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
await handleApprove(data.orderID);
|
||||
}}
|
||||
onError={(err) => {
|
||||
console.error('[Onboarding] PayPal onError', err);
|
||||
setStatus('error');
|
||||
setError('PayPal hat ein Problem gemeldet. Bitte versuche es in wenigen Minuten erneut.');
|
||||
}}
|
||||
onCancel={() => {
|
||||
setStatus('idle');
|
||||
setError('PayPal-Zahlung wurde abgebrochen.');
|
||||
}}
|
||||
disabled={status === 'creating' || status === 'capturing'}
|
||||
/>
|
||||
<p className="text-xs text-brand-navy/70">
|
||||
PayPal leitet dich ggf. kurz weiter, um die Zahlung zu bestätigen. Anschließend wirst du automatisch zum Setup
|
||||
zurückgebracht.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WelcomeOrderSummaryPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { progress, markStep } = useOnboardingProgress();
|
||||
const packagesState = useTenantPackages();
|
||||
|
||||
const packageIdFromState = typeof location.state === 'object' ? (location.state as any)?.packageId : undefined;
|
||||
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedPackageId && packagesState.status !== 'loading') {
|
||||
navigate(ADMIN_WELCOME_PACKAGES_PATH, { replace: true });
|
||||
}
|
||||
}, [selectedPackageId, packagesState.status, navigate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
markStep({ lastStep: 'summary' });
|
||||
}, [markStep]);
|
||||
|
||||
const packageDetails =
|
||||
packagesState.status === 'success' && selectedPackageId
|
||||
? packagesState.catalog.find((pkg) => pkg.id === selectedPackageId)
|
||||
: null;
|
||||
|
||||
const activePackage =
|
||||
packagesState.status === 'success'
|
||||
? packagesState.purchasedPackages.find((pkg) => pkg.package_id === selectedPackageId)
|
||||
: null;
|
||||
|
||||
const isSubscription = Boolean(packageDetails?.features?.subscription);
|
||||
const requiresPayment = Boolean(packageDetails && packageDetails.price > 0);
|
||||
|
||||
const [clientSecret, setClientSecret] = React.useState<string | null>(null);
|
||||
const [intentStatus, setIntentStatus] = React.useState<'idle' | 'loading' | 'error' | 'ready'>('idle');
|
||||
const [intentError, setIntentError] = React.useState<string | null>(null);
|
||||
const [freeAssignStatus, setFreeAssignStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [freeAssignError, setFreeAssignError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!requiresPayment || !packageDetails) {
|
||||
setClientSecret(null);
|
||||
setIntentStatus('idle');
|
||||
setIntentError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stripePromise) {
|
||||
setIntentError('Stripe Publishable Key fehlt. Bitte konfiguriere VITE_STRIPE_PUBLISHABLE_KEY.');
|
||||
setIntentStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIntentStatus('loading');
|
||||
setIntentError(null);
|
||||
createTenantPackagePaymentIntent(packageDetails.id)
|
||||
.then((secret) => {
|
||||
if (cancelled) return;
|
||||
setClientSecret(secret);
|
||||
setIntentStatus('ready');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Onboarding] Failed to create payment intent', error);
|
||||
if (cancelled) return;
|
||||
setIntentError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PaymentIntent konnte nicht erstellt werden. Bitte später erneut versuchen.'
|
||||
);
|
||||
setIntentStatus('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [requiresPayment, packageDetails?.id]);
|
||||
|
||||
const priceText =
|
||||
progress.selectedPackage?.priceText ??
|
||||
(packageDetails && typeof packageDetails.price === 'number'
|
||||
? new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(packageDetails.price)
|
||||
: null);
|
||||
|
||||
return (
|
||||
<TenantWelcomeLayout
|
||||
eyebrow="Schritt 3"
|
||||
title="Bestellübersicht"
|
||||
subtitle="Prüfe Paket, Preis und Abrechnung bevor du zum Event-Setup wechselst."
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-brand-rose hover:text-rose-600"
|
||||
onClick={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Zurück zur Paketauswahl
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<WelcomeStepCard
|
||||
step={3}
|
||||
totalSteps={4}
|
||||
title="Deine Auswahl im Überblick"
|
||||
description="Du kannst sofort an die Abrechnung übergeben oder das Setup fortsetzen und später bezahlen."
|
||||
icon={Receipt}
|
||||
>
|
||||
{packagesState.status === 'loading' && (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-slate-50 p-6 text-sm text-brand-navy/80">
|
||||
<CreditCard className="size-5 text-brand-rose" />
|
||||
Wir prüfen verfügbare Pakete …
|
||||
</div>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'error' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle>Paketdaten derzeit nicht verfügbar</AlertTitle>
|
||||
<AlertDescription>
|
||||
{packagesState.message} Du kannst das Event trotzdem vorbereiten und später zurückkehren.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'success' && !packageDetails && (
|
||||
<Alert>
|
||||
<AlertTitle>Keine Paketauswahl gefunden</AlertTitle>
|
||||
<AlertDescription>
|
||||
Bitte wähle zuerst ein Paket aus oder aktualisiere die Seite, falls sich die Daten geändert haben.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'success' && packageDetails && (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-3xl border border-brand-rose-soft bg-brand-card p-6 shadow-md shadow-rose-100/40">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">
|
||||
{isSubscription ? 'Abo' : 'Credit-Paket'}
|
||||
</p>
|
||||
<h3 className="text-2xl font-semibold text-brand-slate">{packageDetails.name}</h3>
|
||||
</div>
|
||||
{priceText && (
|
||||
<Badge className="rounded-full bg-rose-100 px-4 py-2 text-base font-semibold text-rose-600">
|
||||
{priceText}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<dl className="mt-6 grid gap-3 text-sm text-brand-navy/80 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="font-semibold text-brand-slate">Fotos & Galerie</dt>
|
||||
<dd>
|
||||
{packageDetails.max_photos
|
||||
? `Bis zu ${packageDetails.max_photos} Fotos, Galerie ${packageDetails.gallery_days ?? '∞'} Tage`
|
||||
: 'Unbegrenzte Fotos, flexible Galerie'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-brand-slate">Gäste & Team</dt>
|
||||
<dd>
|
||||
{packageDetails.max_guests
|
||||
? `${packageDetails.max_guests} Gäste inklusive, Co-Hosts frei planbar`
|
||||
: 'Unbegrenzte Gästeliste'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-brand-slate">Highlights</dt>
|
||||
<dd>
|
||||
{Object.entries(packageDetails.features ?? {})
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([feature]) => feature.replace(/_/g, ' '))
|
||||
.join(', ') || 'Standard'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-brand-slate">Status</dt>
|
||||
<dd>{activePackage ? 'Bereits gebucht' : 'Noch nicht gebucht'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{!activePackage && (
|
||||
<Alert className="rounded-2xl border-brand-rose-soft bg-brand-rose-soft/60 text-rose-700">
|
||||
<ShieldCheck className="size-4" />
|
||||
<AlertTitle>Abrechnung steht noch aus</AlertTitle>
|
||||
<AlertDescription>
|
||||
Du kannst das Event bereits vorbereiten. Spätestens zur Veröffentlichung benötigst du ein aktives Paket.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{packageDetails.price === 0 && (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-6">
|
||||
<p className="text-sm text-emerald-700">
|
||||
Dieses Paket ist kostenlos. Du kannst es sofort deinem Tenant zuweisen und direkt mit dem Setup
|
||||
weitermachen.
|
||||
</p>
|
||||
{freeAssignStatus === 'success' ? (
|
||||
<Alert className="mt-4 border-emerald-200 bg-white text-emerald-600">
|
||||
<AlertTitle>Gratis-Paket aktiviert</AlertTitle>
|
||||
<AlertDescription>
|
||||
Deine Credits wurden hinzugefügt. Weiter geht's mit dem Event-Setup.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!packageDetails) {
|
||||
return;
|
||||
}
|
||||
setFreeAssignStatus('loading');
|
||||
setFreeAssignError(null);
|
||||
try {
|
||||
await assignFreeTenantPackage(packageDetails.id);
|
||||
setFreeAssignStatus('success');
|
||||
markStep({ packageSelected: true });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||
} catch (error) {
|
||||
console.error('[Onboarding] Free package assignment failed', error);
|
||||
setFreeAssignStatus('error');
|
||||
setFreeAssignError(
|
||||
error instanceof Error ? error.message : 'Kostenloses Paket konnte nicht aktiviert werden.'
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={freeAssignStatus === 'loading'}
|
||||
className="mt-4 rounded-full bg-brand-teal text-white shadow-md shadow-emerald-300/40 hover:opacity-90"
|
||||
>
|
||||
{freeAssignStatus === 'loading' ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Aktivierung läuft ...
|
||||
</>
|
||||
) : (
|
||||
'Gratis-Paket aktivieren'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{freeAssignStatus === 'error' && freeAssignError && (
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<AlertTitle>Aktivierung fehlgeschlagen</AlertTitle>
|
||||
<AlertDescription>{freeAssignError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requiresPayment && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-brand-slate">Kartenzahlung (Stripe)</h4>
|
||||
{intentStatus === 'loading' && (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 text-sm text-brand-navy/80">
|
||||
<Loader2 className="size-4 animate-spin text-brand-rose" />
|
||||
Zahlungsdetails werden geladen …
|
||||
</div>
|
||||
)}
|
||||
{intentStatus === 'error' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Stripe nicht verfügbar</AlertTitle>
|
||||
<AlertDescription>{intentError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{intentStatus === 'ready' && clientSecret && stripePromise && (
|
||||
<Elements stripe={stripePromise} options={{ clientSecret }}>
|
||||
<StripeCheckoutForm
|
||||
clientSecret={clientSecret}
|
||||
packageId={packageDetails.id}
|
||||
onSuccess={() => {
|
||||
markStep({ packageSelected: true });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||
}}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{paypalClientId ? (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-brand-slate">PayPal</h4>
|
||||
<PayPalScriptProvider
|
||||
options={{ 'client-id': paypalClientId, currency: 'EUR', intent: 'CAPTURE' }}
|
||||
>
|
||||
<PayPalCheckout
|
||||
packageId={packageDetails.id}
|
||||
onSuccess={() => {
|
||||
markStep({ packageSelected: true });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||
}}
|
||||
/>
|
||||
</PayPalScriptProvider>
|
||||
</div>
|
||||
) : (
|
||||
<Alert variant="default" className="border-amber-200 bg-amber-50 text-amber-700">
|
||||
<AlertTitle>PayPal nicht konfiguriert</AlertTitle>
|
||||
<AlertDescription>
|
||||
Hinterlege `VITE_PAYPAL_CLIENT_ID`, damit Gastgeber optional mit PayPal bezahlen können.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-2xl bg-brand-card p-6 shadow-inner shadow-rose-100/40">
|
||||
<h4 className="text-lg font-semibold text-brand-slate">Nächste Schritte</h4>
|
||||
<ol className="list-decimal space-y-2 pl-5 text-sm text-brand-navy/80">
|
||||
<li>Optional: Abrechnung abschließen (Stripe/PayPal) im Billing-Bereich.</li>
|
||||
<li>Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.</li>
|
||||
<li>Vor dem Go-Live Credits prüfen und Gäste-Link teilen.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</WelcomeStepCard>
|
||||
|
||||
<OnboardingCTAList
|
||||
actions={[
|
||||
{
|
||||
id: 'checkout',
|
||||
label: 'Abrechnung starten',
|
||||
description: 'Öffnet den Billing-Bereich mit allen Kaufoptionen (Stripe, PayPal, Credits).',
|
||||
buttonLabel: 'Zu Billing & Zahlung',
|
||||
href: ADMIN_BILLING_PATH,
|
||||
icon: CreditCard,
|
||||
variant: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'continue-to-setup',
|
||||
label: 'Mit Event-Setup fortfahren',
|
||||
description: 'Du kannst später jederzeit zur Abrechnung zurückkehren.',
|
||||
buttonLabel: 'Weiter zum Setup',
|
||||
onClick: () => {
|
||||
markStep({ lastStep: 'event-setup' });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH);
|
||||
},
|
||||
icon: ArrowRight,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TenantWelcomeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
200
resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx
Normal file
200
resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import { CreditCard, ShoppingBag, ArrowRight, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
TenantWelcomeLayout,
|
||||
WelcomeStepCard,
|
||||
OnboardingCTAList,
|
||||
useOnboardingProgress,
|
||||
} from '..';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_SUMMARY_PATH } from '../../constants';
|
||||
import { useTenantPackages } from '../hooks/useTenantPackages';
|
||||
import { Package } from '../../api';
|
||||
|
||||
export default function WelcomePackagesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { markStep } = useOnboardingProgress();
|
||||
const packagesState = useTenantPackages();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (packagesState.status === 'success') {
|
||||
markStep({
|
||||
packageSelected: Boolean(packagesState.activePackage),
|
||||
lastStep: 'packages',
|
||||
selectedPackage: packagesState.activePackage
|
||||
? {
|
||||
id: packagesState.activePackage.package_id,
|
||||
name: packagesState.activePackage.package_name,
|
||||
priceText: packagesState.activePackage.price
|
||||
? new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: packagesState.activePackage.currency ?? 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(packagesState.activePackage.price)
|
||||
: null,
|
||||
isSubscription: false,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}, [packagesState, markStep]);
|
||||
|
||||
const handleSelectPackage = React.useCallback(
|
||||
(pkg: Package) => {
|
||||
const priceText =
|
||||
typeof pkg.price === 'number'
|
||||
? new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(pkg.price)
|
||||
: 'Auf Anfrage';
|
||||
|
||||
markStep({
|
||||
packageSelected: true,
|
||||
lastStep: package-,
|
||||
selectedPackage: {
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
priceText,
|
||||
isSubscription: Boolean(pkg.features?.subscription),
|
||||
},
|
||||
});
|
||||
|
||||
navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } });
|
||||
},
|
||||
[markStep, navigate]
|
||||
);
|
||||
|
||||
return (
|
||||
<TenantWelcomeLayout
|
||||
eyebrow="Schritt 2"
|
||||
title="W<>hle dein Eventpaket"
|
||||
subtitle="Fotospiel unterst<73>tzt flexible Preismodelle: einmalige Credits oder Abos, die mehrere Events abdecken."
|
||||
>
|
||||
<WelcomeStepCard
|
||||
step={2}
|
||||
totalSteps={4}
|
||||
title="Aktiviere die passenden Credits"
|
||||
description="Sichere dir Kapazit<69>t f<>r dein n<>chstes Event. Du kannst jederzeit upgraden <20> bezahle nur, was du wirklich brauchst."
|
||||
icon={CreditCard}
|
||||
>
|
||||
{packagesState.status === 'loading' && (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-brand-sky-soft/60 p-6 text-sm text-brand-navy/80">
|
||||
<Loader2 className="size-5 animate-spin text-brand-rose" />
|
||||
Pakete werden geladen <EFBFBD>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'error' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>Fehler beim Laden</AlertTitle>
|
||||
<AlertDescription>{packagesState.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'success' && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{packagesState.catalog.map((pkg) => {
|
||||
const isActive = packagesState.activePackage?.package_id === pkg.id;
|
||||
const purchased = packagesState.purchasedPackages.find((tenantPkg) => tenantPkg.package_id === pkg.id);
|
||||
const featureLabels = Object.entries(pkg.features ?? {})
|
||||
.filter(([, enabled]) => Boolean(enabled))
|
||||
.map(([key]) => key.replace(/_/g, ' '));
|
||||
|
||||
const isSubscription = Boolean(pkg.features?.subscription);
|
||||
const priceText =
|
||||
typeof pkg.price === 'number'
|
||||
? new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(pkg.price)
|
||||
: 'Auf Anfrage';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pkg.id}
|
||||
className="flex flex-col gap-4 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-brand-rose">
|
||||
{isSubscription ? 'Abo' : 'Credit-Paket'}
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold text-brand-slate">{pkg.name}</h3>
|
||||
</div>
|
||||
<span className="text-lg font-medium text-brand-rose">{priceText}</span>
|
||||
</div>
|
||||
<p className="text-sm text-brand-navy/80">
|
||||
{pkg.max_photos
|
||||
? Bis zu Fotos inklusive <EFBFBD> perfekt f<EFBFBD>r lebendige Reportagen.
|
||||
: 'Sofort einsatzbereit f<>r dein n<>chstes Event.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-wide text-brand-rose">
|
||||
{pkg.max_guests && (
|
||||
<span className="rounded-full bg-brand-rose-soft px-3 py-1">{${pkg.max_guests} G<EFBFBD>ste}</span>
|
||||
)}
|
||||
{pkg.gallery_days && (
|
||||
<span className="rounded-full bg-brand-rose-soft px-3 py-1">{${pkg.gallery_days} Tage Galerie}</span>
|
||||
)}
|
||||
{featureLabels.map((feature) => (
|
||||
<span key={feature} className="rounded-full bg-brand-rose-soft px-3 py-1">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
{isActive && (
|
||||
<span className="rounded-full bg-brand-sky-soft px-3 py-1 text-brand-navy">Aktives Paket</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="mt-auto rounded-full bg-brand-rose text-white shadow-lg shadow-rose-300/40 hover:bg-[var(--brand-rose-strong)]"
|
||||
onClick={() => handleSelectPackage(pkg)}
|
||||
>
|
||||
Paket w<EFBFBD>hlen
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</Button>
|
||||
{purchased && (
|
||||
<p className="text-xs text-brand-rose">
|
||||
Bereits gekauft am{' '}
|
||||
{purchased.purchased_at
|
||||
? new Date(purchased.purchased_at).toLocaleDateString('de-DE')
|
||||
: 'unbekanntem Datum'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</WelcomeStepCard>
|
||||
|
||||
<OnboardingCTAList
|
||||
actions={[
|
||||
{
|
||||
id: 'skip-to-checkout',
|
||||
label: 'Direkt zum Billing',
|
||||
description:
|
||||
'Falls du schon wei<65>t, welches Paket du brauchst, gelangst du hier zum bekannten Abrechnungsbereich.',
|
||||
buttonLabel: 'Billing <20>ffnen',
|
||||
href: ADMIN_BILLING_PATH,
|
||||
icon: ShoppingBag,
|
||||
variant: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'continue',
|
||||
label: 'Bestell<6C>bersicht anzeigen',
|
||||
description: 'Pr<50>fe Paketdetails und entscheide, ob du direkt zahlen oder sp<73>ter fortfahren m<>chtest.',
|
||||
buttonLabel: 'Weiter zur <20>bersicht',
|
||||
onClick: () => navigate(ADMIN_WELCOME_SUMMARY_PATH),
|
||||
icon: ArrowRight,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TenantWelcomeLayout>
|
||||
);
|
||||
}
|
||||
109
resources/js/admin/onboarding/store.tsx
Normal file
109
resources/js/admin/onboarding/store.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
|
||||
export type OnboardingProgress = {
|
||||
welcomeSeen: boolean;
|
||||
packageSelected: boolean;
|
||||
eventCreated: boolean;
|
||||
lastStep?: string | null;
|
||||
selectedPackage?: {
|
||||
id: number;
|
||||
name: string;
|
||||
priceText?: string | null;
|
||||
isSubscription?: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type OnboardingContextValue = {
|
||||
progress: OnboardingProgress;
|
||||
setProgress: (updater: (prev: OnboardingProgress) => OnboardingProgress) => void;
|
||||
markStep: (step: Partial<OnboardingProgress>) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_PROGRESS: OnboardingProgress = {
|
||||
welcomeSeen: false,
|
||||
packageSelected: false,
|
||||
eventCreated: false,
|
||||
lastStep: null,
|
||||
selectedPackage: null,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'tenant-admin:onboarding-progress';
|
||||
|
||||
const OnboardingProgressContext = React.createContext<OnboardingContextValue | undefined>(undefined);
|
||||
|
||||
function readStoredProgress(): OnboardingProgress {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_PROGRESS;
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return DEFAULT_PROGRESS;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<OnboardingProgress>;
|
||||
return {
|
||||
...DEFAULT_PROGRESS,
|
||||
...parsed,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[OnboardingProgress] Failed to parse stored value', error);
|
||||
return DEFAULT_PROGRESS;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredProgress(progress: OnboardingProgress) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
|
||||
} catch (error) {
|
||||
console.warn('[OnboardingProgress] Failed to persist value', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function OnboardingProgressProvider({ children }: { children: React.ReactNode }) {
|
||||
const [progress, setProgressState] = React.useState<OnboardingProgress>(() => readStoredProgress());
|
||||
|
||||
const setProgress = React.useCallback((updater: (prev: OnboardingProgress) => OnboardingProgress) => {
|
||||
setProgressState((prev) => {
|
||||
const next = updater(prev);
|
||||
writeStoredProgress(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const markStep = React.useCallback((step: Partial<OnboardingProgress>) => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
...step,
|
||||
lastStep: typeof step.lastStep === 'undefined' ? prev.lastStep : step.lastStep,
|
||||
}));
|
||||
}, [setProgress]);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setProgress(() => DEFAULT_PROGRESS);
|
||||
}, [setProgress]);
|
||||
|
||||
const value = React.useMemo<OnboardingContextValue>(() => ({
|
||||
progress,
|
||||
setProgress,
|
||||
markStep,
|
||||
reset,
|
||||
}), [progress, setProgress, markStep, reset]);
|
||||
|
||||
return (
|
||||
<OnboardingProgressContext.Provider value={value}>
|
||||
{children}
|
||||
</OnboardingProgressContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOnboardingProgress() {
|
||||
const context = React.useContext(OnboardingProgressContext);
|
||||
if (!context) {
|
||||
throw new Error('useOnboardingProgress must be used within OnboardingProgressProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
354
resources/js/admin/pages/BillingPage.tsx
Normal file
354
resources/js/admin/pages/BillingPage.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import React from 'react';
|
||||
import { CreditCard, Download, Loader2, RefreshCw, Sparkles } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
CreditLedgerEntry,
|
||||
getCreditBalance,
|
||||
getCreditLedger,
|
||||
getTenantPackagesOverview,
|
||||
PaginationMeta,
|
||||
TenantPackageSummary,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
type LedgerState = {
|
||||
entries: CreditLedgerEntry[];
|
||||
meta: PaginationMeta | null;
|
||||
};
|
||||
|
||||
export default function BillingPage() {
|
||||
const [balance, setBalance] = React.useState<number>(0);
|
||||
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
||||
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
||||
const [ledger, setLedger] = React.useState<LedgerState>({ entries: [], meta: null });
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [balanceResult, packagesResult, ledgerResult] = await Promise.all([
|
||||
safeCall(() => getCreditBalance()),
|
||||
safeCall(() => getTenantPackagesOverview()),
|
||||
safeCall(() => getCreditLedger(1)),
|
||||
]);
|
||||
|
||||
if (balanceResult?.balance !== undefined) {
|
||||
setBalance(balanceResult.balance);
|
||||
}
|
||||
|
||||
if (packagesResult) {
|
||||
setPackages(packagesResult.packages);
|
||||
setActivePackage(packagesResult.activePackage);
|
||||
}
|
||||
|
||||
if (ledgerResult) {
|
||||
setLedger({ entries: ledgerResult.data, meta: ledgerResult.meta });
|
||||
} else {
|
||||
setLedger({ entries: [], meta: null });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Billing Daten konnten nicht geladen werden.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!ledger.meta || loadingMore) {
|
||||
return;
|
||||
}
|
||||
const { current_page, last_page } = ledger.meta;
|
||||
if (current_page >= last_page) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const next = await getCreditLedger(current_page + 1);
|
||||
setLedger({
|
||||
entries: [...ledger.entries, ...next.data],
|
||||
meta: next.meta,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Weitere Ledger Eintraege konnten nicht geladen werden.');
|
||||
}
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<Button variant="outline" onClick={() => void loadAll()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
Aktualisieren
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="Billing und Credits"
|
||||
subtitle="Verwalte Guthaben, Pakete und Abrechnungen."
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<BillingSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<CreditCard className="h-5 w-5 text-pink-500" />
|
||||
Credits und Status
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Dein aktuelles Guthaben und das aktive Reseller Paket.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className={activePackage ? 'bg-pink-500/10 text-pink-700' : 'bg-slate-200 text-slate-700'}>
|
||||
{activePackage ? activePackage.package_name : 'Kein aktives Paket'}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<InfoCard label="Verfuegbare Credits" value={balance} tone="pink" />
|
||||
<InfoCard
|
||||
label="Genutzte Events"
|
||||
value={activePackage?.used_events ?? 0}
|
||||
tone="amber"
|
||||
helper={`Verfuegbar: ${activePackage?.remaining_events ?? 0}`}
|
||||
/>
|
||||
<InfoCard
|
||||
label="Preis (netto)"
|
||||
value={formatCurrency(activePackage?.price)}
|
||||
tone="sky"
|
||||
helper={activePackage?.currency ?? 'EUR'}
|
||||
/>
|
||||
<InfoCard
|
||||
label="Ablauf"
|
||||
value={formatDate(activePackage?.expires_at)}
|
||||
tone="emerald"
|
||||
helper="Automatisch verlaengern falls aktiv"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-amber-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
Paket Historie
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Uebersicht ueber aktive und vergangene Reseller Pakete.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{packages.length === 0 ? (
|
||||
<EmptyState message="Noch keine Pakete gebucht." />
|
||||
) : (
|
||||
packages.map((pkg) => (
|
||||
<PackageCard key={pkg.id} pkg={pkg} isActive={pkg.active} />
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-sky-500" />
|
||||
Credit Ledger
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Alle Zu- und Abbuchungen deines Credits Kontos.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4" />
|
||||
Export als CSV
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{ledger.entries.length === 0 ? (
|
||||
<EmptyState message="Noch keine Ledger Eintraege vorhanden." />
|
||||
) : (
|
||||
<>
|
||||
{ledger.entries.map((entry) => (
|
||||
<LedgerRow key={`${entry.id}-${entry.created_at}`} entry={entry} />
|
||||
))}
|
||||
{ledger.meta && ledger.meta.current_page < ledger.meta.last_page && (
|
||||
<Button variant="outline" className="w-full" onClick={() => void loadMore()} disabled={loadingMore}>
|
||||
{loadingMore ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Mehr laden'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
async function safeCall<T>(callback: () => Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
return await callback();
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.warn('[Tenant Billing] optional endpoint fehlgeschlagen', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function InfoCard({
|
||||
label,
|
||||
value,
|
||||
helper,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number | null | undefined;
|
||||
helper?: string;
|
||||
tone: 'pink' | 'amber' | 'sky' | 'emerald';
|
||||
}) {
|
||||
const toneClass = {
|
||||
pink: 'from-pink-50 to-rose-100 text-pink-700',
|
||||
amber: 'from-amber-50 to-yellow-100 text-amber-700',
|
||||
sky: 'from-sky-50 to-blue-100 text-sky-700',
|
||||
emerald: 'from-emerald-50 to-green-100 text-emerald-700',
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border border-white/60 bg-gradient-to-br ${toneClass} p-5 shadow-sm`}>
|
||||
<span className="text-xs uppercase tracking-wide text-slate-600/90">{label}</span>
|
||||
<div className="mt-3 text-xl font-semibold">{value ?? '--'}</div>
|
||||
{helper && <p className="mt-2 text-xs text-slate-600/80">{helper}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageCard({ pkg, isActive }: { pkg: TenantPackageSummary; isActive: boolean }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">{pkg.package_name}</h3>
|
||||
<p className="text-xs text-slate-600">
|
||||
{formatDate(pkg.purchased_at)} - {formatCurrency(pkg.price)} {pkg.currency ?? 'EUR'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={isActive ? 'bg-amber-500/10 text-amber-700' : 'bg-slate-200 text-slate-700'}>
|
||||
{isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="grid gap-2 text-xs text-slate-600 sm:grid-cols-3">
|
||||
<span>Genutzte Events: {pkg.used_events}</span>
|
||||
<span>Verfuegbar: {pkg.remaining_events ?? '--'}</span>
|
||||
<span>Ablauf: {formatDate(pkg.expires_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerRow({ entry }: { entry: CreditLedgerEntry }) {
|
||||
const positive = entry.delta >= 0;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-slate-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{mapReason(entry.reason)}</p>
|
||||
{entry.note && <p className="text-xs text-slate-500">{entry.note}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`text-sm font-semibold ${positive ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{positive ? '+' : ''}
|
||||
{entry.delta}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{formatDate(entry.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-8 text-center">
|
||||
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="space-y-4 rounded-2xl border border-white/60 bg-white/70 p-6 shadow-sm">
|
||||
<div className="h-6 w-48 animate-pulse rounded bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((__, placeholderIndex) => (
|
||||
<div
|
||||
key={placeholderIndex}
|
||||
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mapReason(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'purchase':
|
||||
return 'Credit Kauf';
|
||||
case 'usage':
|
||||
return 'Verbrauch';
|
||||
case 'manual':
|
||||
return 'Manuelle Anpassung';
|
||||
default:
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '--';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '--';
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function formatCurrency(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '--';
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
|
||||
}
|
||||
448
resources/js/admin/pages/DashboardPage.tsx
Normal file
448
resources/js/admin/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { CalendarDays, Camera, CreditCard, Sparkles, Users, Plus, Settings } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
DashboardSummary,
|
||||
getCreditBalance,
|
||||
getDashboardSummary,
|
||||
getEvents,
|
||||
getTenantPackagesOverview,
|
||||
TenantEvent,
|
||||
TenantPackageSummary,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { useAuth } from '../auth/context';
|
||||
import {
|
||||
adminPath,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_TASKS_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
ADMIN_EVENT_CREATE_PATH,
|
||||
} from '../constants';
|
||||
import { useOnboardingProgress } from '../onboarding';
|
||||
|
||||
interface DashboardState {
|
||||
summary: DashboardSummary | null;
|
||||
events: TenantEvent[];
|
||||
credits: number;
|
||||
activePackage: TenantPackageSummary | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
const { progress, markStep } = useOnboardingProgress();
|
||||
const [state, setState] = React.useState<DashboardState>({
|
||||
summary: null,
|
||||
events: [],
|
||||
credits: 0,
|
||||
activePackage: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [summary, events, credits, packages] = await Promise.all([
|
||||
getDashboardSummary().catch(() => null),
|
||||
getEvents().catch(() => [] as TenantEvent[]),
|
||||
getCreditBalance().catch(() => ({ balance: 0 })),
|
||||
getTenantPackagesOverview().catch(() => ({ packages: [], activePackage: null })),
|
||||
]);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackSummary = buildSummaryFallback(events, credits.balance ?? 0, packages.activePackage);
|
||||
|
||||
setState({
|
||||
summary: summary ?? fallbackSummary,
|
||||
events,
|
||||
credits: credits.balance ?? 0,
|
||||
activePackage: packages.activePackage,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: 'Dashboard konnte nicht geladen werden.',
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { summary, events, credits, activePackage, loading, error } = state;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
if (!progress.eventCreated && events.length === 0 && !location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH)) {
|
||||
navigate(ADMIN_WELCOME_BASE_PATH, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (events.length > 0 && !progress.eventCreated) {
|
||||
markStep({ eventCreated: true });
|
||||
}
|
||||
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
|
||||
|
||||
const upcomingEvents = getUpcomingEvents(events);
|
||||
const publishedEvents = events.filter((event) => event.status === 'published');
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<Button
|
||||
className="bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
|
||||
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Neues Event
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-brand-rose-soft text-brand-rose">
|
||||
<CalendarDays className="h-4 w-4" /> Alle Events
|
||||
</Button>
|
||||
{events.length === 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
|
||||
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" /> Guided Setup
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={`Hallo ${user?.name ?? 'Tenant-Admin'}!`}
|
||||
subtitle="Behalte deine Events, Credits und Aufgaben im Blick."
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<DashboardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{events.length === 0 && (
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="space-y-3">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-brand-rose" />
|
||||
Starte mit der Welcome Journey
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Lerne die Storytelling-Elemente kennen, wähle dein Paket und erstelle dein erstes Event mit
|
||||
geführten Schritten.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-2 text-sm text-slate-600">
|
||||
<p>Wir begleiten dich durch Pakete, Aufgaben und Galerie-Konfiguration, damit dein Event glänzt.</p>
|
||||
<p>Du kannst jederzeit zur Welcome Journey zurückkehren, auch wenn bereits Events laufen.</p>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="rounded-full bg-rose-500 text-white shadow-lg shadow-rose-400/40 hover:bg-rose-500/90"
|
||||
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
|
||||
>
|
||||
Jetzt starten
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-brand-rose" />
|
||||
Kurzer Ueberblick
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Wichtigste Kennzahlen deines Tenants auf einen Blick.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="bg-brand-rose-soft text-brand-rose">
|
||||
{activePackage?.package_name ?? 'Kein aktives Package'}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
label="Aktive Events"
|
||||
value={summary?.active_events ?? publishedEvents.length}
|
||||
hint={`${publishedEvents.length} veroeffentlicht`}
|
||||
icon={<CalendarDays className="h-5 w-5 text-brand-rose" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Neue Fotos (7 Tage)"
|
||||
value={summary?.new_photos ?? 0}
|
||||
icon={<Camera className="h-5 w-5 text-fuchsia-500" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Task-Fortschritt"
|
||||
value={`${Math.round(summary?.task_progress ?? 0)}%`}
|
||||
icon={<Users className="h-5 w-5 text-amber-500" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Credits"
|
||||
value={credits}
|
||||
hint={credits <= 1 ? 'Auffuellen empfohlen' : undefined}
|
||||
icon={<CreditCard className="h-5 w-5 text-sky-500" />}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl text-slate-900">Schnellaktionen</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Starte durch mit den wichtigsten Aktionen.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<QuickAction
|
||||
icon={<Plus className="h-5 w-5" />}
|
||||
label="Event erstellen"
|
||||
description="Plane dein naechstes Highlight."
|
||||
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Camera className="h-5 w-5" />}
|
||||
label="Fotos moderieren"
|
||||
description="Pruefe neue Uploads."
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
label="Tasks organisieren"
|
||||
description="Sorge fuer klare Verantwortungen."
|
||||
onClick={() => navigate(ADMIN_TASKS_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<CreditCard className="h-5 w-5" />}
|
||||
label="Credits verwalten"
|
||||
description="Sieh dir Balance & Ledger an."
|
||||
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl text-slate-900">Kommende Events</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Die naechsten Termine inklusive Status & Zugriff.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Einstellungen oeffnen
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<EmptyState
|
||||
message="Noch keine Termine geplant. Lege dein erstes Event an!"
|
||||
ctaLabel="Event planen"
|
||||
onCta={() => navigate(adminPath('/events/new'))}
|
||||
/>
|
||||
) : (
|
||||
upcomingEvents.map((event) => (
|
||||
<UpcomingEventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
onView={() => navigate(ADMIN_EVENT_VIEW_PATH(event.slug))}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSummaryFallback(
|
||||
events: TenantEvent[],
|
||||
balance: number,
|
||||
activePackage: TenantPackageSummary | null
|
||||
): DashboardSummary {
|
||||
const activeEvents = events.filter((event) => Boolean(event.is_active || event.status === 'published'));
|
||||
const totalPhotos = events.reduce((sum, event) => sum + Number(event.photo_count ?? 0), 0);
|
||||
|
||||
return {
|
||||
active_events: activeEvents.length,
|
||||
new_photos: totalPhotos,
|
||||
task_progress: 0,
|
||||
credit_balance: balance,
|
||||
upcoming_events: activeEvents.length,
|
||||
active_package: activePackage
|
||||
? {
|
||||
name: activePackage.package_name,
|
||||
remaining_events: activePackage.remaining_events,
|
||||
expires_at: activePackage.expires_at,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] {
|
||||
const now = new Date();
|
||||
return events
|
||||
.filter((event) => {
|
||||
if (!event.event_date) return false;
|
||||
const date = new Date(event.event_date);
|
||||
return !Number.isNaN(date.getTime()) && date >= now;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = a.event_date ? new Date(a.event_date).getTime() : 0;
|
||||
const dateB = b.event_date ? new Date(b.event_date).getTime() : 0;
|
||||
return dateA - dateB;
|
||||
})
|
||||
.slice(0, 4);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
hint?: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-brand-rose-soft bg-brand-card p-5 shadow-md shadow-pink-100/40 transition-transform hover:-translate-y-0.5 hover:shadow-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
|
||||
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose">{icon}</span>
|
||||
</div>
|
||||
<div className="mt-4 text-2xl font-semibold text-slate-900">{value}</div>
|
||||
{hint && <p className="mt-2 text-xs text-slate-500">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAction({
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-start gap-2 rounded-2xl border border-slate-100 bg-white/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-brand-rose-soft hover:shadow-md focus:outline-none focus:ring-2 focus:ring-brand-rose/40"
|
||||
>
|
||||
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose">{icon}</span>
|
||||
<span className="text-sm font-semibold text-slate-900">{label}</span>
|
||||
<span className="text-xs text-slate-600">{description}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function UpcomingEventRow({ event, onView }: { event: TenantEvent; onView: () => void }) {
|
||||
const date = event.event_date ? new Date(event.event_date) : null;
|
||||
const formattedDate = date
|
||||
? date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
: 'Kein Datum';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-sky-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={event.status === 'published' ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-600'}>
|
||||
{event.status === 'published' ? 'Live' : 'In Planung'}
|
||||
</Badge>
|
||||
<Button size="sm" variant="outline" onClick={onView} className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40">
|
||||
Oeffnen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message, ctaLabel, onCta }: { message: string; ctaLabel: string; onCta: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-10 text-center">
|
||||
<div className="rounded-full bg-brand-rose-soft p-3 text-brand-rose shadow-inner shadow-pink-200/80">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{message}</p>
|
||||
<Button onClick={onCta} className="bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]">
|
||||
{ctaLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="space-y-4 rounded-2xl border border-white/60 bg-white/70 p-6 shadow-sm">
|
||||
<div className="h-6 w-48 animate-pulse rounded bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((__ , cardIndex) => (
|
||||
<div
|
||||
key={cardIndex}
|
||||
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
}
|
||||
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? 'Unbenanntes Event';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Camera, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
@@ -9,7 +9,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { createInviteLink, getEvent, getEventStats, TenantEvent, EventStats as TenantEventStats, toggleEvent } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { adminPath } from '../constants';
|
||||
import {
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_EVENT_EDIT_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_MEMBERS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
} from '../constants';
|
||||
|
||||
interface State {
|
||||
event: TenantEvent | null;
|
||||
@@ -21,8 +27,9 @@ interface State {
|
||||
}
|
||||
|
||||
export default function EventDetailPage() {
|
||||
const params = useParams<{ slug?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const slug = searchParams.get('slug');
|
||||
const slug = params.slug ?? searchParams.get('slug') ?? null;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [state, setState] = React.useState<State>({
|
||||
@@ -106,19 +113,35 @@ export default function EventDetailPage() {
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(adminPath('/events'))}
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurueck zur Liste
|
||||
</Button>
|
||||
{event && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(adminPath(`/events/edit?slug=${encodeURIComponent(event.slug)}`))}
|
||||
className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50"
|
||||
>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}
|
||||
className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50"
|
||||
>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENT_MEMBERS_PATH(event.slug))}
|
||||
className="border-sky-200 text-sky-700 hover:bg-sky-50"
|
||||
>
|
||||
Mitglieder
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
|
||||
className="border-amber-200 text-amber-600 hover:bg-amber-50"
|
||||
>
|
||||
Tasks
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -177,7 +200,7 @@ export default function EventDetailPage() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(adminPath(`/events/photos?slug=${encodeURIComponent(event.slug)}`))}
|
||||
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
|
||||
className="border-sky-200 text-sky-700 hover:bg-sky-50"
|
||||
>
|
||||
<Camera className="h-4 w-4" /> Fotos moderieren
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Loader2, Save, Sparkles, Package as PackageIcon } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { createEvent, getEvent, updateEvent, getPackages } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { adminPath } from '../constants';
|
||||
import { ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
||||
|
||||
interface EventFormState {
|
||||
name: string;
|
||||
@@ -26,8 +26,9 @@ interface EventFormState {
|
||||
}
|
||||
|
||||
export default function EventFormPage() {
|
||||
const params = useParams<{ slug?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const slugParam = searchParams.get('slug');
|
||||
const slugParam = params.slug ?? searchParams.get('slug') ?? undefined;
|
||||
const isEdit = Boolean(slugParam);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -63,12 +64,13 @@ export default function EventFormPage() {
|
||||
const event = await getEvent(slugParam);
|
||||
if (cancelled) return;
|
||||
const name = normalizeName(event.name);
|
||||
setForm({
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
name,
|
||||
slug: event.slug,
|
||||
date: event.event_date ? event.event_date.slice(0, 10) : '',
|
||||
isPublished: event.status === 'published',
|
||||
});
|
||||
}));
|
||||
setOriginalSlug(event.slug);
|
||||
setAutoSlug(false);
|
||||
} catch (err) {
|
||||
@@ -117,12 +119,14 @@ export default function EventFormPage() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const status: 'draft' | 'published' | 'archived' = form.isPublished ? 'published' : 'draft';
|
||||
|
||||
const payload = {
|
||||
name: trimmedName,
|
||||
slug: trimmedSlug,
|
||||
package_id: form.package_id,
|
||||
date: form.date || undefined,
|
||||
status: form.isPublished ? 'published' : 'draft',
|
||||
status,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -130,10 +134,10 @@ export default function EventFormPage() {
|
||||
const targetSlug = originalSlug ?? slugParam!;
|
||||
const updated = await updateEvent(targetSlug, payload);
|
||||
setOriginalSlug(updated.slug);
|
||||
navigate(adminPath(`/events/view?slug=${encodeURIComponent(updated.slug)}`));
|
||||
navigate(ADMIN_EVENT_VIEW_PATH(updated.slug));
|
||||
} else {
|
||||
const { event: created } = await createEvent(payload);
|
||||
navigate(adminPath(`/events/view?slug=${encodeURIComponent(created.slug)}`));
|
||||
navigate(ADMIN_EVENT_VIEW_PATH(created.slug));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
@@ -147,7 +151,7 @@ export default function EventFormPage() {
|
||||
const actions = (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(adminPath('/events'))}
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurueck zur Liste
|
||||
|
||||
309
resources/js/admin/pages/EventMembersPage.tsx
Normal file
309
resources/js/admin/pages/EventMembersPage.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Loader2, Mail, Sparkles, Trash2, Users } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
EventMember,
|
||||
getEvent,
|
||||
getEventMembers,
|
||||
inviteEventMember,
|
||||
removeEventMember,
|
||||
TenantEvent,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_EVENTS_PATH } from '../constants';
|
||||
|
||||
type InviteForm = {
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
const emptyInvite: InviteForm = {
|
||||
email: '',
|
||||
name: '',
|
||||
role: 'member',
|
||||
};
|
||||
|
||||
export default function EventMembersPage() {
|
||||
const params = useParams<{ slug?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const slug = params.slug ?? searchParams.get('slug') ?? null;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [members, setMembers] = React.useState<EventMember[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [invite, setInvite] = React.useState<InviteForm>(emptyInvite);
|
||||
const [inviting, setInviting] = React.useState(false);
|
||||
const [membersUnavailable, setMembersUnavailable] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) {
|
||||
setError('Kein Event-Slug angegeben.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const eventData = await getEvent(slug);
|
||||
if (cancelled) return;
|
||||
setEvent(eventData);
|
||||
|
||||
const response = await getEventMembers(slug);
|
||||
if (cancelled) return;
|
||||
setMembers(response.data);
|
||||
setMembersUnavailable(false);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('Mitgliederverwaltung')) {
|
||||
setMembersUnavailable(true);
|
||||
} else if (!isAuthError(err)) {
|
||||
setError('Mitglieder konnten nicht geladen werden.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
async function handleInvite(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!slug) return;
|
||||
if (!invite.email.trim()) {
|
||||
setError('Bitte gib eine E-Mail-Adresse ein.');
|
||||
return;
|
||||
}
|
||||
setInviting(true);
|
||||
try {
|
||||
const member = await inviteEventMember(slug, {
|
||||
email: invite.email.trim(),
|
||||
name: invite.name.trim() || undefined,
|
||||
role: invite.role,
|
||||
});
|
||||
setMembers((prev) => [member, ...prev]);
|
||||
setInvite(emptyInvite);
|
||||
setMembersUnavailable(false);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('Mitgliederverwaltung')) {
|
||||
setMembersUnavailable(true);
|
||||
} else if (!isAuthError(err)) {
|
||||
setError('Einladung konnte nicht verschickt werden.');
|
||||
}
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(member: EventMember) {
|
||||
if (!slug) return;
|
||||
if (!window.confirm(`${member.name} wirklich entfernen?`)) return;
|
||||
try {
|
||||
await removeEventMember(slug, member.id);
|
||||
setMembers((prev) => prev.filter((entry) => entry.id !== member.id));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Mitglied konnte nicht entfernt werden.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Zurueck zur Uebersicht
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="Event Mitglieder"
|
||||
subtitle="Verwalte Moderatoren, Admins und Helfer fuer dieses Event."
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<MembersSkeleton />
|
||||
) : !event ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Event nicht gefunden</AlertTitle>
|
||||
<AlertDescription>Bitte kehre zur Eventliste zurueck.</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl text-slate-900">{renderName(event.name)}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Status: {event.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<Sparkles className="h-4 w-4 text-pink-500" />
|
||||
Mitglieder
|
||||
</h3>
|
||||
{membersUnavailable ? (
|
||||
<Alert>
|
||||
<AlertTitle>Feature noch nicht aktiviert</AlertTitle>
|
||||
<AlertDescription>
|
||||
Die Mitgliederverwaltung ist fuer dieses Event noch nicht verfuegbar. Bitte kontaktiere den Support,
|
||||
um das Feature freizuschalten.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : members.length === 0 ? (
|
||||
<EmptyState message="Noch keine Mitglieder eingeladen." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex flex-col gap-3 rounded-2xl border border-slate-100 bg-white/90 p-3 shadow-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{member.name}</p>
|
||||
<p className="text-xs text-slate-600">{member.email}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span>Status: {member.status ?? 'aktiv'}</span>
|
||||
{member.joined_at && <span>Beigetreten: {formatDate(member.joined_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||
{mapRole(member.role)}
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={() => void handleRemove(member)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<Users className="h-4 w-4 text-emerald-500" />
|
||||
Neues Mitglied einladen
|
||||
</h3>
|
||||
<form className="space-y-3 rounded-2xl border border-emerald-100 bg-white/90 p-4 shadow-sm" onSubmit={handleInvite}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-email">E-Mail</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
placeholder="person@example.com"
|
||||
value={invite.email}
|
||||
onChange={(event) => setInvite((prev) => ({ ...prev, email: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-name">Name (optional)</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
placeholder="Name"
|
||||
value={invite.name}
|
||||
onChange={(event) => setInvite((prev) => ({ ...prev, name: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-role">Rolle</Label>
|
||||
<Select
|
||||
value={invite.role}
|
||||
onValueChange={(value) => setInvite((prev) => ({ ...prev, role: value }))}
|
||||
>
|
||||
<SelectTrigger id="invite-role">
|
||||
<SelectValue placeholder="Rolle waehlen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tenant_admin">Tenant-Admin</SelectItem>
|
||||
<SelectItem value="member">Mitglied</SelectItem>
|
||||
<SelectItem value="guest">Gast</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={inviting}>
|
||||
{inviting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Mail className="h-4 w-4" />}
|
||||
{' '}Einladung senden
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-6 text-center">
|
||||
<p className="text-xs text-slate-600">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
}
|
||||
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? 'Unbenanntes Event';
|
||||
}
|
||||
|
||||
function mapRole(role: string): string {
|
||||
switch (role) {
|
||||
case 'tenant_admin':
|
||||
return 'Tenant-Admin';
|
||||
case 'member':
|
||||
return 'Mitglied';
|
||||
case 'guest':
|
||||
return 'Gast';
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '--';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '--';
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Camera, Loader2, Sparkles, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
@@ -9,11 +9,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { adminPath } from '../constants';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants';
|
||||
|
||||
export default function EventPhotosPage() {
|
||||
const params = useParams<{ slug?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const slug = searchParams.get('slug');
|
||||
const slug = params.slug ?? searchParams.get('slug') ?? null;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
||||
@@ -82,7 +83,7 @@ export default function EventPhotosPage() {
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardContent className="p-6 text-sm text-slate-600">
|
||||
Kein Slug in der URL gefunden. Kehre zur Event-Liste zurueck und waehle dort ein Event aus.
|
||||
<Button className="mt-4" onClick={() => navigate(adminPath('/events'))}>
|
||||
<Button className="mt-4" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
|
||||
Zurueck zur Liste
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -94,7 +95,7 @@ export default function EventPhotosPage() {
|
||||
const actions = (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(adminPath(`/events/view?slug=${encodeURIComponent(slug)}`))}
|
||||
onClick={() => slug && navigate(ADMIN_EVENT_VIEW_PATH(slug))}
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||
>
|
||||
Zurueck zum Event
|
||||
|
||||
230
resources/js/admin/pages/EventTasksPage.tsx
Normal file
230
resources/js/admin/pages/EventTasksPage.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Loader2, PlusCircle, Sparkles } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
assignTasksToEvent,
|
||||
getEvent,
|
||||
getEventTasks,
|
||||
getTasks,
|
||||
TenantEvent,
|
||||
TenantTask,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_EVENTS_PATH } from '../constants';
|
||||
|
||||
export default function EventTasksPage() {
|
||||
const params = useParams<{ slug?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const slug = params.slug ?? searchParams.get('slug') ?? null;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
|
||||
const [availableTasks, setAvailableTasks] = React.useState<TenantTask[]>([]);
|
||||
const [selected, setSelected] = React.useState<number[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) {
|
||||
setError('Kein Event-Slug angegeben.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const eventData = await getEvent(slug);
|
||||
const [eventTasksResponse, libraryTasks] = await Promise.all([
|
||||
getEventTasks(eventData.id, 1),
|
||||
getTasks({ per_page: 50 }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setEvent(eventData);
|
||||
const assignedIds = new Set(eventTasksResponse.data.map((task) => task.id));
|
||||
setAssignedTasks(eventTasksResponse.data);
|
||||
setAvailableTasks(libraryTasks.data.filter((task) => !assignedIds.has(task.id)));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Event-Tasks konnten nicht geladen werden.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
async function handleAssign() {
|
||||
if (!event || selected.length === 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await assignTasksToEvent(event.id, selected);
|
||||
const refreshed = await getEventTasks(event.id, 1);
|
||||
const assignedIds = new Set(refreshed.data.map((task) => task.id));
|
||||
setAssignedTasks(refreshed.data);
|
||||
setAvailableTasks((prev) => prev.filter((task) => !assignedIds.has(task.id)));
|
||||
setSelected([]);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Tasks konnten nicht zugewiesen werden.');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Zurueck zur Uebersicht
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="Event Tasks"
|
||||
subtitle="Verwalte Aufgaben, die diesem Event zugeordnet sind."
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Hinweis</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<TaskSkeleton />
|
||||
) : !event ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Event nicht gefunden</AlertTitle>
|
||||
<AlertDescription>Bitte kehre zur Eventliste zurueck.</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl text-slate-900">{renderName(event.name)}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Status: {event.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<Sparkles className="h-4 w-4 text-pink-500" />
|
||||
Zugeordnete Tasks
|
||||
</h3>
|
||||
{assignedTasks.length === 0 ? (
|
||||
<EmptyState message="Noch keine Tasks zugewiesen." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{assignedTasks.map((task) => (
|
||||
<div key={task.id} className="rounded-2xl border border-slate-100 bg-white/90 p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||
{mapPriority(task.priority)}
|
||||
</Badge>
|
||||
</div>
|
||||
{task.description && <p className="mt-1 text-xs text-slate-600">{task.description}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<PlusCircle className="h-4 w-4 text-emerald-500" />
|
||||
Tasks aus Bibliothek hinzufuegen
|
||||
</h3>
|
||||
<div className="space-y-2 rounded-2xl border border-emerald-100 bg-white/90 p-3 shadow-sm max-h-72 overflow-y-auto">
|
||||
{availableTasks.length === 0 ? (
|
||||
<EmptyState message="Keine Tasks in der Bibliothek gefunden." />
|
||||
) : (
|
||||
availableTasks.map((task) => (
|
||||
<label key={task.id} className="flex items-start gap-3 rounded-xl border border-transparent p-2 transition hover:border-emerald-200">
|
||||
<Checkbox
|
||||
checked={selected.includes(task.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelected((prev) =>
|
||||
checked ? [...prev, task.id] : prev.filter((id) => id !== task.id)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||
{task.description && <p className="text-xs text-slate-600">{task.description}</p>}
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={() => void handleAssign()} disabled={saving || selected.length === 0}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Ausgewaehlte Tasks zuweisen'}
|
||||
</Button>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-6 text-center">
|
||||
<p className="text-xs text-slate-600">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<div key={index} className="h-48 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mapPriority(priority: TenantTask['priority']): string {
|
||||
switch (priority) {
|
||||
case 'low':
|
||||
return 'Niedrig';
|
||||
case 'high':
|
||||
return 'Hoch';
|
||||
case 'urgent':
|
||||
return 'Dringend';
|
||||
default:
|
||||
return 'Mittel';
|
||||
}
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
}
|
||||
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? 'Unbenanntes Event';
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, CalendarDays, Plus, Settings, Sparkles, Package as PackageIcon } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRight, CalendarDays, Plus, Settings, Sparkles } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -9,9 +8,17 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getEvents, TenantEvent, getPackages } from '../api';
|
||||
import { getEvents, TenantEvent } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { adminPath, ADMIN_SETTINGS_PATH } from '../constants';
|
||||
import {
|
||||
adminPath,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENT_EDIT_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_MEMBERS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
} from '../constants';
|
||||
|
||||
export default function EventsPage() {
|
||||
const [rows, setRows] = React.useState<TenantEvent[]>([]);
|
||||
@@ -19,11 +26,6 @@ export default function EventsPage() {
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: tenantPackages } = useQuery({
|
||||
queryKey: ['tenant-packages'],
|
||||
queryFn: () => getPackages('reseller'), // or separate endpoint
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -60,33 +62,6 @@ export default function EventsPage() {
|
||||
subtitle="Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen."
|
||||
actions={actions}
|
||||
>
|
||||
{tenantPackages && tenantPackages.length > 0 && (
|
||||
<Card className="mb-6 border-0 bg-white/80 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<PackageIcon className="h-5 w-5 text-pink-500" />
|
||||
Aktuelles Package
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Ihr aktuelles Reseller-Package und verbleibende Limits.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid md:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold">Aktives Package</h3>
|
||||
<p className="text-lg font-bold">{tenantPackages.find(p => p.active)?.package?.name || 'Kein aktives Package'}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold">Verbleibende Events</h3>
|
||||
<p className="text-lg font-bold">{tenantPackages.find(p => p.active)?.remaining_events || 0}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold">Ablauf</h3>
|
||||
<p className="text-lg font-bold">{tenantPackages.find(p => p.active)?.expires_at || 'Kein Package'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler beim Laden</AlertTitle>
|
||||
@@ -164,17 +139,23 @@ function EventCard({ event }: { event: TenantEvent }) {
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" className="border-pink-200 text-pink-700 hover:bg-pink-50">
|
||||
<Link to={adminPath(`/events/view?slug=${encodeURIComponent(slug)}`)}>
|
||||
<Link to={ADMIN_EVENT_VIEW_PATH(slug)}>
|
||||
Details <ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
|
||||
<Link to={adminPath(`/events/edit?slug=${encodeURIComponent(slug)}`)}>Bearbeiten</Link>
|
||||
<Link to={ADMIN_EVENT_EDIT_PATH(slug)}>Bearbeiten</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-sky-200 text-sky-700 hover:bg-sky-50">
|
||||
<Link to={adminPath(`/events/photos?slug=${encodeURIComponent(slug)}`)}>Fotos moderieren</Link>
|
||||
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>Fotos moderieren</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
||||
<Link to={ADMIN_EVENT_MEMBERS_PATH(slug)}>Mitglieder</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-amber-200 text-amber-600 hover:bg-amber-50">
|
||||
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
|
||||
<a href={`/e/${slug}`} target="_blank" rel="noreferrer">
|
||||
Oeffnen im Gastportal
|
||||
</a>
|
||||
|
||||
453
resources/js/admin/pages/TasksPage.tsx
Normal file
453
resources/js/admin/pages/TasksPage.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CheckCircle2, Circle, Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
createTask,
|
||||
deleteTask,
|
||||
getTasks,
|
||||
PaginationMeta,
|
||||
TenantTask,
|
||||
TaskPayload,
|
||||
updateTask,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_EVENTS_PATH } from '../constants';
|
||||
|
||||
type TaskFormState = {
|
||||
title: string;
|
||||
description: string;
|
||||
priority: TaskPayload['priority'];
|
||||
due_date: string;
|
||||
is_completed: boolean;
|
||||
};
|
||||
|
||||
const INITIAL_FORM: TaskFormState = {
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
due_date: '',
|
||||
is_completed: false,
|
||||
};
|
||||
|
||||
export default function TasksPage() {
|
||||
const navigate = useNavigate();
|
||||
const [tasks, setTasks] = React.useState<TenantTask[]>([]);
|
||||
const [meta, setMeta] = React.useState<PaginationMeta | null>(null);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [editingTask, setEditingTask] = React.useState<TenantTask | null>(null);
|
||||
const [form, setForm] = React.useState<TaskFormState>(INITIAL_FORM);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
getTasks({ page, search: search.trim() || undefined })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setTasks(result.data);
|
||||
setMeta(result.meta);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Tasks konnten nicht geladen werden.');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [page, search]);
|
||||
|
||||
function openCreate() {
|
||||
setEditingTask(null);
|
||||
setForm(INITIAL_FORM);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(task: TenantTask) {
|
||||
setEditingTask(task);
|
||||
setForm({
|
||||
title: task.title,
|
||||
description: task.description ?? '',
|
||||
priority: task.priority ?? 'medium',
|
||||
due_date: task.due_date ? task.due_date.slice(0, 10) : '',
|
||||
is_completed: task.is_completed,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!form.title.trim()) {
|
||||
setError('Bitte gib einen Titel ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const payload: TaskPayload = {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || null,
|
||||
priority: form.priority ?? undefined,
|
||||
due_date: form.due_date || undefined,
|
||||
is_completed: form.is_completed,
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingTask) {
|
||||
const updated = await updateTask(editingTask.id, payload);
|
||||
setTasks((prev) => prev.map((task) => (task.id === updated.id ? updated : task)));
|
||||
} else {
|
||||
const created = await createTask(payload);
|
||||
setTasks((prev) => [created, ...prev]);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Task konnte nicht gespeichert werden.');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(taskId: number) {
|
||||
if (!window.confirm('Task wirklich loeschen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteTask(taskId);
|
||||
setTasks((prev) => prev.filter((task) => task.id !== taskId));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Task konnte nicht geloescht werden.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCompletion(task: TenantTask) {
|
||||
try {
|
||||
const updated = await updateTask(task.id, { is_completed: !task.is_completed });
|
||||
setTasks((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Status konnte nicht aktualisiert werden.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="Task Bibliothek"
|
||||
subtitle="Weise Aufgaben zu und tracke Fortschritt rund um deine Events."
|
||||
actions={
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neuer Task
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl text-slate-900">Tasks verwalten</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Erstelle Aufgaben und ordne sie deinen Events zu.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Input
|
||||
placeholder="Nach Tasks suchen..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="sm:max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
|
||||
Events oeffnen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<TaskSkeleton />
|
||||
) : tasks.length === 0 ? (
|
||||
<EmptyTasksState onCreate={openCreate} />
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{tasks.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onToggle={() => void toggleCompletion(task)}
|
||||
onEdit={() => openEdit(task)}
|
||||
onDelete={() => void handleDelete(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta && meta.last_page > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
|
||||
>
|
||||
Zurueck
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">
|
||||
Seite {meta.current_page} von {meta.last_page}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page >= meta.last_page}
|
||||
onClick={() => setPage((prev) => Math.min(prev + 1, meta.last_page))}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TaskDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSubmit={handleSubmit}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
saving={saving}
|
||||
isEditing={Boolean(editingTask)}
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({
|
||||
task,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
task: TenantTask;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const assignedCount = task.assigned_events_count ?? task.assigned_events?.length ?? 0;
|
||||
const completed = task.is_completed;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-slate-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="rounded-full border border-pink-200 bg-white/80 p-2 text-pink-600 shadow-sm transition hover:bg-pink-50"
|
||||
>
|
||||
{completed ? <CheckCircle2 className="h-5 w-5" /> : <Circle className="h-5 w-5" />}
|
||||
</button>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-semibold ${completed ? 'text-slate-500 line-through' : 'text-slate-900'}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||
{mapPriority(task.priority)}
|
||||
</Badge>
|
||||
</div>
|
||||
{task.description && <p className="text-xs text-slate-600">{task.description}</p>}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||
{task.due_date && <span>Faellig: {formatDate(task.due_date)}</span>}
|
||||
<span>Zugeordnet: {assignedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onDelete} className="text-rose-600">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
form,
|
||||
setForm,
|
||||
saving,
|
||||
isEditing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
form: TaskFormState;
|
||||
setForm: React.Dispatch<React.SetStateAction<TaskFormState>>;
|
||||
saving: boolean;
|
||||
isEditing: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? 'Task bearbeiten' : 'Neuen Task anlegen'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-title">Titel</Label>
|
||||
<Input
|
||||
id="task-title"
|
||||
value={form.title}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-description">Beschreibung</Label>
|
||||
<textarea
|
||||
id="task-description"
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
className="min-h-[80px] w-full rounded-md border border-slate-200 bg-white/80 p-2 text-sm shadow-sm focus:border-pink-300 focus:outline-none focus:ring-2 focus:ring-pink-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-priority">Prioritaet</Label>
|
||||
<Select
|
||||
value={form.priority ?? 'medium'}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, priority: value as TaskPayload['priority'] }))}
|
||||
>
|
||||
<SelectTrigger id="task-priority">
|
||||
<SelectValue placeholder="Prioritaet waehlen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Niedrig</SelectItem>
|
||||
<SelectItem value="medium">Mittel</SelectItem>
|
||||
<SelectItem value="high">Hoch</SelectItem>
|
||||
<SelectItem value="urgent">Dringend</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-due-date">Faellig am</Label>
|
||||
<Input
|
||||
id="task-due-date"
|
||||
type="date"
|
||||
value={form.due_date}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, due_date: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-pink-100 bg-pink-50/60 p-3">
|
||||
<div>
|
||||
<Label htmlFor="task-completed" className="text-sm font-medium text-slate-800">
|
||||
Bereits erledigt
|
||||
</Label>
|
||||
<p className="text-xs text-slate-500">Markiere Task als abgeschlossen.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="task-completed"
|
||||
checked={form.is_completed}
|
||||
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_completed: Boolean(checked) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTasksState({ onCreate }: { onCreate: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-10 text-center">
|
||||
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
|
||||
<Plus className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">Noch keine Tasks angelegt. Lege deinen ersten Task an.</p>
|
||||
<Button onClick={onCreate}>Task erstellen</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mapPriority(priority: TenantTask['priority']): string {
|
||||
switch (priority) {
|
||||
case 'low':
|
||||
return 'Niedrig';
|
||||
case 'high':
|
||||
return 'Hoch';
|
||||
case 'urgent':
|
||||
return 'Dringend';
|
||||
default:
|
||||
return 'Mittel';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '--';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '--';
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
@@ -1,14 +1,27 @@
|
||||
import React from 'react';
|
||||
import { createBrowserRouter, Outlet, Navigate, useLocation } from 'react-router-dom';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import EventsPage from './pages/EventsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import EventFormPage from './pages/EventFormPage';
|
||||
import EventPhotosPage from './pages/EventPhotosPage';
|
||||
import EventDetailPage from './pages/EventDetailPage';
|
||||
import EventMembersPage from './pages/EventMembersPage';
|
||||
import EventTasksPage from './pages/EventTasksPage';
|
||||
import BillingPage from './pages/BillingPage';
|
||||
import TasksPage from './pages/TasksPage';
|
||||
import AuthCallbackPage from './pages/AuthCallbackPage';
|
||||
import { useAuth } from './auth/context';
|
||||
import { ADMIN_AUTH_CALLBACK_PATH, ADMIN_BASE_PATH, ADMIN_LOGIN_PATH } from './constants';
|
||||
import {
|
||||
ADMIN_AUTH_CALLBACK_PATH,
|
||||
ADMIN_BASE_PATH,
|
||||
ADMIN_LOGIN_PATH,
|
||||
} from './constants';
|
||||
import WelcomeLandingPage from './onboarding/pages/WelcomeLandingPage';
|
||||
import WelcomePackagesPage from './onboarding/pages/WelcomePackagesPage';
|
||||
import WelcomeEventSetupPage from './onboarding/pages/WelcomeEventSetupPage';
|
||||
import WelcomeOrderSummaryPage from './onboarding/pages/WelcomeOrderSummaryPage';
|
||||
|
||||
function RequireAuth() {
|
||||
const { status } = useAuth();
|
||||
@@ -36,13 +49,22 @@ export const router = createBrowserRouter([
|
||||
path: ADMIN_BASE_PATH,
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{ index: true, element: <EventsPage /> },
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: 'dashboard', element: <Navigate to={ADMIN_BASE_PATH} replace /> },
|
||||
{ path: 'events', element: <EventsPage /> },
|
||||
{ path: 'events/new', element: <EventFormPage /> },
|
||||
{ path: 'events/edit', element: <EventFormPage /> },
|
||||
{ path: 'events/view', element: <EventDetailPage /> },
|
||||
{ path: 'events/photos', element: <EventPhotosPage /> },
|
||||
{ path: 'events/:slug', element: <EventDetailPage /> },
|
||||
{ path: 'events/:slug/edit', element: <EventFormPage /> },
|
||||
{ path: 'events/:slug/photos', element: <EventPhotosPage /> },
|
||||
{ path: 'events/:slug/members', element: <EventMembersPage /> },
|
||||
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
|
||||
{ path: 'tasks', element: <TasksPage /> },
|
||||
{ path: 'billing', element: <BillingPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
{ path: 'welcome', element: <WelcomeLandingPage /> },
|
||||
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
|
||||
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> },
|
||||
{ path: 'welcome/event', element: <WelcomeEventSetupPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to={ADMIN_BASE_PATH} replace /> },
|
||||
|
||||
@@ -15,7 +15,8 @@ interface Props {
|
||||
|
||||
const Blog: React.FC<Props> = ({ posts }) => {
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const { t } = useTranslation('marketing');
|
||||
const { t, i18n } = useTranslation('marketing');
|
||||
const locale = i18n.language || 'de'; // Fallback to 'de' if no locale
|
||||
|
||||
const resolvePaginationHref = React.useCallback(
|
||||
(raw?: string | null) => {
|
||||
@@ -110,12 +111,12 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
||||
)}
|
||||
<h3 className="text-xl font-semibold mb-2 font-sans-marketing text-gray-900 dark:text-gray-100">
|
||||
<Link href={`${localizedPath(`/blog/${post.slug}`)}`} className="hover:text-[#FFB6C1]">
|
||||
{post.title}
|
||||
{post.title?.[locale] || post.title?.de || post.title || 'No Title'}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-700 dark:text-gray-300 font-serif-custom">{post.excerpt}</p>
|
||||
<p className="mb-4 text-gray-700 dark:text-gray-300 font-serif-custom">{post.excerpt?.[locale] || post.excerpt?.de || post.excerpt || 'No Excerpt'}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 font-sans-marketing">
|
||||
{t('blog.by')} {post.author?.name || t('blog.team')} | {t('blog.published_at')} {post.published_at}
|
||||
{t('blog.by')} {post.author?.name?.[locale] || post.author?.name?.de || post.author?.name || t('blog.team')} | {t('blog.published_at')} {post.published_at}
|
||||
</p>
|
||||
<Link
|
||||
href={`${localizedPath(`/blog/${post.slug}`)}`}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface Props {
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
content: string;
|
||||
content_html: string;
|
||||
featured_image?: string;
|
||||
published_at: string;
|
||||
author?: { name: string };
|
||||
@@ -44,7 +45,7 @@ const BlogShow: React.FC<Props> = ({ post }) => {
|
||||
{/* Post Content */}
|
||||
<section className="py-20 px-4 bg-white">
|
||||
<div className="container mx-auto max-w-4xl prose prose-lg max-w-none">
|
||||
<div dangerouslySetInnerHTML={{ __html: post.content }} />
|
||||
<div dangerouslySetInnerHTML={{ __html: post.content_html }} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -2,40 +2,34 @@ import { useState, useEffect } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useStripe, useElements, PaymentElement, Elements } from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useCheckoutWizard } from "../WizardContext";
|
||||
|
||||
interface PaymentStepProps {
|
||||
stripePublishableKey: string;
|
||||
paypalClientId: string;
|
||||
}
|
||||
|
||||
// Komponente für kostenpflichtige Zahlungen (immer innerhalb Elements Provider)
|
||||
const PaymentForm: React.FC = () => {
|
||||
// Komponente für Stripe-Zahlungen
|
||||
const StripePaymentForm: React.FC<{ onError: (error: string) => void; onSuccess: () => void; selectedPackage: any; t: any }> = ({ onError, onSuccess, selectedPackage, t }) => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const { selectedPackage, resetPaymentState, nextStep } = useCheckoutWizard();
|
||||
const { t } = useTranslation('marketing');
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [paymentStatus, setPaymentStatus] = useState<'idle' | 'processing' | 'succeeded' | 'failed'>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
resetPaymentState();
|
||||
}, [selectedPackage?.id, resetPaymentState]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
setError(t('checkout.payment_step.stripe_not_loaded'));
|
||||
onError(t('checkout.payment_step.stripe_not_loaded'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setError('');
|
||||
setPaymentStatus('processing');
|
||||
|
||||
try {
|
||||
const { error: stripeError, paymentIntent } = await stripe.confirmPayment({
|
||||
@@ -43,7 +37,7 @@ const PaymentForm: React.FC = () => {
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/checkout/success`,
|
||||
},
|
||||
redirect: 'if_required', // Wichtig für SCA
|
||||
redirect: 'if_required',
|
||||
});
|
||||
|
||||
if (stripeError) {
|
||||
@@ -71,119 +65,186 @@ const PaymentForm: React.FC = () => {
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
setPaymentStatus('failed');
|
||||
onError(errorMessage);
|
||||
} else if (paymentIntent && paymentIntent.status === 'succeeded') {
|
||||
onSuccess();
|
||||
} else if (paymentIntent) {
|
||||
switch (paymentIntent.status) {
|
||||
case 'succeeded':
|
||||
setPaymentStatus('succeeded');
|
||||
// Kleiner Delay für bessere UX
|
||||
setTimeout(() => nextStep(), 1000);
|
||||
break;
|
||||
case 'processing':
|
||||
setError(t('checkout.payment_step.processing'));
|
||||
setPaymentStatus('processing');
|
||||
break;
|
||||
case 'requires_payment_method':
|
||||
setError(t('checkout.payment_step.needs_method'));
|
||||
setPaymentStatus('failed');
|
||||
break;
|
||||
case 'requires_confirmation':
|
||||
setError(t('checkout.payment_step.needs_confirm'));
|
||||
setPaymentStatus('failed');
|
||||
break;
|
||||
default:
|
||||
setError(t('checkout.payment_step.unexpected_status', { status: paymentIntent.status }));
|
||||
setPaymentStatus('failed');
|
||||
}
|
||||
onError(t('checkout.payment_step.unexpected_status', { status: paymentIntent.status }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unexpected payment error:', err);
|
||||
setError(t('checkout.payment_step.error_unknown'));
|
||||
setPaymentStatus('failed');
|
||||
onError(t('checkout.payment_step.error_unknown'));
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('checkout.payment_step.secure_payment_desc')}
|
||||
</p>
|
||||
<PaymentElement />
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!stripe || isProcessing}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{isProcessing ? t('checkout.payment_step.processing_btn') : t('checkout.payment_step.pay_now', { price: selectedPackage?.price || 0 })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('checkout.payment_step.secure_payment_desc')}
|
||||
</p>
|
||||
<PaymentElement />
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!stripe || isProcessing}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{isProcessing ? t('checkout.payment_step.processing_btn') : t('checkout.payment_step.pay_now', { price: selectedPackage?.price || 0 })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
// Komponente für PayPal-Zahlungen
|
||||
const PayPalPaymentForm: React.FC<{ onError: (error: string) => void; onSuccess: () => void; selectedPackage: any; t: any; authUser: any; paypalClientId: string }> = ({ onError, onSuccess, selectedPackage, t, authUser, paypalClientId }) => {
|
||||
const createOrder = async () => {
|
||||
try {
|
||||
const response = await fetch('/paypal/create-order', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: authUser?.tenant_id || authUser?.id, // Annahme: tenant_id verfügbar
|
||||
package_id: selectedPackage?.id,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.id) {
|
||||
return data.id;
|
||||
} else {
|
||||
onError(data.error || t('checkout.payment_step.paypal_order_error'));
|
||||
throw new Error('Failed to create order');
|
||||
}
|
||||
} catch (err) {
|
||||
onError(t('checkout.payment_step.network_error'));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const onApprove = async (data: any) => {
|
||||
try {
|
||||
const response = await fetch('/paypal/capture-order', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
order_id: data.orderID,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.status === 'captured') {
|
||||
onSuccess();
|
||||
} else {
|
||||
onError(result.error || t('checkout.payment_step.paypal_capture_error'));
|
||||
}
|
||||
} catch (err) {
|
||||
onError(t('checkout.payment_step.network_error'));
|
||||
}
|
||||
};
|
||||
|
||||
const onErrorHandler = (error: any) => {
|
||||
console.error('PayPal Error:', error);
|
||||
onError(t('checkout.payment_step.paypal_error'));
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
onError(t('checkout.payment_step.paypal_cancelled'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('checkout.payment_step.secure_paypal_desc') || 'Bezahlen Sie sicher mit PayPal.'}
|
||||
</p>
|
||||
<PayPalButtons
|
||||
style={{ layout: 'vertical' }}
|
||||
createOrder={createOrder}
|
||||
onApprove={onApprove}
|
||||
onError={onErrorHandler}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrapper-Komponente mit eigenem Elements Provider
|
||||
export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey }) => {
|
||||
// Wrapper-Komponente
|
||||
export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey, paypalClientId }) => {
|
||||
const { t } = useTranslation('marketing');
|
||||
const { selectedPackage, authUser, nextStep } = useCheckoutWizard();
|
||||
const { selectedPackage, authUser, nextStep, resetPaymentState } = useCheckoutWizard();
|
||||
const [clientSecret, setClientSecret] = useState<string>('');
|
||||
const [paymentMethod, setPaymentMethod] = useState<'stripe' | 'paypal'>('stripe');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [isFree, setIsFree] = useState(false);
|
||||
|
||||
const isFree = selectedPackage ? selectedPackage.price <= 0 : false;
|
||||
|
||||
// Payment Intent für kostenpflichtige Pakete laden
|
||||
useEffect(() => {
|
||||
if (isFree || !authUser || !selectedPackage) return;
|
||||
const free = selectedPackage ? selectedPackage.price <= 0 : false;
|
||||
setIsFree(free);
|
||||
if (free) {
|
||||
resetPaymentState();
|
||||
return;
|
||||
}
|
||||
|
||||
const loadPaymentIntent = async () => {
|
||||
try {
|
||||
const response = await fetch('/stripe/create-payment-intent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
package_id: selectedPackage.id,
|
||||
}),
|
||||
});
|
||||
if (paymentMethod === 'stripe' && authUser && selectedPackage) {
|
||||
const loadPaymentIntent = async () => {
|
||||
try {
|
||||
const response = await fetch('/stripe/create-payment-intent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
package_id: selectedPackage.id,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Payment Intent Response:', {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
data: data
|
||||
});
|
||||
|
||||
if (response.ok && data.client_secret) {
|
||||
setClientSecret(data.client_secret);
|
||||
setError('');
|
||||
} else {
|
||||
const errorMsg = data.error || t('checkout.payment_step.payment_intent_error');
|
||||
console.error('Payment Intent Error:', errorMsg);
|
||||
setError(errorMsg);
|
||||
if (response.ok && data.client_secret) {
|
||||
setClientSecret(data.client_secret);
|
||||
setError('');
|
||||
} else {
|
||||
setError(data.error || t('checkout.payment_step.payment_intent_error'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('checkout.payment_step.network_error'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('checkout.payment_step.network_error'));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
loadPaymentIntent();
|
||||
}, [selectedPackage?.id, authUser, isFree, t]);
|
||||
loadPaymentIntent();
|
||||
} else {
|
||||
setClientSecret('');
|
||||
}
|
||||
}, [selectedPackage?.id, authUser, paymentMethod, isFree, t, resetPaymentState]);
|
||||
|
||||
// Für kostenlose Pakete: Direkte Aktivierung ohne Stripe
|
||||
const handlePaymentError = (errorMsg: string) => {
|
||||
setError(errorMsg);
|
||||
};
|
||||
|
||||
const handlePaymentSuccess = () => {
|
||||
setTimeout(() => nextStep(), 1000);
|
||||
};
|
||||
|
||||
// Für kostenlose Pakete
|
||||
if (isFree) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -202,30 +263,79 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey }
|
||||
);
|
||||
}
|
||||
|
||||
// Für kostenpflichtige Pakete: Warten auf clientSecret
|
||||
if (!clientSecret) {
|
||||
// Fehler anzeigen
|
||||
if (error && !clientSecret) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('checkout.payment_step.loading_payment')}
|
||||
</p>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setError('')}>Versuchen Sie es erneut</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Eigener Elements Provider mit clientSecret für kostenpflichtige Pakete
|
||||
const stripePromise = loadStripe(stripePublishableKey);
|
||||
|
||||
return (
|
||||
<Elements stripe={stripePromise} options={{ clientSecret }}>
|
||||
<PaymentForm />
|
||||
</Elements>
|
||||
<div className="space-y-6">
|
||||
{/* Zahlungsmethode Auswahl */}
|
||||
<div className="flex space-x-4">
|
||||
<Button
|
||||
variant={paymentMethod === 'stripe' ? 'default' : 'outline'}
|
||||
onClick={() => setPaymentMethod('stripe')}
|
||||
className="flex-1"
|
||||
>
|
||||
Kreditkarte (Stripe)
|
||||
</Button>
|
||||
<Button
|
||||
variant={paymentMethod === 'paypal' ? 'default' : 'outline'}
|
||||
onClick={() => setPaymentMethod('paypal')}
|
||||
className="flex-1"
|
||||
>
|
||||
PayPal
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'stripe' && (
|
||||
<Elements stripe={stripePromise} options={{ clientSecret }}>
|
||||
<StripePaymentForm
|
||||
onError={handlePaymentError}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
selectedPackage={selectedPackage}
|
||||
t={t}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'paypal' && (
|
||||
<PayPalScriptProvider options={{ clientId: paypalClientId, currency: 'EUR' }}>
|
||||
<PayPalPaymentForm
|
||||
onError={handlePaymentError}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
selectedPackage={selectedPackage}
|
||||
t={t}
|
||||
authUser={authUser}
|
||||
paypalClientId={paypalClientId}
|
||||
/>
|
||||
</PayPalScriptProvider>
|
||||
)}
|
||||
|
||||
{!clientSecret && paymentMethod === 'stripe' && (
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('checkout.payment_step.loading_payment')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user