Link tenant packages to events and show usage in billing
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user