verschieben des sofortigen verzichts auf das Widerrrufsrecht zum Anlegen des Events
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CreditCard, Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react';
|
||||
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 } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import {
|
||||
createTenantBillingPortalSession,
|
||||
getTenantPackagesOverview,
|
||||
getTenantPaddleTransactions,
|
||||
TenantPackageSummary,
|
||||
@@ -15,7 +17,8 @@ import {
|
||||
} from '../api';
|
||||
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { adminPath } from '../constants';
|
||||
import { ADMIN_EVENT_VIEW_PATH } from '../constants';
|
||||
import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage';
|
||||
|
||||
export default function MobileBillingPage() {
|
||||
const { t } = useTranslation('management');
|
||||
@@ -27,8 +30,10 @@ export default function MobileBillingPage() {
|
||||
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);
|
||||
@@ -51,6 +56,35 @@ export default function MobileBillingPage() {
|
||||
}
|
||||
}, [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]);
|
||||
@@ -80,6 +114,7 @@ export default function MobileBillingPage() {
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
{error}
|
||||
</Text>
|
||||
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
@@ -90,6 +125,14 @@ export default function MobileBillingPage() {
|
||||
{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...')}
|
||||
@@ -97,7 +140,11 @@ export default function MobileBillingPage() {
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{activePackage ? (
|
||||
<PackageCard pkg={activePackage} label={t('billing.sections.packages.card.statusActive', 'Aktiv')} />
|
||||
<PackageCard
|
||||
pkg={activePackage}
|
||||
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
|
||||
isActive
|
||||
/>
|
||||
) : null}
|
||||
{packages
|
||||
.filter((pkg) => !activePackage || pkg.id !== activePackage.id)
|
||||
@@ -115,14 +162,21 @@ export default function MobileBillingPage() {
|
||||
{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 ? (
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
|
||||
</Text>
|
||||
<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) => (
|
||||
@@ -169,6 +223,9 @@ export default function MobileBillingPage() {
|
||||
{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...')}
|
||||
@@ -190,12 +247,13 @@ export default function MobileBillingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function PackageCard({ pkg, label }: { pkg: TenantPackageSummary; label?: string }) {
|
||||
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="#e5e7eb" space="$2">
|
||||
<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')}
|
||||
@@ -217,9 +275,13 @@ function PackageCard({ pkg, label }: { pkg: TenantPackageSummary; label?: string
|
||||
{renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))}
|
||||
{renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))}
|
||||
</XStack>
|
||||
<YStack space="$1.5" marginTop="$2">
|
||||
<FeatureList pkg={pkg} />
|
||||
</YStack>
|
||||
{usageMetrics.length ? (
|
||||
<YStack space="$2" marginTop="$2">
|
||||
{usageMetrics.map((metric) => (
|
||||
<UsageBar key={metric.key} metric={metric} />
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
@@ -231,43 +293,45 @@ function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, labe
|
||||
return <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>;
|
||||
}
|
||||
|
||||
function FeatureList({ pkg }: { pkg: TenantPackageSummary }) {
|
||||
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
|
||||
const { t } = useTranslation('management');
|
||||
const limits = pkg.package_limits ?? {};
|
||||
const features = (pkg as any).features as string[] | undefined;
|
||||
const labelMap: Record<PackageUsageMetric['key'], string> = {
|
||||
events: t('mobileBilling.usage.events', 'Events'),
|
||||
guests: t('mobileBilling.usage.guests', 'Guests'),
|
||||
photos: t('mobileBilling.usage.photos', 'Photos'),
|
||||
};
|
||||
|
||||
const rows: Array<{ label: string; value: string }> = [];
|
||||
|
||||
if (limits.max_photos !== undefined && limits.max_photos !== null) {
|
||||
rows.push({ label: t('billing.features.maxPhotos', 'Max photos'), value: String(limits.max_photos) });
|
||||
}
|
||||
if (limits.max_guests !== undefined && limits.max_guests !== null) {
|
||||
rows.push({ label: t('billing.features.maxGuests', 'Max guests'), value: String(limits.max_guests) });
|
||||
}
|
||||
if (limits.gallery_days !== undefined && limits.gallery_days !== null) {
|
||||
rows.push({ label: t('billing.features.galleryDays', 'Gallery days'), value: String(limits.gallery_days) });
|
||||
}
|
||||
if (limits.max_tasks !== undefined && limits.max_tasks !== null) {
|
||||
rows.push({ label: t('billing.features.maxTasks', 'Max tasks'), value: String(limits.max_tasks) });
|
||||
}
|
||||
if (Array.isArray(features) && features.length) {
|
||||
rows.push({ label: t('billing.features.featureList', 'Included features'), value: features.join(', ') });
|
||||
if (!metric.limit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!rows.length) 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">
|
||||
{rows.map((row) => (
|
||||
<XStack key={row.label} alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{row.label}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#0f172a" fontWeight="700">
|
||||
{row.value}
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -286,6 +350,7 @@ function formatAmount(value: number | null | undefined, currency: string | null
|
||||
|
||||
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') },
|
||||
@@ -296,6 +361,21 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
||||
(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">
|
||||
@@ -305,28 +385,31 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
||||
</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>
|
||||
{eventName ? (
|
||||
<Text fontSize="$xs" color="#9ca3af">
|
||||
{eventName}
|
||||
</Text>
|
||||
) : null}
|
||||
<XStack space="$2" marginTop="$1">
|
||||
{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="#0f172a" marginTop="$1">
|
||||
{formatAmount(addon.amount, addon.currency)}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user