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, getTenantPackageCheckoutStatus, TenantPackageSummary, PaddleTransactionSummary, } from '../api'; import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; import { ADMIN_EVENT_VIEW_PATH, adminPath } from '../constants'; import { buildPackageUsageMetrics, getUsageState, PackageUsageMetric, usagePercent } from './billingUsage'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; import { collectPackageFeatures, formatEventUsage, getPackageFeatureLabel, getPackageLimitEntries, } from './lib/packageSummary'; import { PendingCheckout, loadPendingCheckout, shouldClearPendingCheckout, storePendingCheckout, } from './lib/billingCheckout'; const CHECKOUT_POLL_INTERVAL_MS = 10000; 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 [pendingCheckout, setPendingCheckout] = React.useState(() => loadPendingCheckout()); const [checkoutStatus, setCheckoutStatus] = React.useState(null); const [checkoutStatusReason, setCheckoutStatusReason] = React.useState(null); const [checkoutActionUrl, setCheckoutActionUrl] = React.useState(null); const lastCheckoutStatusRef = React.useRef(null); 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]); const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => { setPendingCheckout(next); storePendingCheckout(next); }, []); 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]); React.useEffect(() => { if (!location.search) { return; } const params = new URLSearchParams(location.search); const checkout = params.get('checkout'); const packageId = params.get('package_id'); if (!checkout) { return; } if (checkout === 'success') { const packageIdNumber = packageId ? Number(packageId) : null; const existingSessionId = pendingCheckout?.checkoutSessionId ?? null; const pendingEntry = { packageId: Number.isFinite(packageIdNumber) ? packageIdNumber : null, checkoutSessionId: existingSessionId, startedAt: Date.now(), }; persistPendingCheckout(pendingEntry); toast.success(t('billing.checkoutSuccess', 'Checkout completed. Your package will activate shortly.')); } else if (checkout === 'cancel') { persistPendingCheckout(null); toast(t('billing.checkoutCancelled', 'Checkout was cancelled.')); } params.delete('checkout'); params.delete('package_id'); navigate( { pathname: location.pathname, search: params.toString(), hash: location.hash, }, { replace: true }, ); }, [location.hash, location.pathname, location.search, navigate, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]); React.useEffect(() => { if (!pendingCheckout) { return; } if (shouldClearPendingCheckout(pendingCheckout, activePackage?.package_id ?? null)) { persistPendingCheckout(null); } }, [activePackage?.package_id, pendingCheckout, persistPendingCheckout]); React.useEffect(() => { if (!pendingCheckout?.checkoutSessionId) { setCheckoutStatus(null); setCheckoutStatusReason(null); setCheckoutActionUrl(null); lastCheckoutStatusRef.current = null; return; } let active = true; let intervalId: ReturnType | null = null; const poll = async () => { try { const result = await getTenantPackageCheckoutStatus(pendingCheckout.checkoutSessionId as string); if (!active) { return; } setCheckoutStatus(result.status); setCheckoutStatusReason(result.reason ?? null); setCheckoutActionUrl(typeof result.checkout_url === 'string' ? result.checkout_url : null); const lastStatus = lastCheckoutStatusRef.current; lastCheckoutStatusRef.current = result.status; if (result.status === 'completed') { persistPendingCheckout(null); if (lastStatus !== 'completed') { toast.success(t('billing.checkoutActivated', 'Your package is now active.')); } await load(); if (intervalId) { clearInterval(intervalId); } return; } if (result.status === 'failed' || result.status === 'cancelled') { if (intervalId) { clearInterval(intervalId); } } } catch { if (!active) { return; } } }; void poll(); intervalId = setInterval(poll, CHECKOUT_POLL_INTERVAL_MS); return () => { active = false; if (intervalId) { clearInterval(intervalId); } }; }, [load, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]); return ( load()} ariaLabel={t('common.refresh', 'Refresh')}> } > {error ? ( {error} ) : null} {pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? ( {t('billing.checkoutFailedTitle', 'Checkout failed')} {t( 'billing.checkoutFailedBody', 'The payment did not complete. You can try again or contact support.' )} {checkoutStatusReason ? ( {t(`billing.checkoutFailureReasons.${checkoutStatusReason}`, checkoutStatusReason)} ) : null} {t('billing.checkoutFailedBadge', 'Failed')} navigate(adminPath('/mobile/billing/shop'))} fullWidth={false} /> persistPendingCheckout(null)} fullWidth={false} /> ) : null} {pendingCheckout && checkoutStatus === 'requires_customer_action' ? ( {t('billing.checkoutActionTitle', 'Action required')} {t('billing.checkoutActionBody', 'Complete your payment to activate the package.')} {t('billing.checkoutActionBadge', 'Action needed')} { if (checkoutActionUrl && typeof window !== 'undefined') { window.open(checkoutActionUrl, '_blank', 'noopener'); return; } navigate(adminPath('/mobile/billing/shop')); }} fullWidth={false} /> persistPendingCheckout(null)} fullWidth={false} /> ) : null} {pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' && checkoutStatus !== 'requires_customer_action' ? ( {t('billing.checkoutPendingTitle', 'Activating your package')} {t( 'billing.checkoutPendingBody', 'This can take a few minutes. We will update this screen once the package is active.' )} {t('billing.checkoutPendingBadge', 'Pending')} persistPendingCheckout(null)} fullWidth={false} /> ) : 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} ))} )} {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) => ( ))} )} ); } function PackageCard({ pkg, label, isActive = false, onOpenPortal, portalBusy, }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean; onOpenPortal?: () => void; portalBusy?: boolean; }) { const { t } = useTranslation('management'); const { border, primary, accentSoft, textStrong, muted } = useAdminTheme(); const limits = (pkg.package_limits ?? null) as Record | null; const limitMaxEvents = typeof limits?.max_events_per_year === 'number' ? (limits?.max_events_per_year as number) : null; const remaining = pkg.remaining_events ?? limitMaxEvents ?? 0; const remainingText = remaining === 0 ? t('mobileBilling.remainingEventsZero', 'No events remaining') : limitMaxEvents ? t('mobileBilling.remainingEventsOf', '{{remaining}} of {{limit}} events remaining', { remaining, limit: limitMaxEvents, }) : t('mobileBilling.remainingEvents', '{{count}} events', { count: remaining }); const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null; const usageMetrics = buildPackageUsageMetrics(pkg); const usageStates = usageMetrics.map((metric) => getUsageState(metric)); const hasUsageWarning = usageStates.some((state) => state === 'warning' || state === 'danger'); const isDanger = usageStates.includes('danger'); const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents: pkg.remaining_events ?? null, usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null, }); const featureKeys = collectPackageFeatures(pkg); const eventUsageText = formatEventUsage( typeof pkg.used_events === 'number' ? pkg.used_events : null, limitMaxEvents, t ); return ( {pkg.package_name ?? t('mobileBilling.packageFallback', 'Package')} {label ? {label} : null} {expires ? ( {expires} ) : null} {remainingText} {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'))} {eventUsageText ? ( {eventUsageText} ) : null} {limitEntries.length ? ( {t('mobileBilling.details.limitsTitle', 'Limits')} {limitEntries.map((entry) => ( {entry.label} {entry.value} ))} ) : null} {featureKeys.length ? ( {t('mobileBilling.details.featuresTitle', 'Features')} {featureKeys.map((feature) => ( {getPackageFeatureLabel(feature, t)} ))} ) : null} {usageMetrics.length ? ( {usageMetrics.map((metric) => ( ))} ) : null} {isActive && hasUsageWarning && onOpenPortal ? ( ) : 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, warningText, danger } = useAdminTheme(); const labelMap: Record = { events: t('mobileBilling.usage.events', 'Events'), guests: t('mobileBilling.usage.guests', 'Guests'), photos: t('mobileBilling.usage.photos', 'Photos'), gallery: t('mobileBilling.usage.gallery', 'Gallery days'), }; if (!metric.limit) { return null; } const status = getUsageState(metric); 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.remainingOf', { remaining: metric.remaining, limit: metric.limit, defaultValue: 'Remaining {{remaining}} of {{limit}}', }) : null; const fill = usagePercent(metric); const statusLabel = status === 'danger' ? t('mobileBilling.usage.statusDanger', 'Limit reached') : status === 'warning' ? t('mobileBilling.usage.statusWarning', 'Low') : null; const fillColor = status === 'danger' ? danger : status === 'warning' ? warningText : primary; return ( {labelMap[metric.key]} {statusLabel ? {statusLabel} : null} {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' }); }