Files
fotospiel-app/resources/js/admin/mobile/BillingPage.tsx
Codex Agent 4ce409e918 Completed the full mobile app polish pass: navigation feel, safe‑area consistency, input styling, list rows, FAB
patterns, skeleton loading, photo selection/bulk actions with shared‑element transitions, notification detail sheet,
  offline banner, maskable manifest icons, and route prefetching.

  Key changes

  - Navigation/shell: press feedback on all header actions, glassy sticky header and tab bar, safer bottom spacing
    (resources/js/admin/mobile/components/MobileShell.tsx, resources/js/admin/mobile/components/BottomNav.tsx).
  - Forms + lists: shared mobile form controls, list‑style rows in settings/profile, consistent inputs across core
    flows (resources/js/admin/mobile/components/FormControls.tsx, resources/js/admin/mobile/SettingsPage.tsx,
    resources/js/admin/mobile/ProfilePage.tsx, resources/js/admin/mobile/EventFormPage.tsx, resources/js/admin/mobile/
    EventMembersPage.tsx, resources/js/admin/mobile/EventTasksPage.tsx, resources/js/admin/mobile/
    EventGuestNotificationsPage.tsx, resources/js/admin/mobile/NotificationsPage.tsx, resources/js/admin/mobile/
    EventPhotosPage.tsx, resources/js/admin/mobile/EventsPage.tsx).
  - Media workflows: shared‑element photo transitions, selection mode + bulk actions bar (resources/js/admin/mobile/
    EventPhotosPage.tsx).
  - Loading UX: shimmering skeletons (resources/css/app.css, resources/js/admin/mobile/components/Primitives.tsx).
  - PWA polish + perf: maskable icons, offline banner hook, and route prefetch (public/manifest.json, resources/js/
    admin/mobile/hooks/useOnlineStatus.tsx, resources/js/admin/mobile/prefetch.ts, resources/js/admin/main.tsx).
2025-12-27 23:55:48 +01:00

422 lines
16 KiB
TypeScript

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 } from '../constants';
import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage';
export default function MobileBillingPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
const location = useLocation();
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
const [transactions, setTransactions] = React.useState<PaddleTransactionSummary[]>([]);
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [portalBusy, setPortalBusy] = React.useState(false);
const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de';
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 (
<MobileShell
activeTab="profile"
title={t('billing.title', 'Billing & Packages')}
onBack={() => navigate(-1)}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
</MobileCard>
) : null}
<MobileCard space="$2" ref={packagesRef as any}>
<XStack alignItems="center" space="$2">
<Package size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('billing.sections.packages.title', 'Packages')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
</Text>
<CTAButton
label={portalBusy ? t('billing.actions.portalBusy', 'Öffne Portal...') : t('billing.actions.portal', 'Manage in Paddle')}
onPress={openPortal}
disabled={portalBusy}
/>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
{t('common.loading', 'Lädt...')}
</Text>
) : (
<YStack space="$2">
{activePackage ? (
<PackageCard
pkg={activePackage}
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
isActive
/>
) : null}
{packages
.filter((pkg) => !activePackage || pkg.id !== activePackage.id)
.map((pkg) => (
<PackageCard key={pkg.id} pkg={pkg} />
))}
</YStack>
)}
</MobileCard>
<MobileCard space="$2" ref={invoicesRef as any}>
<XStack alignItems="center" space="$2">
<Receipt size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('billing.sections.invoices.title', 'Invoices & Payments')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
{t('billing.sections.invoices.hint', 'Review transactions and download receipts.')}
</Text>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
{t('common.loading', 'Lädt...')}
</Text>
) : transactions.length === 0 ? (
<YStack space="$2">
<Text fontSize="$sm" color="#4b5563">
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
</Text>
<CTAButton label={t('billing.actions.openPackages', 'Open packages')} onPress={scrollToPackages} />
<CTAButton label={t('billing.actions.contactSupport', 'Contact support')} tone="ghost" onPress={openSupport} />
</YStack>
) : (
<YStack space="$1.5">
{transactions.slice(0, 8).map((trx) => (
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor="#e5e7eb" paddingVertical="$1.5">
<YStack>
<Text fontSize="$sm" color="#0f172a" fontWeight="700">
{trx.status ?? '—'}
</Text>
<Text fontSize="$xs" color="#6b7280">
{formatDate(trx.created_at)}
</Text>
{trx.origin ? (
<Text fontSize="$xs" color="#9ca3af">
{trx.origin}
</Text>
) : null}
</YStack>
<YStack alignItems="flex-end">
<Text fontSize="$sm" color="#0f172a" fontWeight="700">
{formatAmount(trx.amount, trx.currency)}
</Text>
{trx.tax ? (
<Text fontSize="$xs" color="#6b7280">
{t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })}
</Text>
) : null}
{trx.receipt_url ? (
<a href={trx.receipt_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: '#2563eb' }}>
{t('billing.sections.transactions.labels.receipt', 'Beleg')}
</a>
) : null}
</YStack>
</XStack>
))}
</YStack>
)}
{null}
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Sparkles size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('billing.sections.addOns.title', 'Add-ons')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
{t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')}
</Text>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
{t('common.loading', 'Lädt...')}
</Text>
) : addons.length === 0 ? (
<Text fontSize="$sm" color="#4b5563">
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
</Text>
) : (
<YStack space="$1.5">
{addons.slice(0, 8).map((addon) => (
<AddonRow key={addon.id} addon={addon} />
))}
</YStack>
)}
{null}
</MobileCard>
</MobileShell>
);
}
function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean }) {
const { t } = useTranslation('management');
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 (
<MobileCard borderColor={isActive ? '#2563eb' : '#e5e7eb'} borderWidth={isActive ? 2 : 1} backgroundColor={isActive ? '#eff6ff' : undefined} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{pkg.package_name ?? t('mobileBilling.packageFallback', 'Package')}
</Text>
{label ? <PillBadge tone="success">{label}</PillBadge> : null}
</XStack>
{expires ? (
<Text fontSize="$xs" color="#6b7280">
{expires}
</Text>
) : null}
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<PillBadge tone="muted">
{t('mobileBilling.remainingEvents', '{{count}} events', { count: remaining })}
</PillBadge>
{pkg.price !== null && pkg.price !== undefined ? (
<PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge>
) : null}
{renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))}
{renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))}
</XStack>
{usageMetrics.length ? (
<YStack space="$2" marginTop="$2">
{usageMetrics.map((metric) => (
<UsageBar key={metric.key} metric={metric} />
))}
</YStack>
) : null}
</MobileCard>
);
}
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 <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>;
}
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
const { t } = useTranslation('management');
const labelMap: Record<PackageUsageMetric['key'], string> = {
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 (
<YStack space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color="#6b7280">
{labelMap[metric.key]}
</Text>
<Text fontSize="$xs" color="#0f172a" fontWeight="700">
{valueText}
</Text>
</XStack>
<YStack height={6} borderRadius={999} backgroundColor="#e5e7eb" overflow="hidden">
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? '#2563eb' : '#94a3b8'} />
</YStack>
{remainingText ? (
<Text fontSize="$xs" color="#6b7280">
{remainingText}
</Text>
) : null}
</YStack>
);
}
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 labels: Record<TenantAddonHistoryEntry['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];
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 ? (
<XStack space="$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>
) : null;
return (
<MobileCard borderColor="#e5e7eb" padding="$3" space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color="#0f172a">
{addon.label ?? addon.addon_key}
</Text>
<PillBadge tone={status.tone}>{status.text}</PillBadge>
</XStack>
{eventName ? (
eventPath ? (
<Pressable onPress={() => navigate(eventPath)}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color="#0f172a" fontWeight="600">
{eventName}
</Text>
<Text fontSize="$xs" color="#2563eb" fontWeight="700">
{t('mobileBilling.openEvent', 'Open event')}
</Text>
</XStack>
</Pressable>
) : (
<Text fontSize="$xs" color="#9ca3af">
{eventName}
</Text>
)
) : null}
{impactBadges}
<Text fontSize="$sm" color="#0f172a" marginTop="$1.5">
{formatAmount(addon.amount, addon.currency)}
</Text>
<Text fontSize="$xs" color="#6b7280">
{formatDate(addon.purchased_at)}
</Text>
</MobileCard>
);
}
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' });
}