Gate event create FAB by package quota
This commit is contained in:
@@ -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') {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user