Link tenant packages to events and show usage in billing
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 12:54:33 +01:00
parent fa114ac0dc
commit 0291d537fb
11 changed files with 572 additions and 51 deletions

View File

@@ -506,6 +506,23 @@ export async function fetchHelpCenterArticle(slug: string, locale?: string): Pro
}
export type TenantPackageSummary = {
current_event?: {
id: number;
slug: string;
name: string | Record<string, string> | null;
status: string | null;
event_date: string | null;
linked_at: string | null;
} | null;
last_event?: {
id: number;
slug: string;
name: string | Record<string, string> | null;
status: string | null;
event_date: string | null;
linked_at: string | null;
} | null;
linked_events_count?: number;
id: number;
package_id: number;
package_name: string;
@@ -1109,6 +1126,50 @@ function normalizeDashboard(payload: JsonValue | null): DashboardSummary | null
export function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
const packageData = pkg.package ?? {};
return {
current_event:
(pkg as any).current_event && typeof (pkg as any).current_event === 'object'
? {
id: Number((pkg as any).current_event.id ?? 0),
slug: String((pkg as any).current_event.slug ?? ''),
name: ((pkg as any).current_event.name ?? null) as string | Record<string, string> | null,
status:
typeof (pkg as any).current_event.status === 'string'
? String((pkg as any).current_event.status)
: null,
event_date:
typeof (pkg as any).current_event.event_date === 'string'
? String((pkg as any).current_event.event_date)
: null,
linked_at:
typeof (pkg as any).current_event.linked_at === 'string'
? String((pkg as any).current_event.linked_at)
: null,
}
: null,
last_event:
(pkg as any).last_event && typeof (pkg as any).last_event === 'object'
? {
id: Number((pkg as any).last_event.id ?? 0),
slug: String((pkg as any).last_event.slug ?? ''),
name: ((pkg as any).last_event.name ?? null) as string | Record<string, string> | null,
status:
typeof (pkg as any).last_event.status === 'string'
? String((pkg as any).last_event.status)
: null,
event_date:
typeof (pkg as any).last_event.event_date === 'string'
? String((pkg as any).last_event.event_date)
: null,
linked_at:
typeof (pkg as any).last_event.linked_at === 'string'
? String((pkg as any).last_event.linked_at)
: null,
}
: null,
linked_events_count:
(pkg as any).linked_events_count === undefined || (pkg as any).linked_events_count === null
? 0
: Number((pkg as any).linked_events_count),
id: Number(pkg.id ?? 0),
package_id: Number(pkg.package_id ?? packageData.id ?? 0),
package_name: String(packageData.name ?? pkg.package_name ?? 'Unbekanntes Package'),

View File

@@ -1,7 +1,7 @@
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 { ChevronDown, ChevronUp, 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';
@@ -57,6 +57,7 @@ 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 [showPackageHistory, setShowPackageHistory] = React.useState(false);
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
@@ -109,6 +110,13 @@ export default function MobileBillingPage() {
storePendingCheckout(next);
}, []);
const nonActivePackages = React.useMemo(
() => packages.filter((pkg) => !activePackage || pkg.id !== activePackage.id),
[activePackage, packages]
);
const hiddenPackageCount = Math.max(0, nonActivePackages.length - 3);
const visiblePackageHistory = showPackageHistory ? nonActivePackages : nonActivePackages.slice(0, 3);
const handleReceiptDownload = React.useCallback(
async (transaction: TenantBillingTransactionSummary) => {
if (!transaction.receipt_url) {
@@ -403,11 +411,37 @@ export default function MobileBillingPage() {
onOpenShop={() => navigate(shopLink)}
/>
) : null}
{packages
.filter((pkg) => !activePackage || pkg.id !== activePackage.id)
.map((pkg) => (
{visiblePackageHistory.map((pkg) => (
<PackageCard key={pkg.id} pkg={pkg} />
))}
{hiddenPackageCount > 0 ? (
<Pressable onPress={() => setShowPackageHistory((current) => !current)}>
<XStack
alignItems="center"
justifyContent="space-between"
borderWidth={1}
borderColor={primary}
backgroundColor={accentSoft}
borderRadius={14}
paddingHorizontal="$3"
paddingVertical="$2"
>
<YStack gap="$0.5">
<Text fontSize="$xs" color={muted} fontWeight="700">
{t('billing.sections.packages.history.label', 'Paketverlauf')}
</Text>
<Text fontSize="$sm" color={textStrong} fontWeight="800">
{showPackageHistory
? t('billing.sections.packages.history.hide', 'Verlauf ausblenden')
: t('billing.sections.packages.history.show', 'Verlauf anzeigen ({{count}})', {
count: hiddenPackageCount,
})}
</Text>
</YStack>
{showPackageHistory ? <ChevronUp size={18} color={primary} /> : <ChevronDown size={18} color={primary} />}
</XStack>
</Pressable>
) : null}
</YStack>
)}
</MobileCard>
@@ -531,6 +565,7 @@ function PackageCard({
onOpenShop?: () => void;
}) {
const { t } = useTranslation('management');
const navigate = useNavigate();
const { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
const limits = (pkg.package_limits ?? null) as Record<string, unknown> | null;
const isPartnerPackage = pkg.package_type === 'reseller';
@@ -549,6 +584,7 @@ function PackageCard({
const usageStates = usageMetrics.map((metric) => getUsageState(metric));
const hasUsageWarning = usageStates.some((state) => state === 'warning' || state === 'danger');
const isDanger = usageStates.includes('danger');
const [detailsCollapsed, setDetailsCollapsed] = React.useState(!isActive);
const limitEntries = getPackageLimitEntries(limits, t, {
remainingEvents: pkg.remaining_events ?? null,
usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null,
@@ -559,6 +595,14 @@ function PackageCard({
limitMaxEvents,
t
);
const currentLinkedEvent = pkg.current_event ?? null;
const lastLinkedEvent = !currentLinkedEvent ? pkg.last_event ?? null : null;
const linkedEvent = currentLinkedEvent ?? lastLinkedEvent;
const linkedEventName = linkedEvent ? resolveLinkedEventName(linkedEvent.name, t) : null;
const linkedEventPath = linkedEvent?.slug ? ADMIN_EVENT_VIEW_PATH(linkedEvent.slug) : null;
const linkedEventLabel = currentLinkedEvent
? t('billing.sections.packages.card.currentEvent', 'Aktuell genutzt für')
: t('billing.sections.packages.card.lastEvent', 'Zuletzt genutzt für');
return (
<MobileCard
borderColor={isActive ? primary : border}
@@ -591,60 +635,128 @@ function PackageCard({
{eventUsageText}
</Text>
) : null}
{limitEntries.length ? (
<YStack gap="$1.5" marginTop="$2">
{linkedEventName ? (
<YStack gap="$1" marginTop="$1.5">
<Text fontSize="$xs" color={muted}>
{t('mobileBilling.details.limitsTitle', 'Limits')}
{linkedEventLabel}
</Text>
{limitEntries.map((entry) => (
<XStack key={entry.key} alignItems="center" justifyContent="space-between">
{linkedEventPath ? (
<Pressable onPress={() => navigate(linkedEventPath)}>
<Text fontSize="$sm" color={primary} fontWeight="700">
{linkedEventName}
</Text>
</Pressable>
) : (
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{linkedEventName}
</Text>
)}
{(pkg.linked_events_count ?? 0) > 1 ? (
<Text fontSize="$xs" color={muted}>
{t('billing.sections.packages.card.linkedEventsCount', '{{count}} verknüpfte Events', {
count: pkg.linked_events_count ?? 0,
})}
</Text>
) : null}
</YStack>
) : null}
<Pressable onPress={() => setDetailsCollapsed((current) => !current)}>
<XStack
alignItems="center"
justifyContent="space-between"
borderWidth={1}
borderColor={isActive ? primary : border}
backgroundColor={isActive ? accentSoft : undefined}
borderRadius={12}
paddingHorizontal="$2.5"
paddingVertical="$1.5"
>
<YStack gap="$0.5">
<Text fontSize="$xs" color={muted} fontWeight="700">
{t('billing.sections.packages.card.detailsLabel', 'Paketdetails')}
</Text>
<Text fontSize="$sm" color={textStrong} fontWeight="800">
{detailsCollapsed
? t('billing.sections.packages.card.showDetails', 'Details anzeigen')
: t('billing.sections.packages.card.hideDetails', 'Details ausblenden')}
</Text>
</YStack>
{detailsCollapsed ? <ChevronDown size={16} color={primary} /> : <ChevronUp size={16} color={primary} />}
</XStack>
</Pressable>
{!detailsCollapsed ? (
<YStack gap="$2">
{limitEntries.length ? (
<YStack gap="$1.5" marginTop="$2">
<Text fontSize="$xs" color={muted}>
{entry.label}
{t('mobileBilling.details.limitsTitle', 'Limits')}
</Text>
<Text fontSize="$xs" color={textStrong} fontWeight="700">
{entry.value}
{limitEntries.map((entry) => (
<XStack key={entry.key} alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={muted}>
{entry.label}
</Text>
<Text fontSize="$xs" color={textStrong} fontWeight="700">
{entry.value}
</Text>
</XStack>
))}
</YStack>
) : null}
{featureKeys.length ? (
<YStack gap="$1.5" marginTop="$2">
<Text fontSize="$xs" color={muted}>
{t('mobileBilling.details.featuresTitle', 'Features')}
</Text>
</XStack>
))}
{featureKeys.map((feature) => (
<XStack key={feature} alignItems="center" gap="$2">
<Sparkles size={14} color={primary} />
<Text fontSize="$xs" color={textStrong}>
{getPackageFeatureLabel(feature, t)}
</Text>
</XStack>
))}
</YStack>
) : null}
{usageMetrics.length ? (
<YStack gap="$2" marginTop="$2">
{usageMetrics.map((metric) => (
<UsageBar key={metric.key} metric={metric} />
))}
</YStack>
) : null}
{isActive && hasUsageWarning ? (
<CTAButton
label={
isDanger
? t('mobileBilling.usage.ctaDanger', 'Upgrade package')
: t('mobileBilling.usage.ctaWarning', 'Secure more capacity')
}
onPress={onOpenShop}
tone={isDanger ? 'danger' : 'primary'}
/>
) : null}
</YStack>
) : null}
{featureKeys.length ? (
<YStack gap="$1.5" marginTop="$2">
<Text fontSize="$xs" color={muted}>
{t('mobileBilling.details.featuresTitle', 'Features')}
</Text>
{featureKeys.map((feature) => (
<XStack key={feature} alignItems="center" gap="$2">
<Sparkles size={14} color={primary} />
<Text fontSize="$xs" color={textStrong}>
{getPackageFeatureLabel(feature, t)}
</Text>
</XStack>
))}
</YStack>
) : null}
{usageMetrics.length ? (
<YStack gap="$2" marginTop="$2">
{usageMetrics.map((metric) => (
<UsageBar key={metric.key} metric={metric} />
))}
</YStack>
) : null}
{isActive && hasUsageWarning ? (
<CTAButton
label={
isDanger
? t('mobileBilling.usage.ctaDanger', 'Upgrade package')
: t('mobileBilling.usage.ctaWarning', 'Secure more capacity')
}
onPress={onOpenShop}
tone={isDanger ? 'danger' : 'primary'}
/>
) : null}
</MobileCard>
);
}
function resolveLinkedEventName(
name: string | Record<string, string> | null | undefined,
t: (key: string, defaultValue?: string) => string
): string {
if (typeof name === 'string' && name.trim() !== '') {
return name;
}
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? t('events.placeholders.untitled', 'Untitled event');
}
return t('events.placeholders.untitled', 'Untitled event');
}
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;

View File

@@ -35,6 +35,8 @@ vi.mock('react-i18next', () => ({
}));
vi.mock('lucide-react', () => ({
ChevronDown: () => <span />,
ChevronUp: () => <span />,
Package: () => <span />,
Receipt: () => <span />,
RefreshCcw: () => <span />,
@@ -113,7 +115,7 @@ vi.mock('../../lib/apiError', () => ({
}));
vi.mock('../../constants', () => ({
ADMIN_EVENT_VIEW_PATH: '/mobile/events',
ADMIN_EVENT_VIEW_PATH: (slug: string) => `/mobile/events/${slug}`,
adminPath: (path: string) => path,
}));
@@ -165,8 +167,155 @@ vi.mock('../../api', () => ({
}));
import MobileBillingPage from '../BillingPage';
import * as api from '../../api';
describe('MobileBillingPage', () => {
it('shows linked event information for a package', async () => {
vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({
activePackage: {
id: 11,
package_id: 501,
package_name: 'Pro Paket',
package_type: 'reseller',
included_package_slug: 'pro',
active: true,
used_events: 2,
remaining_events: 1,
price: 49,
currency: 'EUR',
purchased_at: '2024-01-01T00:00:00Z',
expires_at: null,
package_limits: {},
linked_events_count: 2,
current_event: {
id: 77,
slug: 'sommerfest',
name: { de: 'Sommerfest' },
status: 'published',
event_date: '2024-08-15T00:00:00Z',
linked_at: '2024-08-01T00:00:00Z',
},
},
packages: [],
});
render(<MobileBillingPage />);
await screen.findByText('Aktuell genutzt für');
expect(screen.getByText('Sommerfest')).toBeInTheDocument();
expect(screen.getByText('2 verknüpfte Events')).toBeInTheDocument();
});
it('shows only recent package history and can expand the rest', async () => {
vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({
activePackage: {
id: 1,
package_id: 100,
package_name: 'Aktives Paket',
package_type: 'reseller',
included_package_slug: 'standard',
active: true,
used_events: 0,
remaining_events: 1,
price: 29,
currency: 'EUR',
purchased_at: '2024-01-01T00:00:00Z',
expires_at: null,
package_limits: {},
},
packages: [
{
id: 1,
package_id: 100,
package_name: 'Aktives Paket',
package_type: 'reseller',
included_package_slug: 'standard',
active: true,
used_events: 0,
remaining_events: 1,
price: 29,
currency: 'EUR',
purchased_at: '2024-01-01T00:00:00Z',
expires_at: null,
package_limits: {},
},
{
id: 2,
package_id: 101,
package_name: 'Historie 1',
package_type: 'reseller',
included_package_slug: 'standard',
active: false,
used_events: 1,
remaining_events: 0,
price: 29,
currency: 'EUR',
purchased_at: '2023-12-01T00:00:00Z',
expires_at: null,
package_limits: {},
},
{
id: 3,
package_id: 102,
package_name: 'Historie 2',
package_type: 'reseller',
included_package_slug: 'standard',
active: false,
used_events: 1,
remaining_events: 0,
price: 29,
currency: 'EUR',
purchased_at: '2023-11-01T00:00:00Z',
expires_at: null,
package_limits: {},
},
{
id: 4,
package_id: 103,
package_name: 'Historie 3',
package_type: 'reseller',
included_package_slug: 'standard',
active: false,
used_events: 1,
remaining_events: 0,
price: 29,
currency: 'EUR',
purchased_at: '2023-10-01T00:00:00Z',
expires_at: null,
package_limits: {},
},
{
id: 5,
package_id: 104,
package_name: 'Historie 4',
package_type: 'reseller',
included_package_slug: 'standard',
active: false,
used_events: 1,
remaining_events: 0,
price: 29,
currency: 'EUR',
purchased_at: '2023-09-01T00:00:00Z',
expires_at: null,
package_limits: {},
},
],
});
render(<MobileBillingPage />);
await screen.findByText('Aktives Paket');
expect(screen.getByText('Historie 1')).toBeInTheDocument();
expect(screen.getByText('Historie 2')).toBeInTheDocument();
expect(screen.getByText('Historie 3')).toBeInTheDocument();
expect(screen.queryByText('Historie 4')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Verlauf anzeigen (1)'));
expect(await screen.findByText('Historie 4')).toBeInTheDocument();
expect(screen.getByText('Verlauf ausblenden')).toBeInTheDocument();
});
it('downloads receipts via the API helper', async () => {
render(<MobileBillingPage />);