Fix demo task readiness and gate event creation
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-05 11:26:07 +01:00
parent 7262617897
commit 04c399aeb6
14 changed files with 318 additions and 36 deletions

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { AlertCircle, Bell, CalendarDays, Camera, CheckCircle2, ChevronRight, Circle, Download, Heart, Image as ImageIcon, Layout, ListTodo, Megaphone, QrCode, Settings, ShieldCheck, Sparkles, TrendingUp, Tv, Users } from 'lucide-react'; import { AlertCircle, Bell, CalendarDays, Camera, CheckCircle2, ChevronRight, Circle, Download, Heart, Image as ImageIcon, Layout, ListTodo, Megaphone, Plus, QrCode, Settings, ShieldCheck, Sparkles, TrendingUp, Tv, Users } from 'lucide-react';
import { Button } from '@tamagui/button'; import { Button } from '@tamagui/button';
import { Card } from '@tamagui/card'; import { Card } from '@tamagui/card';
import { YGroup } from '@tamagui/group'; import { YGroup } from '@tamagui/group';
@@ -19,7 +19,7 @@ import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { ADMIN_EVENTS_PATH, adminPath } from '../constants'; import { ADMIN_EVENTS_PATH, adminPath } from '../constants';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent, getEventPhotos, TenantPhoto, updateEvent, getLiveShowQueue } from '../api'; import { getEventStats, EventStats, TenantEvent, getEventPhotos, TenantPhoto, updateEvent, getLiveShowQueue, getTenantPackagesOverview, type TenantPackageSummary } from '../api';
import { formatEventDate } from '../lib/events'; import { formatEventDate } from '../lib/events';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { ADMIN_ACTION_COLORS, useAdminTheme } from './theme'; import { ADMIN_ACTION_COLORS, useAdminTheme } from './theme';
@@ -37,6 +37,59 @@ function translateLimits(t: any) {
return (key: string, options?: any) => t(`management:limits.${key}`, key, options); return (key: string, options?: any) => t(`management:limits.${key}`, key, options);
} }
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 resellerHasRemainingEvents(pkg: TenantPackageSummary): boolean {
if (pkg.package_type !== 'reseller') {
return false;
}
const remaining = toNumber(pkg.remaining_events);
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;
}
// --- TAMAGUI-ALIGNED PRIMITIVES --- // --- TAMAGUI-ALIGNED PRIMITIVES ---
function DashboardCard({ function DashboardCard({
@@ -129,6 +182,7 @@ export default function MobileDashboardPage() {
const { user } = useAuth(); const { user } = useAuth();
const theme = useAdminTheme(); const theme = useAdminTheme();
const isMember = user?.role === 'member'; const isMember = user?.role === 'member';
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
// --- LOGIC --- // --- LOGIC ---
const memberPermissions = React.useMemo(() => { const memberPermissions = React.useMemo(() => {
@@ -178,6 +232,29 @@ export default function MobileDashboardPage() {
}, },
}); });
const { data: packagesOverview, isLoading: packagesLoading } = useQuery({
queryKey: ['mobile', 'dashboard', 'packages-overview'],
enabled: !isMember && !isSuperAdmin,
queryFn: () => getTenantPackagesOverview({ force: true }),
});
const canCreateEvent = React.useMemo(() => {
if (!canManageEvents) {
return false;
}
if (isSuperAdmin) {
return true;
}
if (isMember || packagesLoading) {
return false;
}
const activePackages = collectActivePackages(packagesOverview ?? null);
return activePackages.some((pkg) => resellerHasRemainingEvents(pkg));
}, [canManageEvents, isSuperAdmin, isMember, packagesLoading, packagesOverview]);
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
React.useEffect(() => { React.useEffect(() => {
@@ -265,6 +342,7 @@ export default function MobileDashboardPage() {
permissions={memberPermissions} permissions={memberPermissions}
isMember={isMember} isMember={isMember}
isCompleted={isCompleted} isCompleted={isCompleted}
canCreateEvent={canCreateEvent}
/> />
{/* 5. RECENT PHOTOS */} {/* 5. RECENT PHOTOS */}
@@ -627,7 +705,7 @@ function PulseStrip({ event, stats, liveShowApprovedCount }: any) {
return <KpiStrip items={items} />; return <KpiStrip items={items} />;
} }
function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }: any) { function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted, canCreateEvent }: any) {
const theme = useAdminTheme(); const theme = useAdminTheme();
const { t } = useTranslation(['management', 'dashboard']); const { t } = useTranslation(['management', 'dashboard']);
const slug = event?.slug; const slug = event?.slug;
@@ -649,6 +727,7 @@ function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }
].filter(Boolean) as ToolItem[]; ].filter(Boolean) as ToolItem[];
const adminItems: ToolItem[] = [ const adminItems: ToolItem[] = [
canCreateEvent ? { label: t('management:events.list.actions.create', 'Create Event'), icon: Plus, path: `/mobile/events/new`, color: theme.primary } : null,
{ label: t('management:mobileDashboard.shortcutAnalytics', 'Analytics'), icon: TrendingUp, path: `/mobile/events/${slug}/analytics`, color: ADMIN_ACTION_COLORS.analytics }, { label: t('management:mobileDashboard.shortcutAnalytics', 'Analytics'), icon: TrendingUp, path: `/mobile/events/${slug}/analytics`, color: ADMIN_ACTION_COLORS.analytics },
!isCompleted ? { label: t('events.recap.exportTitleShort', 'Exports'), icon: Download, path: `/mobile/exports`, color: ADMIN_ACTION_COLORS.recap } : null, !isCompleted ? { label: t('events.recap.exportTitleShort', 'Exports'), icon: Download, path: `/mobile/exports`, color: ADMIN_ACTION_COLORS.recap } : null,
{ label: t('management:mobileProfile.settings', 'Settings'), icon: Settings, path: `/mobile/events/${slug}/edit`, color: ADMIN_ACTION_COLORS.settings }, { label: t('management:mobileProfile.settings', 'Settings'), icon: Settings, path: `/mobile/events/${slug}/edit`, color: ADMIN_ACTION_COLORS.settings },

View File

@@ -66,7 +66,7 @@ function allowPermission(permissions: string[], permission: string): boolean {
export default function MobileEventTasksPage() { export default function MobileEventTasksPage() {
const { slug: slugParam } = useParams<{ slug?: string }>(); const { slug: slugParam } = useParams<{ slug?: string }>();
const { activeEvent, selectEvent } = useEventContext(); const { activeEvent, selectEvent, refetch } = useEventContext();
const slug = slugParam ?? activeEvent?.slug ?? null; const slug = slugParam ?? activeEvent?.slug ?? null;
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -258,6 +258,7 @@ export default function MobileEventTasksPage() {
const result = await getEventTasks(eventId, 1); const result = await getEventTasks(eventId, 1);
setAssignedTasks(result.data); setAssignedTasks(result.data);
setLibrary((prev) => prev.filter((t) => t.id !== taskId)); setLibrary((prev) => prev.filter((t) => t.id !== taskId));
refetch();
toast.success(t('events.tasks.assigned', 'Fotoaufgabe hinzugefügt')); toast.success(t('events.tasks.assigned', 'Fotoaufgabe hinzugefügt'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -281,6 +282,7 @@ export default function MobileEventTasksPage() {
const assignedIds = new Set(result.data.map((t) => t.id)); const assignedIds = new Set(result.data.map((t) => t.id));
setAssignedTasks(result.data); setAssignedTasks(result.data);
setLibrary((prev) => prev.filter((t) => !assignedIds.has(t.id))); setLibrary((prev) => prev.filter((t) => !assignedIds.has(t.id)));
refetch();
toast.success(t('events.tasks.imported', 'Fotoaufgabenpaket importiert')); toast.success(t('events.tasks.imported', 'Fotoaufgabenpaket importiert'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -337,6 +339,7 @@ export default function MobileEventTasksPage() {
setLibrary((prev) => prev.filter((t) => !assignedIds.has(t.id))); setLibrary((prev) => prev.filter((t) => !assignedIds.has(t.id)));
setShowTaskSheet(false); setShowTaskSheet(false);
setNewTask({ id: null, title: '', description: '', emotion_id: '', tenant_id: null }); setNewTask({ id: null, title: '', description: '', emotion_id: '', tenant_id: null });
refetch();
toast.success(t('events.tasks.created', 'Fotoaufgabe gespeichert')); toast.success(t('events.tasks.created', 'Fotoaufgabe gespeichert'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -361,6 +364,7 @@ export default function MobileEventTasksPage() {
} }
return next; return next;
}); });
refetch();
toast.success(t('events.tasks.removed', 'Fotoaufgabe entfernt')); toast.success(t('events.tasks.removed', 'Fotoaufgabe entfernt'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -392,6 +396,7 @@ export default function MobileEventTasksPage() {
setAssignedTasks((prev) => prev.filter((task) => !selectedTaskIds.has(task.id))); setAssignedTasks((prev) => prev.filter((task) => !selectedTaskIds.has(task.id)));
setSelectedTaskIds(new Set()); setSelectedTaskIds(new Set());
setSelectionMode(false); setSelectionMode(false);
refetch();
toast.success(t('events.tasks.removed', 'Fotoaufgabe entfernt')); toast.success(t('events.tasks.removed', 'Fotoaufgabe entfernt'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -519,6 +524,7 @@ export default function MobileEventTasksPage() {
setAssignedTasks(result.data); setAssignedTasks(result.data);
setBulkLines(''); setBulkLines('');
setShowBulkSheet(false); setShowBulkSheet(false);
refetch();
toast.success(t('events.tasks.created', 'Fotoaufgabe gespeichert')); toast.success(t('events.tasks.created', 'Fotoaufgabe gespeichert'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {

View File

@@ -28,6 +28,7 @@ export default function MobileEventsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const isMember = user?.role === 'member'; const isMember = user?.role === 'member';
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
const [events, setEvents] = React.useState<TenantEvent[]>([]); const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@@ -85,13 +86,21 @@ export default function MobileEventsPage() {
}, [isMember]); }, [isMember]);
const canCreateEvent = React.useMemo(() => { const canCreateEvent = React.useMemo(() => {
if (isMember || packagesLoading) { if (isMember) {
return false;
}
if (isSuperAdmin) {
return true;
}
if (packagesLoading) {
return false; return false;
} }
const activePackages = collectActivePackages(packagesOverview); const activePackages = collectActivePackages(packagesOverview);
return activePackages.some((pkg) => packageHasRemainingEvents(pkg)); return activePackages.some((pkg) => resellerHasRemainingEvents(pkg));
}, [isMember, packagesLoading, packagesOverview]); }, [isMember, isSuperAdmin, packagesLoading, packagesOverview]);
return ( return (
<MobileShell <MobileShell
@@ -540,17 +549,12 @@ function toNumber(value: unknown): number | null {
return null; return null;
} }
function packageHasRemainingEvents(pkg: TenantPackageSummary): boolean { function resellerHasRemainingEvents(pkg: TenantPackageSummary): boolean {
const remaining = toNumber(pkg.remaining_events);
if (pkg.package_type !== 'reseller') { if (pkg.package_type !== 'reseller') {
if (remaining !== null) { return false;
return remaining > 0;
} }
const usedEvents = toNumber(pkg.used_events) ?? 0; const remaining = toNumber(pkg.remaining_events);
return usedEvents < 1;
}
if (remaining !== null) { if (remaining !== null) {
return remaining > 0; return remaining > 0;

View File

@@ -399,7 +399,17 @@ describe('MobileDashboardPage', () => {
expect(screen.getByText('Experience')).toBeInTheDocument(); expect(screen.getByText('Experience')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument(); expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.queryByText('Create Event')).not.toBeInTheDocument();
authState.user = { role: 'tenant_admin' }; authState.user = { role: 'tenant_admin' };
}); });
it('shows create event action for reseller packages', () => {
fixtures.activePackage.package_type = 'reseller';
fixtures.activePackage.remaining_events = 2;
render(<MobileDashboardPage />);
expect(screen.getByText('Create Event')).toBeInTheDocument();
});
}); });

View File

@@ -63,11 +63,13 @@ vi.mock('../hooks/useBackNavigation', () => ({
})); }));
const selectEventMock = vi.fn(); const selectEventMock = vi.fn();
const refetchMock = vi.fn();
vi.mock('../../context/EventContext', () => ({ vi.mock('../../context/EventContext', () => ({
useEventContext: () => ({ useEventContext: () => ({
activeEvent: fixtures.event, activeEvent: fixtures.event,
selectEvent: selectEventMock, selectEvent: selectEventMock,
refetch: refetchMock,
}), }),
})); }));
@@ -245,7 +247,12 @@ vi.mock('../components/FormControls', () => ({
})); }));
vi.mock('../components/Sheet', () => ({ vi.mock('../components/Sheet', () => ({
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, MobileSheet: ({ children, footer }: { children: React.ReactNode; footer?: React.ReactNode }) => (
<div>
{children}
{footer}
</div>
),
})); }));
vi.mock('../components/Tag', () => ({ vi.mock('../components/Tag', () => ({
@@ -336,4 +343,23 @@ describe('MobileEventTasksPage', () => {
expect(screen.queryByLabelText('Add')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Add')).not.toBeInTheDocument();
}); });
}); });
it('refetches events after creating a task', async () => {
refetchMock.mockClear();
(api.createTask as unknown as { mockResolvedValueOnce: (value: any) => void }).mockResolvedValueOnce({ id: 99 });
render(<MobileEventTasksPage />);
expect(await screen.findByText('Photo tasks for guests')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('z.B. Erstes Gruppenfoto'), {
target: { value: 'New Task' },
});
fireEvent.click(screen.getAllByRole('button', { name: 'Fotoaufgabe speichern' })[0]);
await waitFor(() => {
expect(refetchMock).toHaveBeenCalled();
});
});
}); });

View File

@@ -255,6 +255,41 @@ describe('MobileEventsPage', () => {
}); });
it('shows the create button when an active package has remaining events', async () => { it('shows the create button when an active package has remaining events', 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: 1,
remaining_events: 2,
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: 1,
remaining_events: 2,
price: 240,
currency: 'EUR',
purchased_at: null,
expires_at: null,
package_limits: { max_events_per_year: 5 },
},
});
render(<MobileEventsPage />); render(<MobileEventsPage />);
expect(await screen.findByText('New event')).toBeInTheDocument(); expect(await screen.findByText('New event')).toBeInTheDocument();

View File

@@ -16,11 +16,13 @@ export function EventSwitcherSheet({
onClose, onClose,
events, events,
activeSlug, activeSlug,
canCreateEvent,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
events: TenantEvent[]; events: TenantEvent[];
activeSlug: string | null; activeSlug: string | null;
canCreateEvent: boolean;
}) { }) {
const { t, i18n } = useTranslation(['management', 'mobile']); const { t, i18n } = useTranslation(['management', 'mobile']);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -73,6 +75,7 @@ export function EventSwitcherSheet({
); );
})} })}
{canCreateEvent ? (
<Pressable onPress={() => { onClose(); navigate(adminPath('/mobile/events/new')); }}> <Pressable onPress={() => { onClose(); navigate(adminPath('/mobile/events/new')); }}>
<XStack padding="$3" justifyContent="center"> <XStack padding="$3" justifyContent="center">
<Text fontSize="$sm" fontWeight="700" color={theme.primary}> <Text fontSize="$sm" fontWeight="700" color={theme.primary}>
@@ -80,6 +83,7 @@ export function EventSwitcherSheet({
</Text> </Text>
</XStack> </XStack>
</Pressable> </Pressable>
) : null}
</YStack> </YStack>
</MobileSheet> </MobileSheet>
); );

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { ChevronLeft, Bell, QrCode, ChevronsUpDown, Search } from 'lucide-react'; import { ChevronLeft, Bell, QrCode, ChevronsUpDown, Search } from 'lucide-react';
import { YStack, XStack, SizableText as Text, Image } from 'tamagui'; import { YStack, XStack, SizableText as Text, Image } from 'tamagui';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
@@ -12,7 +13,7 @@ import { MobileCard, CTAButton } from './Primitives';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge'; import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { useOnlineStatus } from '../hooks/useOnlineStatus'; import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { resolveEventDisplayName } from '../../lib/events'; import { resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api'; import { TenantEvent, getEvents, getTenantPackagesOverview, type TenantPackageSummary } from '../../api';
import { setTabHistory } from '../lib/tabHistory'; import { setTabHistory } from '../lib/tabHistory';
import { loadPhotoQueue } from '../lib/photoModerationQueue'; import { loadPhotoQueue } from '../lib/photoModerationQueue';
import { countQueuedPhotoActions } from '../lib/queueStatus'; import { countQueuedPhotoActions } from '../lib/queueStatus';
@@ -31,6 +32,59 @@ type MobileShellProps = {
headerActions?: React.ReactNode; headerActions?: React.ReactNode;
}; };
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 resellerHasRemainingEvents(pkg: TenantPackageSummary): boolean {
if (pkg.package_type !== 'reseller') {
return false;
}
const remaining = toNumber(pkg.remaining_events);
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;
}
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) { export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
const { events, activeEvent, selectEvent } = useEventContext(); const { events, activeEvent, selectEvent } = useEventContext();
const { user } = useAuth(); const { user } = useAuth();
@@ -40,6 +94,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const { t } = useTranslation('mobile'); const { t } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge(); const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus(); const online = useOnlineStatus();
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
useDocumentTitle(title); useDocumentTitle(title);
@@ -144,6 +199,29 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
}; };
const showQr = Boolean(effectiveActive?.slug) && allowPermission('join-tokens:manage'); const showQr = Boolean(effectiveActive?.slug) && allowPermission('join-tokens:manage');
const { data: packagesOverview, isLoading: packagesLoading } = useQuery({
queryKey: ['mobile', 'header', 'packages-overview'],
enabled: !isMember && !isSuperAdmin,
queryFn: () => getTenantPackagesOverview({ force: true }),
});
const canCreateEvent = React.useMemo(() => {
if (isMember) {
return false;
}
if (isSuperAdmin) {
return true;
}
if (packagesLoading) {
return false;
}
const activePackages = collectActivePackages(packagesOverview ?? null);
return activePackages.some((pkg) => resellerHasRemainingEvents(pkg));
}, [isMember, isSuperAdmin, packagesLoading, packagesOverview]);
// --- CONTEXT PILL --- // --- CONTEXT PILL ---
const EventContextPill = () => { const EventContextPill = () => {
if (!effectiveActive || isEventsIndex || isCompactHeader) { if (!effectiveActive || isEventsIndex || isCompactHeader) {
@@ -391,6 +469,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
onClose={() => setSwitcherOpen(false)} onClose={() => setSwitcherOpen(false)}
events={effectiveEvents} events={effectiveEvents}
activeSlug={effectiveActive?.slug ?? null} activeSlug={effectiveActive?.slug ?? null}
canCreateEvent={canCreateEvent}
/> />
<UserMenuSheet <UserMenuSheet
open={userMenuOpen} open={userMenuOpen}

View File

@@ -112,6 +112,11 @@ vi.mock('../../hooks/useOnlineStatus', () => ({
vi.mock('../../../api', () => ({ vi.mock('../../../api', () => ({
getEvents: vi.fn().mockResolvedValue([]), getEvents: vi.fn().mockResolvedValue([]),
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
}));
vi.mock('@tanstack/react-query', () => ({
useQuery: () => ({ data: { packages: [], activePackage: null }, isLoading: false }),
})); }));
vi.mock('../../lib/tabHistory', () => ({ vi.mock('../../lib/tabHistory', () => ({

View File

@@ -14,8 +14,7 @@ import React from 'react';
import { usePage } from '@inertiajs/react'; import { usePage } from '@inertiajs/react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Sentry, initSentry } from './lib/sentry'; import { Sentry, initSentry } from './lib/sentry';
import { buildPageTitle } from './lib/appTitle';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const InertiaFallback: React.FC = () => ( const InertiaFallback: React.FC = () => (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground"> <div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Oberfläche konnte nicht geladen werden. Oberfläche konnte nicht geladen werden.
@@ -48,7 +47,7 @@ const LocaleSync: React.FC<{ children: React.ReactNode }> = ({ children }) => {
}; };
createInertiaApp({ createInertiaApp({
title: (title) => title ? `${title} - ${appName}` : appName, title: (title) => buildPageTitle(title),
resolve: (name) => resolve: (name) =>
resolvePageComponent( resolvePageComponent(
`./pages/${name}.tsx`, `./pages/${name}.tsx`,

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { buildPageTitle, getAppName } from '../appTitle';
describe('app title helpers', () => {
it('falls back to Fotospiel when VITE_APP_NAME is missing', () => {
const original = import.meta.env.VITE_APP_NAME;
// @ts-expect-error - import.meta.env is mutable in vitest
import.meta.env.VITE_APP_NAME = '';
expect(getAppName()).toBe('Fotospiel');
expect(buildPageTitle('Demo')).toBe('Demo - Fotospiel');
// @ts-expect-error - restore original value
import.meta.env.VITE_APP_NAME = original;
});
it('uses the configured VITE_APP_NAME when available', () => {
const original = import.meta.env.VITE_APP_NAME;
// @ts-expect-error - import.meta.env is mutable in vitest
import.meta.env.VITE_APP_NAME = 'Fotospiel App';
expect(getAppName()).toBe('Fotospiel App');
expect(buildPageTitle('Demo')).toBe('Demo - Fotospiel App');
// @ts-expect-error - restore original value
import.meta.env.VITE_APP_NAME = original;
});
});

View File

@@ -0,0 +1,8 @@
export function getAppName(): string {
return import.meta.env.VITE_APP_NAME || 'Fotospiel';
}
export function buildPageTitle(title?: string): string {
const appName = getAppName();
return title ? `${title} - ${appName}` : appName;
}

View File

@@ -4,14 +4,13 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import ReactDOMServer from 'react-dom/server'; import ReactDOMServer from 'react-dom/server';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import i18n from './i18n'; import i18n from './i18n';
import { buildPageTitle } from './lib/appTitle';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createServer((page) => createServer((page) =>
createInertiaApp({ createInertiaApp({
page, page,
render: ReactDOMServer.renderToString, render: ReactDOMServer.renderToString,
title: (title) => (title ? `${title} - ${appName}` : appName), title: (title) => buildPageTitle(title),
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')), resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
setup: ({ App, props }) => { setup: ({ App, props }) => {
const locale = (props.initialPage?.props as Record<string, unknown> | undefined)?.locale as string | undefined; const locale = (props.initialPage?.props as Record<string, unknown> | undefined)?.locale as string | undefined;

View File

@@ -28,7 +28,7 @@
})(); })();
</script> </script>
<title inertia>{{ config('app.name', 'Laravel') }}</title> <title inertia>{{ config('app.name', 'Fotospiel') }}</title>
<link rel="icon" href="{{ asset('favicon.ico') }}" type="image/x-icon"> <link rel="icon" href="{{ asset('favicon.ico') }}" type="image/x-icon">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">