Gate event create FAB by package quota
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-01-20 12:54:16 +01:00
parent e28eb9a90b
commit cb5d5a2870
2 changed files with 187 additions and 2 deletions

View File

@@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives'; import { PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives';
import { MobileInput } from './components/FormControls'; import { MobileInput } from './components/FormControls';
import { getEvents, TenantEvent } from '../api'; import { getEvents, getTenantPackagesOverview, TenantEvent, type TenantPackageSummary } from '../api';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
@@ -32,6 +32,11 @@ export default function MobileEventsPage() {
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all'); const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all');
const [packagesOverview, setPackagesOverview] = React.useState<{
packages: TenantPackageSummary[];
activePackage: TenantPackageSummary | null;
} | null>(null);
const [packagesLoading, setPackagesLoading] = React.useState(false);
const searchRef = React.useRef<HTMLInputElement>(null); const searchRef = React.useRef<HTMLInputElement>(null);
const back = useBackNavigation(); const back = useBackNavigation();
const { text, muted, subtle, border, primary, danger, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme(); const { text, muted, subtle, border, primary, danger, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme();
@@ -49,6 +54,44 @@ export default function MobileEventsPage() {
})(); })();
}, [t]); }, [t]);
React.useEffect(() => {
if (isMember) {
return;
}
let isActive = true;
setPackagesLoading(true);
getTenantPackagesOverview()
.then((overview) => {
if (isActive) {
setPackagesOverview(overview);
}
})
.catch(() => {
if (isActive) {
setPackagesOverview(null);
}
})
.finally(() => {
if (isActive) {
setPackagesLoading(false);
}
});
return () => {
isActive = false;
};
}, [isMember]);
const canCreateEvent = React.useMemo(() => {
if (isMember || packagesLoading) {
return false;
}
const activePackages = collectActivePackages(packagesOverview);
return activePackages.some((pkg) => packageHasRemainingEvents(pkg));
}, [isMember, packagesLoading, packagesOverview]);
return ( return (
<MobileShell <MobileShell
activeTab="home" activeTab="home"
@@ -164,7 +207,7 @@ export default function MobileEventsPage() {
/> />
)} )}
{!isMember ? ( {canCreateEvent ? (
<FloatingActionButton <FloatingActionButton
label={t('events.list.actions.create')} label={t('events.list.actions.create')}
icon={Plus} icon={Plus}
@@ -476,6 +519,65 @@ function renderName(name: TenantEvent['name'], t: (key: string) => string): stri
return t('events.placeholders.untitled'); return t('events.placeholders.untitled');
} }
function collectActivePackages(
overview: { packages: TenantPackageSummary[]; activePackage: TenantPackageSummary | null } | null
): TenantPackageSummary[] {
if (!overview) {
return [];
}
const packages = overview.packages ?? [];
const activePackage = overview.activePackage;
const activeList = packages.filter((pkg) => pkg.active);
if (activePackage && !activeList.some((pkg) => pkg.id === activePackage.id) && activePackage.active) {
return [activePackage, ...activeList];
}
return activeList;
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
function packageHasRemainingEvents(pkg: TenantPackageSummary): boolean {
const remaining = toNumber(pkg.remaining_events);
if (pkg.package_type !== 'reseller') {
if (remaining !== null) {
return remaining > 0;
}
const usedEvents = toNumber(pkg.used_events) ?? 0;
return usedEvents < 1;
}
if (remaining !== null) {
return remaining > 0;
}
const limits = (pkg.package_limits ?? {}) as Record<string, unknown>;
const limitMaxEvents = toNumber(limits.max_events_per_year);
if (limitMaxEvents === null) {
return false;
}
const usedEvents = toNumber(pkg.used_events) ?? 0;
return limitMaxEvents > usedEvents;
}
function resolveEventSearchName(name: TenantEvent['name'], t: (key: string) => string): string { function resolveEventSearchName(name: TenantEvent['name'], t: (key: string) => string): string {
if (typeof name === 'string') return name; if (typeof name === 'string') return name;
if (name && typeof name === 'object') { if (name && typeof name === 'object') {

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react'; import { act, fireEvent, render, screen } from '@testing-library/react';
import * as api from '../../api';
const navigateMock = vi.fn(); const navigateMock = vi.fn();
const authState = { const authState = {
@@ -62,6 +63,40 @@ vi.mock('../../api', () => ({
settings: {}, settings: {},
}, },
]), ]),
getTenantPackagesOverview: vi.fn().mockResolvedValue({
packages: [
{
id: 1,
package_id: 1,
package_name: 'Standard',
package_type: 'endcustomer',
included_package_slug: null,
active: true,
used_events: 0,
remaining_events: null,
price: 120,
currency: 'EUR',
purchased_at: null,
expires_at: null,
package_limits: null,
},
],
activePackage: {
id: 1,
package_id: 1,
package_name: 'Standard',
package_type: 'endcustomer',
included_package_slug: null,
active: true,
used_events: 0,
remaining_events: null,
price: 120,
currency: 'EUR',
purchased_at: null,
expires_at: null,
package_limits: null,
},
}),
})); }));
vi.mock('../../auth/tokens', () => ({ vi.mock('../../auth/tokens', () => ({
@@ -192,4 +227,52 @@ describe('MobileEventsPage', () => {
expect(screen.getByText('Summer Gala')).toBeInTheDocument(); expect(screen.getByText('Summer Gala')).toBeInTheDocument();
expect(screen.queryByText('Demo Event')).not.toBeInTheDocument(); expect(screen.queryByText('Demo Event')).not.toBeInTheDocument();
}); });
it('shows the create button when an active package has remaining events', async () => {
render(<MobileEventsPage />);
expect(await screen.findByText('New event')).toBeInTheDocument();
});
it('hides the create button when no remaining events are available', async () => {
vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({
packages: [
{
id: 2,
package_id: 2,
package_name: 'Reseller',
package_type: 'reseller',
included_package_slug: 'standard',
active: true,
used_events: 5,
remaining_events: 0,
price: 240,
currency: 'EUR',
purchased_at: null,
expires_at: null,
package_limits: { max_events_per_year: 5 },
},
],
activePackage: {
id: 2,
package_id: 2,
package_name: 'Reseller',
package_type: 'reseller',
included_package_slug: 'standard',
active: true,
used_events: 5,
remaining_events: 0,
price: 240,
currency: 'EUR',
purchased_at: null,
expires_at: null,
package_limits: { max_events_per_year: 5 },
},
});
render(<MobileEventsPage />);
expect(await screen.findByText('Demo Event')).toBeInTheDocument();
expect(screen.queryByText('New event')).not.toBeInTheDocument();
});
}); });