Fix demo task readiness and gate event creation
This commit is contained in:
@@ -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 },
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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', () => ({
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
28
resources/js/lib/__tests__/appTitle.test.ts
Normal file
28
resources/js/lib/__tests__/appTitle.test.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
8
resources/js/lib/appTitle.ts
Normal file
8
resources/js/lib/appTitle.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user