implemented event package addons with filament resource, event-admin purchase path and notifications, showing up in purchase history
This commit is contained in:
@@ -82,6 +82,7 @@ export type TenantEvent = {
|
||||
expires_at: string | null;
|
||||
} | null;
|
||||
limits?: EventLimitSummary | null;
|
||||
addons?: EventAddonSummary[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -156,6 +157,32 @@ export type PhotoboothStatus = {
|
||||
};
|
||||
};
|
||||
|
||||
export type EventAddonCheckout = {
|
||||
addon_key: string;
|
||||
quantity?: number;
|
||||
checkout_url: string | null;
|
||||
checkout_id: string | null;
|
||||
expires_at: string | null;
|
||||
};
|
||||
|
||||
export type EventAddonCatalogItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
price_id: string | null;
|
||||
increments?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type EventAddonSummary = {
|
||||
id: number;
|
||||
key: string;
|
||||
label?: string | null;
|
||||
status: 'pending' | 'completed' | 'failed';
|
||||
extra_photos: number;
|
||||
extra_guests: number;
|
||||
extra_gallery_days: number;
|
||||
purchased_at: string | null;
|
||||
};
|
||||
|
||||
export type HelpCenterArticleSummary = {
|
||||
slug: string;
|
||||
title: string;
|
||||
@@ -338,6 +365,28 @@ export type PaddleTransactionSummary = {
|
||||
tax?: number | null;
|
||||
};
|
||||
|
||||
export type TenantAddonEventSummary = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string | Record<string, string> | null;
|
||||
};
|
||||
|
||||
export type TenantAddonHistoryEntry = {
|
||||
id: number;
|
||||
addon_key: string;
|
||||
label?: string | null;
|
||||
event: TenantAddonEventSummary | null;
|
||||
amount: number | null;
|
||||
currency: string | null;
|
||||
status: 'pending' | 'completed' | 'failed';
|
||||
purchased_at: string | null;
|
||||
extra_photos: number;
|
||||
extra_guests: number;
|
||||
extra_gallery_days: number;
|
||||
quantity: number;
|
||||
receipt_url?: string | null;
|
||||
};
|
||||
|
||||
export type CreditLedgerEntry = {
|
||||
id: number;
|
||||
delta: number;
|
||||
@@ -829,6 +878,48 @@ function normalizePaddleTransaction(entry: JsonValue): PaddleTransactionSummary
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTenantAddonHistoryEntry(entry: JsonValue): TenantAddonHistoryEntry {
|
||||
let event: TenantAddonEventSummary | null = null;
|
||||
|
||||
if (entry.event && typeof entry.event === 'object') {
|
||||
const rawEvent = entry.event as JsonValue;
|
||||
const id = Number((rawEvent as { id?: unknown }).id ?? 0);
|
||||
const slugValue = (rawEvent as { slug?: unknown }).slug;
|
||||
const rawName = (rawEvent as { name?: unknown }).name ?? null;
|
||||
let name: TenantAddonEventSummary['name'] = null;
|
||||
|
||||
if (typeof rawName === 'string') {
|
||||
name = rawName;
|
||||
} else if (rawName && typeof rawName === 'object') {
|
||||
name = normalizeTranslationMap(rawName, undefined, true);
|
||||
}
|
||||
|
||||
event = {
|
||||
id,
|
||||
slug: typeof slugValue === 'string' ? slugValue : '',
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
const amountValue = entry.amount;
|
||||
|
||||
return {
|
||||
id: Number(entry.id ?? 0),
|
||||
addon_key: String(entry.addon_key ?? ''),
|
||||
label: typeof entry.label === 'string' ? entry.label : null,
|
||||
event,
|
||||
amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null,
|
||||
currency: typeof entry.currency === 'string' ? entry.currency : null,
|
||||
status: (entry.status as TenantAddonHistoryEntry['status']) ?? 'pending',
|
||||
purchased_at: typeof entry.purchased_at === 'string' ? entry.purchased_at : null,
|
||||
extra_photos: Number(entry.extra_photos ?? 0),
|
||||
extra_guests: Number(entry.extra_guests ?? 0),
|
||||
extra_gallery_days: Number(entry.extra_gallery_days ?? 0),
|
||||
quantity: Number(entry.quantity ?? 1),
|
||||
receipt_url: typeof entry.receipt_url === 'string' ? entry.receipt_url : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTask(task: JsonValue): TenantTask {
|
||||
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
|
||||
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
|
||||
@@ -1122,6 +1213,28 @@ export async function getEvent(slug: string): Promise<TenantEvent> {
|
||||
return normalizeEvent(data.data);
|
||||
}
|
||||
|
||||
export async function createEventAddonCheckout(
|
||||
eventSlug: string,
|
||||
params: { addon_key: string; quantity?: number; success_url?: string; cancel_url?: string }
|
||||
): Promise<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(eventSlug)}/addons/checkout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
return await jsonOrThrow<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }>(
|
||||
response,
|
||||
'Failed to create addon checkout'
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAddonCatalog(): Promise<EventAddonCatalogItem[]> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/addons/catalog');
|
||||
const data = await jsonOrThrow<{ data?: EventAddonCatalogItem[] }>(response, 'Failed to load add-ons');
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
export async function getEventTypes(): Promise<TenantEventType[]> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/event-types');
|
||||
const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load event types');
|
||||
@@ -1675,6 +1788,42 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
|
||||
data: TenantAddonHistoryEntry[];
|
||||
meta: PaginationMeta;
|
||||
}> {
|
||||
const params = new URLSearchParams({
|
||||
page: String(Math.max(1, page)),
|
||||
per_page: String(Math.max(1, Math.min(perPage, 100))),
|
||||
});
|
||||
|
||||
const response = await authorizedFetch(`/api/v1/tenant/billing/addons?${params.toString()}`);
|
||||
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
data: [],
|
||||
meta: { current_page: 1, last_page: 1, per_page: perPage, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await jsonOrThrow<{ data?: JsonValue[]; meta?: Partial<PaginationMeta>; current_page?: number; last_page?: number; per_page?: number; total?: number }>(
|
||||
response,
|
||||
'Failed to load add-on history'
|
||||
);
|
||||
|
||||
const rows = Array.isArray(payload.data) ? payload.data.map((row) => normalizeTenantAddonHistoryEntry(row)) : [];
|
||||
const metaSource = payload.meta ?? payload;
|
||||
|
||||
const meta: PaginationMeta = {
|
||||
current_page: Number(metaSource.current_page ?? 1),
|
||||
last_page: Number(metaSource.last_page ?? 1),
|
||||
per_page: Number(metaSource.per_page ?? perPage),
|
||||
total: Number(metaSource.total ?? rows.length),
|
||||
};
|
||||
|
||||
return { data: rows, meta };
|
||||
}
|
||||
|
||||
export async function getCreditBalance(): Promise<CreditBalance> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/credits/balance');
|
||||
if (response.status === 404) {
|
||||
|
||||
66
resources/js/admin/components/Addons/AddonSummaryList.tsx
Normal file
66
resources/js/admin/components/Addons/AddonSummaryList.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { EventAddonSummary } from '../../api';
|
||||
|
||||
type Props = {
|
||||
addons: EventAddonSummary[];
|
||||
t: (key: string, fallback: string) => string;
|
||||
};
|
||||
|
||||
export function AddonSummaryList({ addons, t }: Props) {
|
||||
if (!addons.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{addons.map((addon) => (
|
||||
<div key={addon.id} className="flex flex-col gap-1 rounded-2xl border border-slate-200/70 bg-white/70 p-4 text-sm dark:border-white/10 dark:bg-white/5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-white">{addon.label ?? addon.key}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{buildSummary(addon, t)}
|
||||
</p>
|
||||
{addon.purchased_at ? (
|
||||
<p className="text-xs text-slate-400">
|
||||
{t('events.sections.addons.purchasedAt', `Purchased ${new Date(addon.purchased_at).toLocaleString()}`, {
|
||||
date: new Date(addon.purchased_at).toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Badge variant={addon.status === 'completed' ? 'outline' : addon.status === 'pending' ? 'secondary' : 'destructive'}>
|
||||
{t(`events.sections.addons.status.${addon.status}`, addon.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSummary(addon: EventAddonSummary, t: (key: string, fallback: string, options?: Record<string, unknown>) => string): string {
|
||||
const parts: string[] = [];
|
||||
if (addon.extra_photos > 0) {
|
||||
parts.push(
|
||||
t('events.sections.addons.summary.photos', `+${addon.extra_photos} photos`, {
|
||||
count: addon.extra_photos.toLocaleString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (addon.extra_guests > 0) {
|
||||
parts.push(
|
||||
t('events.sections.addons.summary.guests', `+${addon.extra_guests} guests`, {
|
||||
count: addon.extra_guests.toLocaleString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (addon.extra_gallery_days > 0) {
|
||||
parts.push(
|
||||
t('events.sections.addons.summary.gallery', `+${addon.extra_gallery_days} days gallery`, {
|
||||
count: addon.extra_gallery_days,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
64
resources/js/admin/components/Addons/AddonsPicker.tsx
Normal file
64
resources/js/admin/components/Addons/AddonsPicker.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { ShoppingCart } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { EventAddonCatalogItem } from '../../api';
|
||||
|
||||
type Props = {
|
||||
addons: EventAddonCatalogItem[];
|
||||
scope: 'photos' | 'guests' | 'gallery';
|
||||
onCheckout: (addonKey: string) => void;
|
||||
busy?: boolean;
|
||||
t: (key: string, fallback: string) => string;
|
||||
};
|
||||
|
||||
const scopeDefaults: Record<Props['scope'], string[]> = {
|
||||
photos: ['extra_photos_500', 'extra_photos_2000'],
|
||||
guests: ['extra_guests_50', 'extra_guests_100'],
|
||||
gallery: ['extend_gallery_30d', 'extend_gallery_90d'],
|
||||
};
|
||||
|
||||
export function AddonsPicker({ addons, scope, onCheckout, busy, t }: Props) {
|
||||
const options = React.useMemo(() => {
|
||||
const whitelist = scopeDefaults[scope];
|
||||
const filtered = addons.filter((addon) => whitelist.includes(addon.key));
|
||||
return filtered.length ? filtered : addons;
|
||||
}, [addons, scope]);
|
||||
|
||||
const [selected, setSelected] = React.useState<string | undefined>(() => options[0]?.key);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelected(options[0]?.key);
|
||||
}, [options]);
|
||||
|
||||
if (!options.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Select value={selected} onValueChange={(value) => setSelected(value)}>
|
||||
<SelectTrigger className="w-full sm:w-64">
|
||||
<SelectValue placeholder={t('addons.selectPlaceholder', 'Add-on auswählen')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((addon) => (
|
||||
<SelectItem key={addon.key} value={addon.key} disabled={!addon.price_id}>
|
||||
{addon.label}
|
||||
{!addon.price_id ? ' (kein Preis verknüpft)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!selected || busy || !options.find((a) => a.key === selected)?.price_id}
|
||||
onClick={() => selected && onCheckout(selected)}
|
||||
>
|
||||
<ShoppingCart className="mr-2 h-4 w-4" />
|
||||
{t('addons.buyNow', 'Jetzt freischalten')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,9 @@
|
||||
"galleryWarningDay": "Galerie läuft in {days} Tag ab.",
|
||||
"galleryWarningDays": "Galerie läuft in {days} Tagen ab.",
|
||||
"galleryExpired": "Galerie ist abgelaufen. Gäste sehen keine Inhalte mehr.",
|
||||
"unlimited": "Unbegrenzt"
|
||||
"unlimited": "Unbegrenzt",
|
||||
"buyMorePhotos": "Mehr Fotos freischalten",
|
||||
"buyMoreGuests": "Mehr Gäste freischalten",
|
||||
"extendGallery": "Galerie verlängern"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,32 @@
|
||||
"loadingMore": "Laden…"
|
||||
}
|
||||
},
|
||||
"addOns": {
|
||||
"title": "Add-on-Verlauf",
|
||||
"description": "Einmalige Add-ons, die für diesen Tenant gebucht wurden.",
|
||||
"empty": "Noch keine Add-ons gebucht.",
|
||||
"badge": "Add-ons",
|
||||
"table": {
|
||||
"addon": "Add-on",
|
||||
"event": "Event",
|
||||
"amount": "Betrag",
|
||||
"status": "Status",
|
||||
"purchased": "Gekauft",
|
||||
"eventFallback": "Event archiviert"
|
||||
},
|
||||
"status": {
|
||||
"pending": "In Bearbeitung",
|
||||
"completed": "Abgeschlossen",
|
||||
"failed": "Fehlgeschlagen"
|
||||
},
|
||||
"extras": {
|
||||
"photos": "+{{count}} Fotos",
|
||||
"guests": "+{{count}} Gäste",
|
||||
"gallery": "+{{count}} Galerietage"
|
||||
},
|
||||
"loadMore": "Weitere Add-ons laden",
|
||||
"loadingMore": "Add-ons werden geladen…"
|
||||
},
|
||||
"packages": {
|
||||
"title": "Paket-Historie",
|
||||
"description": "Übersicht über aktive und vergangene Pakete.",
|
||||
@@ -382,7 +408,8 @@
|
||||
"backToEvent": "Event öffnen",
|
||||
"copy": "Link kopieren",
|
||||
"copied": "Kopiert!",
|
||||
"deactivate": "Deaktivieren"
|
||||
"deactivate": "Deaktivieren",
|
||||
"buyMoreGuests": "Mehr Gäste freischalten"
|
||||
},
|
||||
"labels": {
|
||||
"usage": "Nutzung",
|
||||
@@ -511,11 +538,16 @@
|
||||
"loadFailed": "Event konnte nicht geladen werden.",
|
||||
"notFoundTitle": "Event nicht gefunden",
|
||||
"notFoundBody": "Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.",
|
||||
"toggleFailed": "Status konnte nicht angepasst werden."
|
||||
"toggleFailed": "Status konnte nicht angepasst werden.",
|
||||
"checkoutMissing": "Checkout konnte nicht gestartet werden.",
|
||||
"checkoutFailed": "Add-on Checkout fehlgeschlagen."
|
||||
},
|
||||
"alerts": {
|
||||
"failedTitle": "Aktion fehlgeschlagen"
|
||||
},
|
||||
"success": {
|
||||
"addonApplied": "Add-on angewendet. Limits aktualisieren sich in Kürze."
|
||||
},
|
||||
"placeholders": {
|
||||
"untitled": "Unbenanntes Event"
|
||||
},
|
||||
@@ -526,7 +558,10 @@
|
||||
"tasks": "Aufgaben verwalten",
|
||||
"invites": "Einladungen & Layouts",
|
||||
"photos": "Fotos moderieren",
|
||||
"refresh": "Aktualisieren"
|
||||
"refresh": "Aktualisieren",
|
||||
"buyMorePhotos": "Mehr Fotos freischalten",
|
||||
"buyMoreGuests": "Mehr Gäste freischalten",
|
||||
"extendGallery": "Galerie verlängern"
|
||||
},
|
||||
"workspace": {
|
||||
"detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.",
|
||||
@@ -552,6 +587,23 @@
|
||||
"activeYes": "Ja",
|
||||
"activeNo": "Nein"
|
||||
},
|
||||
"sections": {
|
||||
"addons": {
|
||||
"title": "Add-ons & Upgrades",
|
||||
"description": "Zuletzt gebuchte Add-ons für dieses Event.",
|
||||
"status": {
|
||||
"completed": "Aktiv",
|
||||
"pending": "In Bearbeitung",
|
||||
"failed": "Fehlgeschlagen"
|
||||
},
|
||||
"purchasedAt": "Gekauft {{date}}",
|
||||
"summary": {
|
||||
"photos": "+{{count}} Fotos",
|
||||
"guests": "+{{count}} Gäste",
|
||||
"gallery": "+{{count}} Tage Galerie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"published": "Veröffentlicht",
|
||||
"draft": "Entwurf",
|
||||
|
||||
@@ -72,6 +72,9 @@
|
||||
"galleryWarningDay": "Gallery expires in {days} day.",
|
||||
"galleryWarningDays": "Gallery expires in {days} days.",
|
||||
"galleryExpired": "Gallery has expired. Guests can no longer access the photos.",
|
||||
"unlimited": "Unlimited"
|
||||
"unlimited": "Unlimited",
|
||||
"buyMorePhotos": "Unlock more photos",
|
||||
"buyMoreGuests": "Unlock more guests",
|
||||
"extendGallery": "Extend gallery"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,32 @@
|
||||
"loadingMore": "Loading…"
|
||||
}
|
||||
},
|
||||
"addOns": {
|
||||
"title": "Add-on history",
|
||||
"description": "One-time add-ons purchased for this tenant.",
|
||||
"empty": "No add-ons purchased yet.",
|
||||
"badge": "Add-ons",
|
||||
"table": {
|
||||
"addon": "Add-on",
|
||||
"event": "Event",
|
||||
"amount": "Amount",
|
||||
"status": "Status",
|
||||
"purchased": "Purchased",
|
||||
"eventFallback": "Event archived"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Processing",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"extras": {
|
||||
"photos": "+{{count}} photos",
|
||||
"guests": "+{{count}} guests",
|
||||
"gallery": "+{{count}} gallery days"
|
||||
},
|
||||
"loadMore": "Load more add-ons",
|
||||
"loadingMore": "Loading add-ons…"
|
||||
},
|
||||
"packages": {
|
||||
"title": "Package history",
|
||||
"description": "Overview of current and past packages.",
|
||||
@@ -382,7 +408,8 @@
|
||||
"backToEvent": "Open event",
|
||||
"copy": "Copy link",
|
||||
"copied": "Copied!",
|
||||
"deactivate": "Deactivate"
|
||||
"deactivate": "Deactivate",
|
||||
"buyMoreGuests": "Unlock more guests"
|
||||
},
|
||||
"labels": {
|
||||
"usage": "Usage",
|
||||
@@ -511,11 +538,16 @@
|
||||
"loadFailed": "Event could not be loaded.",
|
||||
"notFoundTitle": "Event not found",
|
||||
"notFoundBody": "Without a valid identifier we can’t load the data. Return to the list and choose an event.",
|
||||
"toggleFailed": "Status could not be updated."
|
||||
"toggleFailed": "Status could not be updated.",
|
||||
"checkoutMissing": "Checkout could not be started.",
|
||||
"checkoutFailed": "Add-on checkout failed."
|
||||
},
|
||||
"alerts": {
|
||||
"failedTitle": "Action failed"
|
||||
},
|
||||
"success": {
|
||||
"addonApplied": "Add-on applied. Limits will refresh shortly."
|
||||
},
|
||||
"placeholders": {
|
||||
"untitled": "Untitled event"
|
||||
},
|
||||
@@ -526,7 +558,10 @@
|
||||
"tasks": "Manage tasks",
|
||||
"invites": "Invites & layouts",
|
||||
"photos": "Moderate photos",
|
||||
"refresh": "Refresh"
|
||||
"refresh": "Refresh",
|
||||
"buyMorePhotos": "Unlock more photos",
|
||||
"buyMoreGuests": "Unlock more guests",
|
||||
"extendGallery": "Extend gallery"
|
||||
},
|
||||
"workspace": {
|
||||
"detailSubtitle": "Keep status, tasks, and invites of your event in one view.",
|
||||
@@ -552,6 +587,23 @@
|
||||
"activeYes": "Yes",
|
||||
"activeNo": "No"
|
||||
},
|
||||
"sections": {
|
||||
"addons": {
|
||||
"title": "Add-ons & Boosts",
|
||||
"description": "Recently purchased add-ons for this event.",
|
||||
"status": {
|
||||
"completed": "Active",
|
||||
"pending": "Processing",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"purchasedAt": "Purchased {{date}}",
|
||||
"summary": {
|
||||
"photos": "+{{count}} photos",
|
||||
"guests": "+{{count}} guests",
|
||||
"gallery": "+{{count}} days gallery"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"published": "Published",
|
||||
"draft": "Draft",
|
||||
|
||||
@@ -8,7 +8,15 @@ import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
|
||||
import {
|
||||
getTenantPackagesOverview,
|
||||
getTenantPaddleTransactions,
|
||||
getTenantAddonHistory,
|
||||
PaddleTransactionSummary,
|
||||
TenantAddonHistoryEntry,
|
||||
TenantPackageSummary,
|
||||
PaginationMeta,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import {
|
||||
TenantHeroCard,
|
||||
@@ -34,6 +42,9 @@ export default function BillingPage() {
|
||||
const [transactionCursor, setTransactionCursor] = React.useState<string | null>(null);
|
||||
const [transactionsHasMore, setTransactionsHasMore] = React.useState(false);
|
||||
const [transactionsLoading, setTransactionsLoading] = React.useState(false);
|
||||
const [addonHistory, setAddonHistory] = React.useState<TenantAddonHistoryEntry[]>([]);
|
||||
const [addonMeta, setAddonMeta] = React.useState<PaginationMeta | null>(null);
|
||||
const [addonsLoading, setAddonsLoading] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
@@ -55,6 +66,33 @@ export default function BillingPage() {
|
||||
[locale]
|
||||
);
|
||||
|
||||
const resolveEventName = React.useCallback(
|
||||
(event: TenantAddonHistoryEntry['event']) => {
|
||||
const fallback = t('billing.sections.addOns.table.eventFallback', 'Event removed');
|
||||
if (!event) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof event.name === 'string' && event.name.trim().length > 0) {
|
||||
return event.name;
|
||||
}
|
||||
|
||||
if (event.name && typeof event.name === 'object') {
|
||||
const lang = i18n.language?.split('-')[0] ?? 'de';
|
||||
return (
|
||||
event.name[lang] ??
|
||||
event.name.de ??
|
||||
event.name.en ??
|
||||
Object.values(event.name)[0] ??
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
},
|
||||
[i18n.language, t]
|
||||
);
|
||||
|
||||
const packageLabels = React.useMemo(
|
||||
() => ({
|
||||
statusActive: t('billing.sections.packages.card.statusActive'),
|
||||
@@ -70,18 +108,24 @@ export default function BillingPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [packagesResult, paddleTransactions] = await Promise.all([
|
||||
const [packagesResult, paddleTransactions, addonHistoryResult] = await Promise.all([
|
||||
getTenantPackagesOverview(force ? { force: true } : undefined),
|
||||
getTenantPaddleTransactions().catch((err) => {
|
||||
console.warn('Failed to load Paddle transactions', err);
|
||||
return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false };
|
||||
}),
|
||||
getTenantAddonHistory().catch((err) => {
|
||||
console.warn('Failed to load add-on history', err);
|
||||
return { data: [] as TenantAddonHistoryEntry[], meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 } };
|
||||
}),
|
||||
]);
|
||||
setPackages(packagesResult.packages);
|
||||
setActivePackage(packagesResult.activePackage);
|
||||
setTransactions(paddleTransactions.data);
|
||||
setTransactionCursor(paddleTransactions.nextCursor);
|
||||
setTransactionsHasMore(paddleTransactions.hasMore);
|
||||
setAddonHistory(addonHistoryResult.data);
|
||||
setAddonMeta(addonHistoryResult.meta);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(t('billing.errors.load'));
|
||||
@@ -110,6 +154,24 @@ export default function BillingPage() {
|
||||
}
|
||||
}, [transactionCursor, transactionsHasMore, transactionsLoading]);
|
||||
|
||||
const loadMoreAddons = React.useCallback(async () => {
|
||||
if (addonsLoading || !addonMeta || addonMeta.current_page >= addonMeta.last_page) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAddonsLoading(true);
|
||||
try {
|
||||
const nextPage = addonMeta.current_page + 1;
|
||||
const result = await getTenantAddonHistory(nextPage);
|
||||
setAddonHistory((current) => [...current, ...result.data]);
|
||||
setAddonMeta(result.meta);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load additional add-on history', error);
|
||||
} finally {
|
||||
setAddonsLoading(false);
|
||||
}
|
||||
}, [addonMeta, addonsLoading]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadAll();
|
||||
}, [loadAll]);
|
||||
@@ -118,6 +180,12 @@ export default function BillingPage() {
|
||||
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
|
||||
[activePackage, t, formatDate],
|
||||
);
|
||||
const hasMoreAddons = React.useMemo(() => {
|
||||
if (!addonMeta) {
|
||||
return false;
|
||||
}
|
||||
return addonMeta.current_page < addonMeta.last_page;
|
||||
}, [addonMeta]);
|
||||
|
||||
const heroBadge = t('billing.hero.badge', 'Abrechnung');
|
||||
const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.');
|
||||
@@ -288,6 +356,38 @@ export default function BillingPage() {
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard className="space-y-4">
|
||||
<SectionHeader
|
||||
eyebrow={t('billing.sections.addOns.badge', 'Add-ons')}
|
||||
title={t('billing.sections.addOns.title')}
|
||||
description={t('billing.sections.addOns.description')}
|
||||
/>
|
||||
{addonHistory.length === 0 ? (
|
||||
<EmptyState message={t('billing.sections.addOns.empty')} />
|
||||
) : (
|
||||
<AddonHistoryTable
|
||||
items={addonHistory}
|
||||
formatCurrency={formatCurrency}
|
||||
formatDate={formatDate}
|
||||
resolveEventName={resolveEventName}
|
||||
locale={locale}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{hasMoreAddons && (
|
||||
<Button variant="outline" onClick={() => void loadMoreAddons()} disabled={addonsLoading}>
|
||||
{addonsLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('billing.sections.addOns.loadingMore', 'Loading add-ons...')}
|
||||
</>
|
||||
) : (
|
||||
t('billing.sections.addOns.loadMore', 'Load more add-ons')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard className="space-y-4">
|
||||
<SectionHeader
|
||||
eyebrow={t('billing.sections.transactions.badge', 'Transaktionen')}
|
||||
@@ -336,6 +436,118 @@ export default function BillingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function AddonHistoryTable({
|
||||
items,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
resolveEventName,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
items: TenantAddonHistoryEntry[];
|
||||
formatCurrency: (value: number | null | undefined, currency?: string) => string;
|
||||
formatDate: (value: string | null | undefined) => string;
|
||||
resolveEventName: (event: TenantAddonHistoryEntry['event']) => string;
|
||||
locale: string;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const extrasLabel = (key: 'photos' | 'guests' | 'gallery', count: number) =>
|
||||
t(`billing.sections.addOns.extras.${key}`, { count });
|
||||
|
||||
return (
|
||||
<FrostedSurface className="overflow-x-auto border border-slate-200/60 p-0 dark:border-slate-800/70">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-800">
|
||||
<thead className="bg-slate-50/60 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:bg-slate-900/20 dark:text-slate-400">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t('billing.sections.addOns.table.addon')}</th>
|
||||
<th className="px-4 py-3">{t('billing.sections.addOns.table.event')}</th>
|
||||
<th className="px-4 py-3">{t('billing.sections.addOns.table.amount')}</th>
|
||||
<th className="px-4 py-3">{t('billing.sections.addOns.table.status')}</th>
|
||||
<th className="px-4 py-3">{t('billing.sections.addOns.table.purchased')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/70">
|
||||
{items.map((item) => {
|
||||
const extras: string[] = [];
|
||||
if (item.extra_photos > 0) {
|
||||
extras.push(extrasLabel('photos', item.extra_photos));
|
||||
}
|
||||
if (item.extra_guests > 0) {
|
||||
extras.push(extrasLabel('guests', item.extra_guests));
|
||||
}
|
||||
if (item.extra_gallery_days > 0) {
|
||||
extras.push(extrasLabel('gallery', item.extra_gallery_days));
|
||||
}
|
||||
|
||||
const purchasedLabel = item.purchased_at
|
||||
? new Date(item.purchased_at).toLocaleString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: formatDate(item.purchased_at);
|
||||
|
||||
const statusKey = `billing.sections.addOns.status.${item.status}`;
|
||||
const statusLabel = t(statusKey, { defaultValue: item.status });
|
||||
const statusTone: Record<string, string> = {
|
||||
completed: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
|
||||
pending: 'bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
|
||||
failed: 'bg-rose-500/15 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200',
|
||||
};
|
||||
|
||||
return (
|
||||
<tr key={item.id} className="bg-white even:bg-slate-50/40 dark:bg-slate-950/50 dark:even:bg-slate-900/40">
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<span className="font-semibold">{item.label ?? item.addon_key}</span>
|
||||
{item.quantity > 1 ? (
|
||||
<Badge variant="outline" className="border-slate-200/70 text-[11px] font-medium dark:border-slate-700">
|
||||
×{item.quantity}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{extras.length > 0 ? (
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{extras.join(' · ')}</p>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<p className="font-medium text-slate-800 dark:text-slate-200">{resolveEventName(item.event)}</p>
|
||||
{item.event?.slug ? (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-500">{item.event.slug}</p>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(item.amount, item.currency ?? 'EUR')}
|
||||
</p>
|
||||
{item.receipt_url ? (
|
||||
<a
|
||||
href={item.receipt_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-sky-600 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200"
|
||||
>
|
||||
{t('billing.sections.transactions.labels.receipt')}
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<Badge className={statusTone[item.status] ?? 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top text-sm text-slate-600 dark:text-slate-300">{purchasedLabel}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</FrostedSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function TransactionCard({
|
||||
transaction,
|
||||
formatCurrency,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
RefreshCw,
|
||||
Smile,
|
||||
Sparkles,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
toggleEvent,
|
||||
submitTenantFeedback,
|
||||
updatePhotoVisibility,
|
||||
createEventAddonCheckout,
|
||||
} from '../api';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
@@ -54,6 +56,9 @@ import {
|
||||
ActionGrid,
|
||||
TenantHeroCard,
|
||||
} from '../components/tenant';
|
||||
import { AddonsPicker } from '../components/Addons/AddonsPicker';
|
||||
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
|
||||
import { EventAddonCatalogItem, getAddonCatalog } from '../api';
|
||||
import { GuestBroadcastCard } from '../components/GuestBroadcastCard';
|
||||
|
||||
type EventDetailPageProps = {
|
||||
@@ -76,6 +81,7 @@ type WorkspaceState = {
|
||||
|
||||
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
@@ -91,6 +97,9 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
});
|
||||
|
||||
const [toolkit, setToolkit] = React.useState<ToolkitState>({ data: null, loading: true, error: null });
|
||||
const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
|
||||
const [addonRefreshCount, setAddonRefreshCount] = React.useState(0);
|
||||
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
@@ -103,8 +112,9 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
setToolkit((prev) => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const [eventData, statsData] = await Promise.all([getEvent(slug), getEventStats(slug)]);
|
||||
const [eventData, statsData, addonOptions] = await Promise.all([getEvent(slug), getEventStats(slug), getAddonCatalog()]);
|
||||
setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false }));
|
||||
setAddonsCatalog(addonOptions);
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
@@ -181,7 +191,43 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
[event?.limits, tCommon],
|
||||
);
|
||||
|
||||
const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
||||
const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
||||
const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
|
||||
|
||||
const handleAddonPurchase = React.useCallback(
|
||||
async (scope: 'photos' | 'guests' | 'gallery', addonKeyOverride?: string) => {
|
||||
if (!slug) return;
|
||||
|
||||
const defaultAddons: Record<typeof scope, string> = {
|
||||
photos: 'extra_photos_500',
|
||||
guests: 'extra_guests_100',
|
||||
gallery: 'extend_gallery_30d',
|
||||
};
|
||||
|
||||
const addonKey = addonKeyOverride ?? defaultAddons[scope];
|
||||
setAddonBusyId(scope);
|
||||
try {
|
||||
const currentUrl = window.location.origin + window.location.pathname;
|
||||
const successUrl = `${currentUrl}?addon_success=1`;
|
||||
const checkout = await createEventAddonCheckout(slug, {
|
||||
addon_key: addonKey,
|
||||
quantity: 1,
|
||||
success_url: successUrl,
|
||||
cancel_url: currentUrl,
|
||||
});
|
||||
if (checkout.checkout_url) {
|
||||
window.location.href = checkout.checkout_url;
|
||||
} else {
|
||||
toast(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
|
||||
} finally {
|
||||
setAddonBusyId(null);
|
||||
}
|
||||
},
|
||||
[slug, t],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
limitWarnings.forEach((warning) => {
|
||||
@@ -198,6 +244,30 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
});
|
||||
}, [limitWarnings]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const success = searchParams.get('addon_success');
|
||||
if (success && slug) {
|
||||
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
|
||||
void load();
|
||||
setAddonRefreshCount(3);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('addon_success');
|
||||
const search = params.toString();
|
||||
navigate(search ? `${window.location.pathname}?${search}` : window.location.pathname, { replace: true });
|
||||
}
|
||||
}, [searchParams, slug, load, navigate, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (addonRefreshCount <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
void load();
|
||||
setAddonRefreshCount((count) => count - 1);
|
||||
}, 8000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [addonRefreshCount, load]);
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<AdminLayout
|
||||
@@ -230,10 +300,39 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
{(['photos', 'guests', 'gallery'] as const).includes(warning.scope) ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery'); }}
|
||||
disabled={addonBusyId === warning.scope}
|
||||
className="justify-start"
|
||||
>
|
||||
<ShoppingCart className="mr-2 h-4 w-4" />
|
||||
{warning.scope === 'photos'
|
||||
? t('events.actions.buyMorePhotos', 'Mehr Fotos freischalten')
|
||||
: warning.scope === 'guests'
|
||||
? t('events.actions.buyMoreGuests', 'Mehr Gäste freischalten')
|
||||
: t('events.actions.extendGallery', 'Galerie verlängern')}
|
||||
</Button>
|
||||
{addonsCatalog.length > 0 ? (
|
||||
<AddonsPicker
|
||||
addons={addonsCatalog}
|
||||
scope={warning.scope as 'photos' | 'guests' | 'gallery'}
|
||||
onCheckout={(key) => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery', key); }}
|
||||
busy={addonBusyId === warning.scope}
|
||||
t={(key, fallback) => t(key as any, fallback)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
@@ -257,7 +356,17 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
navigate={navigate}
|
||||
/>
|
||||
|
||||
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
|
||||
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
|
||||
|
||||
{state.event?.addons?.length ? (
|
||||
<SectionCard>
|
||||
<SectionHeader
|
||||
title={t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
||||
description={t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}
|
||||
/>
|
||||
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key, fallback)} />
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
|
||||
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
@@ -20,6 +21,9 @@ import {
|
||||
TenantEvent,
|
||||
updateEventQrInvite,
|
||||
EventQrInviteLayout,
|
||||
createEventAddonCheckout,
|
||||
getAddonCatalog,
|
||||
type EventAddonCatalogItem,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import {
|
||||
@@ -29,6 +33,8 @@ import {
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
} from '../constants';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { AddonsPicker } from '../components/Addons/AddonsPicker';
|
||||
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
|
||||
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
|
||||
import { buildDownloadFilename, normalizeEventDateSegment } from './components/invite-layout/fileNames';
|
||||
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
|
||||
@@ -191,9 +197,14 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const [eventData, invitesData] = await Promise.all([getEvent(slug), getEventQrInvites(slug)]);
|
||||
const [eventData, invitesData, catalog] = await Promise.all([
|
||||
getEvent(slug),
|
||||
getEventQrInvites(slug),
|
||||
getAddonCatalog(),
|
||||
]);
|
||||
setState({ event: eventData, invites: invitesData, loading: false, error: null });
|
||||
setSelectedInviteId((current) => current ?? invitesData[0]?.id ?? null);
|
||||
setAddonsCatalog(catalog);
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen konnten nicht geladen werden.' });
|
||||
@@ -765,6 +776,36 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
[state.event?.limits, tLimits]
|
||||
);
|
||||
|
||||
const [addonBusy, setAddonBusy] = React.useState<string | null>(null);
|
||||
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const handleAddonPurchase = React.useCallback(
|
||||
async (addonKey?: string) => {
|
||||
if (!slug) return;
|
||||
setAddonBusy('guests');
|
||||
const key = addonKey ?? 'extra_guests_100';
|
||||
try {
|
||||
const currentUrl = window.location.origin + window.location.pathname;
|
||||
const successUrl = `${currentUrl}?addon_success=1`;
|
||||
const checkout = await createEventAddonCheckout(slug, {
|
||||
addon_key: key,
|
||||
quantity: 1,
|
||||
success_url: successUrl,
|
||||
cancel_url: currentUrl,
|
||||
});
|
||||
if (checkout.checkout_url) {
|
||||
window.location.href = checkout.checkout_url;
|
||||
}
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, 'Checkout fehlgeschlagen.'));
|
||||
} finally {
|
||||
setAddonBusy(null);
|
||||
}
|
||||
},
|
||||
[slug],
|
||||
);
|
||||
|
||||
const limitScopeLabels = React.useMemo(
|
||||
() => ({
|
||||
photos: tLimits('photosTitle'),
|
||||
@@ -774,6 +815,16 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
[tLimits]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const success = searchParams.get('addon_success');
|
||||
if (success && slug) {
|
||||
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
|
||||
void load();
|
||||
searchParams.delete('addon_success');
|
||||
navigate(window.location.pathname, { replace: true });
|
||||
}
|
||||
}, [searchParams, slug, load, navigate, t]);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={eventName}
|
||||
@@ -788,18 +839,54 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{limitScopeLabels[warning.scope]}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-sm">
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{limitScopeLabels[warning.scope]}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-sm">
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
{warning.scope === 'guests' ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void handleAddonPurchase(); }}
|
||||
disabled={addonBusy === 'guests'}
|
||||
>
|
||||
<ShoppingCart className="mr-2 h-4 w-4" />
|
||||
{t('invites.actions.buyMoreGuests', 'Mehr Gäste freischalten')}
|
||||
</Button>
|
||||
<AddonsPicker
|
||||
addons={addonsCatalog}
|
||||
scope="guests"
|
||||
onCheckout={(key) => { void handleAddonPurchase(key); }}
|
||||
busy={addonBusy === 'guests'}
|
||||
t={(key, fallback) => t(key as any, fallback)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.event?.addons?.length ? (
|
||||
<Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle>
|
||||
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key as any, fallback)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
|
||||
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Camera, Loader2, Sparkles, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import { Camera, Loader2, Sparkles, Trash2, AlertTriangle, ShoppingCart } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import toast from 'react-hot-toast';
|
||||
import { AddonsPicker } from '../components/Addons/AddonsPicker';
|
||||
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
|
||||
import { getAddonCatalog, getEvent, type EventAddonCatalogItem, type EventAddonSummary } from '../api';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
|
||||
import { createEventAddonCheckout, deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage, isApiError } from '../lib/apiError';
|
||||
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings';
|
||||
@@ -31,6 +35,10 @@ export default function EventPhotosPage() {
|
||||
const [error, setError] = React.useState<string | undefined>(undefined);
|
||||
const [busyId, setBusyId] = React.useState<number | null>(null);
|
||||
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
|
||||
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
||||
const [catalogError, setCatalogError] = React.useState<string | undefined>(undefined);
|
||||
const [searchParams, setSearchParams] = React.useState(() => new URLSearchParams(window.location.search));
|
||||
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
@@ -40,9 +48,16 @@ export default function EventPhotosPage() {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const result = await getEventPhotos(slug);
|
||||
setPhotos(result.photos);
|
||||
setLimits(result.limits ?? null);
|
||||
const [photoResult, eventData, catalog] = await Promise.all([
|
||||
getEventPhotos(slug),
|
||||
getEvent(slug),
|
||||
getAddonCatalog(),
|
||||
]);
|
||||
setPhotos(photoResult.photos);
|
||||
setLimits(photoResult.limits ?? null);
|
||||
setEventAddons(eventData.addons ?? []);
|
||||
setAddons(catalog);
|
||||
setCatalogError(undefined);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.'));
|
||||
@@ -56,6 +71,18 @@ export default function EventPhotosPage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const success = searchParams.get('addon_success');
|
||||
if (success && slug) {
|
||||
toast(translateLimits('addonApplied', { defaultValue: 'Add-on angewendet. Limits aktualisieren sich in Kürze.' }));
|
||||
void load();
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.delete('addon_success');
|
||||
setSearchParams(params);
|
||||
navigate(window.location.pathname, { replace: true });
|
||||
}
|
||||
}, [searchParams, slug, load, navigate, translateLimits]);
|
||||
|
||||
async function handleToggleFeature(photo: TenantPhoto) {
|
||||
if (!slug) return;
|
||||
setBusyId(photo.id);
|
||||
@@ -126,7 +153,19 @@ export default function EventPhotosPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<LimitWarningsBanner limits={limits} translate={translateLimits} />
|
||||
<LimitWarningsBanner limits={limits} translate={translateLimits} eventSlug={slug} addons={addons} />
|
||||
|
||||
{eventAddons.length > 0 && (
|
||||
<Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle>
|
||||
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AddonSummaryList addons={eventAddons} t={(key, fallback) => t(key as any, fallback)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
|
||||
<CardHeader>
|
||||
@@ -197,11 +236,49 @@ export default function EventPhotosPage() {
|
||||
function LimitWarningsBanner({
|
||||
limits,
|
||||
translate,
|
||||
eventSlug,
|
||||
addons,
|
||||
}: {
|
||||
limits: EventLimitSummary | null;
|
||||
translate: (key: string, options?: Record<string, unknown>) => string;
|
||||
eventSlug: string | null;
|
||||
addons: EventAddonCatalogItem[];
|
||||
}) {
|
||||
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
|
||||
const [busyScope, setBusyScope] = React.useState<string | null>(null);
|
||||
|
||||
const handleCheckout = React.useCallback(
|
||||
async (scopeOrKey: 'photos' | 'gallery' | string) => {
|
||||
if (!eventSlug) return;
|
||||
const scope = scopeOrKey === 'gallery' || scopeOrKey === 'photos' ? scopeOrKey : (scopeOrKey.includes('gallery') ? 'gallery' : 'photos');
|
||||
setBusyScope(scope);
|
||||
const addonKey = scopeOrKey === 'photos' || scopeOrKey === 'gallery'
|
||||
? (() => {
|
||||
const fallbackKey = scope === 'photos' ? 'extra_photos_500' : 'extend_gallery_30d';
|
||||
const candidates = addons.filter((addon) => addon.price_id && addon.key.includes(scope === 'photos' ? 'photos' : 'gallery'));
|
||||
return candidates[0]?.key ?? fallbackKey;
|
||||
})()
|
||||
: scopeOrKey;
|
||||
try {
|
||||
const currentUrl = window.location.origin + window.location.pathname;
|
||||
const successUrl = `${currentUrl}?addon_success=1`;
|
||||
const checkout = await createEventAddonCheckout(eventSlug, {
|
||||
addon_key: addonKey,
|
||||
quantity: 1,
|
||||
success_url: successUrl,
|
||||
cancel_url: currentUrl,
|
||||
});
|
||||
if (checkout.checkout_url) {
|
||||
window.location.href = checkout.checkout_url;
|
||||
}
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, 'Checkout fehlgeschlagen.'));
|
||||
} finally {
|
||||
setBusyScope(null);
|
||||
}
|
||||
},
|
||||
[eventSlug, addons],
|
||||
);
|
||||
|
||||
if (!warnings.length) {
|
||||
return null;
|
||||
@@ -215,10 +292,36 @@ function LimitWarningsBanner({
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
{warning.scope === 'photos' || warning.scope === 'gallery' ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void handleCheckout(warning.scope as 'photos' | 'gallery'); }}
|
||||
disabled={busyScope === warning.scope}
|
||||
>
|
||||
<ShoppingCart className="mr-2 h-4 w-4" />
|
||||
{warning.scope === 'photos'
|
||||
? translate('buyMorePhotos', { defaultValue: 'Mehr Fotos freischalten' })
|
||||
: translate('extendGallery', { defaultValue: 'Galerie verlängern' })}
|
||||
</Button>
|
||||
<div className="text-xs text-slate-500">
|
||||
<AddonsPicker
|
||||
addons={addons}
|
||||
scope={warning.scope as 'photos' | 'gallery'}
|
||||
onCheckout={(key) => { void handleCheckout(key); }}
|
||||
busy={busyScope === warning.scope}
|
||||
t={(key, fallback) => translate(key, { defaultValue: fallback })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user