feat: implement AI styling foundation and billing scope rework
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-06 20:01:58 +01:00
parent df00deb0df
commit 36bed12ff9
80 changed files with 8944 additions and 49 deletions

View File

@@ -13,8 +13,12 @@ import {
getTenantBillingTransactions,
getTenantPackagesOverview,
getTenantPackageCheckoutStatus,
getEvent,
TenantPackageSummary,
TenantEvent,
TenantBillingTransactionSummary,
EventAddonSummary,
PaginationMeta,
downloadTenantBillingReceipt,
} from '../api';
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
@@ -43,18 +47,45 @@ import {
storePendingCheckout,
} from './lib/billingCheckout';
import { triggerDownloadFromBlob } from './invite-layout/export-utils';
import { useEventContext } from '../context/EventContext';
const CHECKOUT_POLL_INTERVAL_MS = 10000;
const BILLING_HISTORY_PAGE_SIZE = 8;
const CURRENT_EVENT_ADDONS_PAGE_SIZE = 6;
function createInitialPaginationMeta(perPage: number): PaginationMeta {
return {
current_page: 1,
last_page: 1,
per_page: perPage,
total: 0,
};
}
export default function MobileBillingPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
const location = useLocation();
const { activeEvent } = useEventContext();
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
const [transactions, setTransactions] = React.useState<TenantBillingTransactionSummary[]>([]);
const [transactionsMeta, setTransactionsMeta] = React.useState<PaginationMeta>(() =>
createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE)
);
const [transactionsLoadingMore, setTransactionsLoadingMore] = React.useState(false);
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [addonsMeta, setAddonsMeta] = React.useState<PaginationMeta>(() =>
createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE)
);
const [addonsLoadingMore, setAddonsLoadingMore] = React.useState(false);
const [scopeAddons, setScopeAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [scopeAddonsMeta, setScopeAddonsMeta] = React.useState<PaginationMeta>(() =>
createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE)
);
const [scopeAddonsLoadingMore, setScopeAddonsLoadingMore] = React.useState(false);
const [scopeEvent, setScopeEvent] = React.useState<TenantEvent | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [showPackageHistory, setShowPackageHistory] = React.useState(false);
@@ -63,6 +94,7 @@ export default function MobileBillingPage() {
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
const lastCheckoutStatusRef = React.useRef<string | null>(null);
const mismatchTrackingRef = React.useRef<string | null>(null);
const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de';
@@ -79,13 +111,51 @@ export default function MobileBillingPage() {
try {
const [pkg, trx, addonHistory] = await Promise.all([
getTenantPackagesOverview({ force: true }),
getTenantBillingTransactions().catch(() => ({ data: [] as TenantBillingTransactionSummary[] })),
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
getTenantBillingTransactions(1, BILLING_HISTORY_PAGE_SIZE).catch(() => ({
data: [] as TenantBillingTransactionSummary[],
meta: createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE),
})),
getTenantAddonHistory({ page: 1, perPage: BILLING_HISTORY_PAGE_SIZE }).catch(() => ({
data: [] as TenantAddonHistoryEntry[],
meta: createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE),
})),
]);
let scopedEvent: TenantEvent | null = null;
let scopedAddons: TenantAddonHistoryEntry[] = [];
const scopeSlug = activeEvent?.slug ?? null;
const scopeEventIdFallback = activeEvent?.id ?? null;
if (scopeSlug) {
scopedEvent = await getEvent(scopeSlug).catch(() => activeEvent ?? null);
const scopedEventId = scopedEvent?.id ?? scopeEventIdFallback;
if (scopedEventId) {
const scopedAddonHistory = await getTenantAddonHistory({
eventId: scopedEventId,
perPage: CURRENT_EVENT_ADDONS_PAGE_SIZE,
page: 1,
}).catch(() => ({
data: [] as TenantAddonHistoryEntry[],
meta: createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE),
}));
scopedAddons = scopedAddonHistory.data ?? [];
setScopeAddonsMeta(scopedAddonHistory.meta ?? createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE));
} else {
setScopeAddonsMeta(createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE));
}
} else {
setScopeAddonsMeta(createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE));
}
setPackages(pkg.packages ?? []);
setActivePackage(pkg.activePackage ?? null);
setTransactions(trx.data ?? []);
setTransactionsMeta(trx.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE));
setAddons(addonHistory.data ?? []);
setAddonsMeta(addonHistory.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE));
setScopeEvent(scopedEvent);
setScopeAddons(scopedAddons);
setError(null);
} catch (err) {
const message = getApiErrorMessage(err, t('billing.errors.load', 'Konnte Abrechnung nicht laden.'));
@@ -93,7 +163,7 @@ export default function MobileBillingPage() {
} finally {
setLoading(false);
}
}, [t]);
}, [activeEvent, t]);
const scrollToPackages = React.useCallback(() => {
packagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -116,6 +186,49 @@ export default function MobileBillingPage() {
);
const hiddenPackageCount = Math.max(0, nonActivePackages.length - 3);
const visiblePackageHistory = showPackageHistory ? nonActivePackages : nonActivePackages.slice(0, 3);
const scopedEventName = React.useMemo(() => {
if (!scopeEvent) {
return null;
}
return resolveLinkedEventName(scopeEvent.name, t);
}, [scopeEvent, t]);
const scopedEventPath = scopeEvent?.slug ? ADMIN_EVENT_VIEW_PATH(scopeEvent.slug) : null;
const activeEventId = scopeEvent?.id ?? activeEvent?.id ?? null;
const scopedEventPackage = scopeEvent?.package ?? null;
const scopedEventAddons = React.useMemo<EventAddonSummary[]>(() => {
const rows = scopeEvent?.addons;
return Array.isArray(rows) ? rows : [];
}, [scopeEvent?.addons]);
const scopeMismatchCount = React.useMemo(() => {
if (!activeEventId) {
return 0;
}
return addons.filter((addon) => addon.event?.id && Number(addon.event.id) !== Number(activeEventId)).length;
}, [activeEventId, addons]);
const trackBillingInteraction = React.useCallback(
(action: string, value?: number) => {
if (typeof window === 'undefined') {
return;
}
const maybePaq = (window as unknown as { _paq?: unknown[] })._paq;
if (!Array.isArray(maybePaq)) {
return;
}
const label = activeEventId ? `event:${activeEventId}` : 'event:none';
const payload: (string | number)[] = ['trackEvent', 'Admin Billing', action, label];
if (typeof value === 'number' && Number.isFinite(value)) {
payload.push(value);
}
maybePaq.push(payload);
},
[activeEventId],
);
const handleReceiptDownload = React.useCallback(
async (transaction: TenantBillingTransactionSummary) => {
@@ -138,10 +251,100 @@ export default function MobileBillingPage() {
[t],
);
const loadMoreTransactions = React.useCallback(async () => {
if (transactionsLoadingMore) {
return;
}
if (transactionsMeta.current_page >= transactionsMeta.last_page) {
return;
}
setTransactionsLoadingMore(true);
try {
const nextPage = transactionsMeta.current_page + 1;
const result = await getTenantBillingTransactions(nextPage, BILLING_HISTORY_PAGE_SIZE);
setTransactions((current) => [...current, ...(result.data ?? [])]);
setTransactionsMeta(result.meta ?? transactionsMeta);
trackBillingInteraction('transactions_load_more', nextPage);
} catch (err) {
toast.error(getApiErrorMessage(err, t('billing.errors.more', 'Konnte weitere Einträge nicht laden.')));
} finally {
setTransactionsLoadingMore(false);
}
}, [trackBillingInteraction, transactionsLoadingMore, transactionsMeta, t]);
const loadMoreAddonHistory = React.useCallback(async () => {
if (addonsLoadingMore) {
return;
}
if (addonsMeta.current_page >= addonsMeta.last_page) {
return;
}
setAddonsLoadingMore(true);
try {
const nextPage = addonsMeta.current_page + 1;
const result = await getTenantAddonHistory({
page: nextPage,
perPage: BILLING_HISTORY_PAGE_SIZE,
});
setAddons((current) => [...current, ...(result.data ?? [])]);
setAddonsMeta(result.meta ?? addonsMeta);
trackBillingInteraction('addon_history_load_more', nextPage);
} catch (err) {
toast.error(getApiErrorMessage(err, t('billing.errors.more', 'Konnte weitere Einträge nicht laden.')));
} finally {
setAddonsLoadingMore(false);
}
}, [addonsLoadingMore, addonsMeta, t, trackBillingInteraction]);
const loadMoreScopeAddons = React.useCallback(async () => {
if (scopeAddonsLoadingMore) {
return;
}
if (!activeEventId || scopeAddonsMeta.current_page >= scopeAddonsMeta.last_page) {
return;
}
setScopeAddonsLoadingMore(true);
try {
const nextPage = scopeAddonsMeta.current_page + 1;
const result = await getTenantAddonHistory({
eventId: activeEventId,
page: nextPage,
perPage: CURRENT_EVENT_ADDONS_PAGE_SIZE,
});
setScopeAddons((current) => [...current, ...(result.data ?? [])]);
setScopeAddonsMeta(result.meta ?? scopeAddonsMeta);
trackBillingInteraction('scope_addons_load_more', nextPage);
} catch (err) {
toast.error(getApiErrorMessage(err, t('billing.errors.more', 'Konnte weitere Einträge nicht laden.')));
} finally {
setScopeAddonsLoadingMore(false);
}
}, [activeEventId, scopeAddonsLoadingMore, scopeAddonsMeta, t, trackBillingInteraction]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
if (!activeEventId || scopeMismatchCount <= 0) {
return;
}
const key = `${activeEventId}:${scopeMismatchCount}`;
if (mismatchTrackingRef.current === key) {
return;
}
mismatchTrackingRef.current = key;
trackBillingInteraction('scope_mismatch_visible', scopeMismatchCount);
}, [activeEventId, scopeMismatchCount, trackBillingInteraction]);
React.useEffect(() => {
if (!location.hash) return;
const hash = location.hash.replace('#', '');
@@ -387,15 +590,122 @@ export default function MobileBillingPage() {
</MobileCard>
) : null}
<MobileCard gap="$2">
<XStack alignItems="center" gap="$2">
<Package size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.currentEvent.title', 'Current event')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{t(
'billing.sections.currentEvent.hint',
'This section shows what is active for your currently selected event.'
)}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
{t('common.loading', 'Lädt...')}
</Text>
) : !scopeEvent ? (
<Text fontSize="$sm" color={text}>
{t('billing.sections.currentEvent.empty', 'Select an event to view event-specific packages and add-ons.')}
</Text>
) : (
<YStack gap="$2">
<YStack gap="$1">
<Text fontSize="$xs" color={muted}>
{t('billing.sections.currentEvent.eventLabel', 'Selected event')}
</Text>
{scopedEventName ? (
scopedEventPath ? (
<Pressable onPress={() => navigate(scopedEventPath)}>
<Text fontSize="$sm" color={primary} fontWeight="700">
{scopedEventName}
</Text>
</Pressable>
) : (
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{scopedEventName}
</Text>
)
) : null}
</YStack>
{scopedEventPackage ? (
<MobileCard borderColor={border} padding="$3" gap="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{scopedEventPackage.name ?? t('mobileBilling.packageFallback', 'Package')}
</Text>
<PillBadge tone="success">
{t('billing.sections.currentEvent.packageActive', 'Active for this event')}
</PillBadge>
</XStack>
{scopedEventPackage.expires_at ? (
<Text fontSize="$xs" color={muted}>
{t('billing.sections.currentEvent.packageExpires', 'Gallery active until {{date}}', {
date: formatDate(scopedEventPackage.expires_at),
})}
</Text>
) : null}
</MobileCard>
) : (
<MobileCard borderColor={border} padding="$3" gap="$1">
<Text fontSize="$sm" color={text}>
{t('billing.sections.currentEvent.noPackage', 'No package is currently assigned to this event.')}
</Text>
</MobileCard>
)}
<YStack gap="$1.5">
<Text fontSize="$xs" color={muted}>
{t('billing.sections.currentEvent.addonsLabel', 'Add-ons for this event')}
</Text>
{scopeAddons.length > 0 ? (
<YStack gap="$1.5">
{scopeAddons.map((addon) => (
<AddonRow key={`scope-${addon.id}`} addon={addon} hideEventLink />
))}
{scopeAddonsMeta.current_page < scopeAddonsMeta.last_page ? (
<CTAButton
label={
scopeAddonsLoadingMore
? t('billing.addOns.loadingMore', 'Add-ons werden geladen…')
: t('billing.addOns.loadMore', 'Weitere Add-ons laden')
}
onPress={() => void loadMoreScopeAddons()}
tone="ghost"
/>
) : null}
</YStack>
) : scopedEventAddons.length > 0 ? (
<YStack gap="$1.5">
{scopedEventAddons.slice(0, 6).map((addon) => (
<EventAddonRow key={`event-addon-${addon.id}`} addon={addon} />
))}
</YStack>
) : (
<Text fontSize="$sm" color={text}>
{t('billing.sections.currentEvent.noAddons', 'No add-ons purchased for this event.')}
</Text>
)}
</YStack>
</YStack>
)}
</MobileCard>
<MobileCard gap="$2" ref={packagesRef as any}>
<XStack alignItems="center" gap="$2">
<Package size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.packages.title', 'Packages')}
{t('billing.sections.packages.title', 'Package history (all events)')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
{t(
'billing.sections.packages.hint',
'All purchased packages across all events.'
)}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
@@ -406,7 +716,7 @@ export default function MobileBillingPage() {
{activePackage ? (
<PackageCard
pkg={activePackage}
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
label={t('billing.sections.packages.card.statusActiveTenant', 'Currently active')}
isActive
onOpenShop={() => navigate(shopLink)}
/>
@@ -470,7 +780,7 @@ export default function MobileBillingPage() {
</YStack>
) : (
<YStack gap="$1.5">
{transactions.slice(0, 8).map((trx) => {
{transactions.map((trx) => {
const statusLabel = trx.status
? t(`billing.sections.transactions.status.${trx.status}`, trx.status)
: '—';
@@ -519,6 +829,17 @@ export default function MobileBillingPage() {
</XStack>
);
})}
{transactionsMeta.current_page < transactionsMeta.last_page ? (
<CTAButton
label={
transactionsLoadingMore
? t('billing.sections.transactions.loadingMore', 'Laden…')
: t('billing.sections.transactions.loadMore', 'Weitere Transaktionen laden')
}
onPress={() => void loadMoreTransactions()}
tone="ghost"
/>
) : null}
</YStack>
)}
</MobileCard>
@@ -527,11 +848,14 @@ export default function MobileBillingPage() {
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.addOns.title', 'Add-ons')}
{t('billing.sections.addOns.title', 'Add-on purchase history')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')}
{t(
'billing.sections.addOns.hint',
'History across all events. Entries here are historical and not automatically active for the current event.'
)}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
@@ -543,9 +867,20 @@ export default function MobileBillingPage() {
</Text>
) : (
<YStack gap="$1.5">
{addons.slice(0, 8).map((addon) => (
<AddonRow key={addon.id} addon={addon} />
{addons.map((addon) => (
<AddonRow key={addon.id} addon={addon} currentEventId={activeEventId} />
))}
{addonsMeta.current_page < addonsMeta.last_page ? (
<CTAButton
label={
addonsLoadingMore
? t('billing.addOns.loadingMore', 'Add-ons werden geladen…')
: t('billing.addOns.loadMore', 'Weitere Add-ons laden')
}
onPress={() => void loadMoreAddonHistory()}
tone="ghost"
/>
) : null}
</YStack>
)}
</MobileCard>
@@ -846,7 +1181,15 @@ function formatAmount(value: number | null | undefined, currency: string | null
}
}
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
function AddonRow({
addon,
hideEventLink = false,
currentEventId = null,
}: {
addon: TenantAddonHistoryEntry;
hideEventLink?: boolean;
currentEventId?: number | null;
}) {
const { t } = useTranslation('management');
const navigate = useNavigate();
const { border, textStrong, text, muted, subtle, primary } = useAdminTheme();
@@ -861,6 +1204,12 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
null;
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
const purchasedForDifferentEvent = Boolean(
!hideEventLink &&
currentEventId &&
addon.event?.id &&
Number(addon.event.id) !== Number(currentEventId)
);
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
const impactBadges = hasImpact ? (
<XStack gap="$2" marginTop="$1.5" flexWrap="wrap">
@@ -884,7 +1233,7 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
</Text>
<PillBadge tone={status.tone}>{status.text}</PillBadge>
</XStack>
{eventName ? (
{!hideEventLink && eventName ? (
eventPath ? (
<Pressable onPress={() => navigate(eventPath)}>
<XStack alignItems="center" justifyContent="space-between">
@@ -909,9 +1258,56 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
<Text fontSize="$xs" color={muted}>
{formatDate(addon.purchased_at)}
</Text>
{purchasedForDifferentEvent ? (
<Text fontSize="$xs" color={muted}>
{t('billing.sections.addOns.otherEventNotice', 'Purchased for another event')}
</Text>
) : null}
</MobileCard>
);
}
function EventAddonRow({ addon }: { addon: EventAddonSummary }) {
const { t } = useTranslation('management');
const { border, textStrong, text, muted } = useAdminTheme();
const labels: Record<EventAddonSummary['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
failed: { tone: 'muted', text: t('mobileBilling.status.failed', 'Failed') },
};
const status = labels[addon.status];
return (
<MobileCard borderColor={border} padding="$3" gap="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{addon.label ?? addon.key}
</Text>
<PillBadge tone={status.tone}>{status.text}</PillBadge>
</XStack>
<XStack gap="$2" marginTop="$1.5" flexWrap="wrap">
{addon.extra_photos ? (
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
) : null}
{addon.extra_guests ? (
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
) : null}
{addon.extra_gallery_days ? (
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
) : null}
</XStack>
<Text fontSize="$sm" color={text}>
{formatDate(addon.purchased_at)}
</Text>
<Text fontSize="$xs" color={muted}>
{t('billing.sections.currentEvent.eventAddonSource', 'Source: event package')}
</Text>
</MobileCard>
);
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const date = new Date(value);