From cb5d5a28704dd901ed757dd08ddcb03449252603 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 20 Jan 2026 12:54:16 +0100 Subject: [PATCH] Gate event create FAB by package quota --- resources/js/admin/mobile/EventsPage.tsx | 106 +++++++++++++++++- .../mobile/__tests__/EventsPage.test.tsx | 83 ++++++++++++++ 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/resources/js/admin/mobile/EventsPage.tsx b/resources/js/admin/mobile/EventsPage.tsx index 805f05e..e395bbb 100644 --- a/resources/js/admin/mobile/EventsPage.tsx +++ b/resources/js/admin/mobile/EventsPage.tsx @@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives'; import { MobileInput } from './components/FormControls'; -import { getEvents, TenantEvent } from '../api'; +import { getEvents, getTenantPackagesOverview, TenantEvent, type TenantPackageSummary } from '../api'; import { adminPath } from '../constants'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; @@ -32,6 +32,11 @@ export default function MobileEventsPage() { const [error, setError] = React.useState(null); const [query, setQuery] = React.useState(''); const [statusFilter, setStatusFilter] = React.useState('all'); + const [packagesOverview, setPackagesOverview] = React.useState<{ + packages: TenantPackageSummary[]; + activePackage: TenantPackageSummary | null; + } | null>(null); + const [packagesLoading, setPackagesLoading] = React.useState(false); const searchRef = React.useRef(null); const back = useBackNavigation(); const { text, muted, subtle, border, primary, danger, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme(); @@ -49,6 +54,44 @@ export default function MobileEventsPage() { })(); }, [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 ( )} - {!isMember ? ( + {canCreateEvent ? ( string): stri 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; + 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 { if (typeof name === 'string') return name; if (name && typeof name === 'object') { diff --git a/resources/js/admin/mobile/__tests__/EventsPage.test.tsx b/resources/js/admin/mobile/__tests__/EventsPage.test.tsx index 5379d35..b87f627 100644 --- a/resources/js/admin/mobile/__tests__/EventsPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/EventsPage.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen } from '@testing-library/react'; +import * as api from '../../api'; const navigateMock = vi.fn(); const authState = { @@ -62,6 +63,40 @@ vi.mock('../../api', () => ({ 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', () => ({ @@ -192,4 +227,52 @@ describe('MobileEventsPage', () => { expect(screen.getByText('Summer Gala')).toBeInTheDocument(); expect(screen.queryByText('Demo Event')).not.toBeInTheDocument(); }); + + it('shows the create button when an active package has remaining events', async () => { + render(); + + 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(); + + expect(await screen.findByText('Demo Event')).toBeInTheDocument(); + expect(screen.queryByText('New event')).not.toBeInTheDocument(); + }); });