import React from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import toast from 'react-hot-toast'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { createTenantBillingPortalSession, getTenantPackagesOverview, getTenantPaddleTransactions, TenantPackageSummary, PaddleTransactionSummary, } from '../api'; import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; import { ADMIN_EVENT_VIEW_PATH, adminPath } from '../constants'; import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; export default function MobileBillingPage() { const { t } = useTranslation('management'); const navigate = useNavigate(); const location = useLocation(); const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme(); const [packages, setPackages] = React.useState([]); const [activePackage, setActivePackage] = React.useState(null); const [transactions, setTransactions] = React.useState([]); const [addons, setAddons] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [portalBusy, setPortalBusy] = React.useState(false); const packagesRef = React.useRef(null); const invoicesRef = React.useRef(null); const supportEmail = 'support@fotospiel.de'; const back = useBackNavigation(adminPath('/mobile/profile')); const load = React.useCallback(async () => { setLoading(true); try { const [pkg, trx, addonHistory] = await Promise.all([ getTenantPackagesOverview({ force: true }), getTenantPaddleTransactions().catch(() => ({ data: [] as PaddleTransactionSummary[] })), getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })), ]); setPackages(pkg.packages ?? []); setActivePackage(pkg.activePackage ?? null); setTransactions(trx.data ?? []); setAddons(addonHistory.data ?? []); setError(null); } catch (err) { const message = getApiErrorMessage(err, t('billing.errors.load', 'Konnte Abrechnung nicht laden.')); setError(message); } finally { setLoading(false); } }, [t]); const scrollToPackages = React.useCallback(() => { packagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, []); const openSupport = React.useCallback(() => { if (typeof window !== 'undefined') { window.location.href = `mailto:${supportEmail}`; } }, [supportEmail]); const openPortal = React.useCallback(async () => { if (portalBusy) { return; } setPortalBusy(true); try { const { url } = await createTenantBillingPortalSession(); if (typeof window !== 'undefined') { window.open(url, '_blank', 'noopener'); } } catch (err) { const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Paddle-Portal nicht öffnen.')); toast.error(message); } finally { setPortalBusy(false); } }, [portalBusy, t]); React.useEffect(() => { void load(); }, [load]); React.useEffect(() => { if (!location.hash) return; const hash = location.hash.replace('#', ''); const target = hash === 'invoices' ? invoicesRef.current : packagesRef.current; if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }, [location.hash, loading]); return ( load()} ariaLabel={t('common.refresh', 'Refresh')}> } > {error ? ( {error} ) : null} {t('billing.sections.packages.title', 'Packages')} {t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')} {loading ? ( {t('common.loading', 'Lädt...')} ) : ( {activePackage ? ( ) : null} {packages .filter((pkg) => !activePackage || pkg.id !== activePackage.id) .map((pkg) => ( ))} )} {t('billing.sections.invoices.title', 'Invoices & Payments')} {t('billing.sections.invoices.hint', 'Review transactions and download receipts.')} {loading ? ( {t('common.loading', 'Lädt...')} ) : transactions.length === 0 ? ( {t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')} ) : ( {transactions.slice(0, 8).map((trx) => ( {trx.status ?? '—'} {formatDate(trx.created_at)} {trx.origin ? ( {trx.origin} ) : null} {formatAmount(trx.amount, trx.currency)} {trx.tax ? ( {t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })} ) : null} {trx.receipt_url ? ( {t('billing.sections.transactions.labels.receipt', 'Beleg')} ) : null} ))} )} {null} {t('billing.sections.addOns.title', 'Add-ons')} {t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')} {loading ? ( {t('common.loading', 'Lädt...')} ) : addons.length === 0 ? ( {t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')} ) : ( {addons.slice(0, 8).map((addon) => ( ))} )} {null} ); } function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean }) { const { t } = useTranslation('management'); const { border, primary, accentSoft, textStrong, muted } = useAdminTheme(); const remaining = pkg.remaining_events ?? (pkg.package_limits?.max_events_per_year as number | undefined) ?? 0; const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null; const usageMetrics = buildPackageUsageMetrics(pkg); return ( {pkg.package_name ?? t('mobileBilling.packageFallback', 'Package')} {label ? {label} : null} {expires ? ( {expires} ) : null} {t('mobileBilling.remainingEvents', '{{count}} events', { count: remaining })} {pkg.price !== null && pkg.price !== undefined ? ( {formatAmount(pkg.price, pkg.currency ?? 'EUR')} ) : null} {renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))} {renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))} {usageMetrics.length ? ( {usageMetrics.map((metric) => ( ))} ) : null} ); } function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, label: string) { const value = (pkg.package_limits as any)?.[key] ?? (pkg as any)[key]; if (value === undefined || value === null) return null; const enabled = value !== false; return {enabled ? label : `${label} off`}; } function UsageBar({ metric }: { metric: PackageUsageMetric }) { const { t } = useTranslation('management'); const { muted, textStrong, border, primary, subtle } = useAdminTheme(); const labelMap: Record = { events: t('mobileBilling.usage.events', 'Events'), guests: t('mobileBilling.usage.guests', 'Guests'), photos: t('mobileBilling.usage.photos', 'Photos'), }; if (!metric.limit) { return null; } const hasUsage = metric.used !== null; const valueText = hasUsage ? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit }) : t('mobileBilling.usage.limit', { limit: metric.limit }); const remainingText = metric.remaining !== null ? t('mobileBilling.usage.remaining', { count: metric.remaining }) : null; const fill = usagePercent(metric); return ( {labelMap[metric.key]} {valueText} {remainingText ? ( {remainingText} ) : null} ); } function formatAmount(value: number | null | undefined, currency: string | null | undefined): string { if (value === null || value === undefined) { return '—'; } const cur = currency ?? 'EUR'; try { return new Intl.NumberFormat(undefined, { style: 'currency', currency: cur }).format(value); } catch { return `${value} ${cur}`; } } function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) { const { t } = useTranslation('management'); const navigate = useNavigate(); const { border, textStrong, text, muted, subtle, primary } = useAdminTheme(); const labels: Record = { 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]; const eventName = (addon.event?.name && typeof addon.event.name === 'string' && addon.event.name) || (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 hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days); const impactBadges = hasImpact ? ( {addon.extra_photos ? ( {t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })} ) : null} {addon.extra_guests ? ( {t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })} ) : null} {addon.extra_gallery_days ? ( {t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })} ) : null} ) : null; return ( {addon.label ?? addon.addon_key} {status.text} {eventName ? ( eventPath ? ( navigate(eventPath)}> {eventName} {t('mobileBilling.openEvent', 'Open event')} ) : ( {eventName} ) ) : null} {impactBadges} {formatAmount(addon.amount, addon.currency)} {formatDate(addon.purchased_at)} ); } function formatDate(value: string | null | undefined): string { if (!value) return '—'; const date = new Date(value); if (Number.isNaN(date.getTime())) return '—'; return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }); }