Fix TypeScript typecheck errors
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-30 15:56:06 +01:00
parent 916b204688
commit b1f9f7cee0
42 changed files with 324 additions and 72 deletions

View File

@@ -16,9 +16,10 @@ export interface TenantProfile {
id: number;
tenant_id: number;
role?: string | null;
name?: string;
name?: string | null;
slug?: string;
email?: string | null;
avatar_url?: string | null;
features?: Record<string, unknown>;
[key: string]: unknown;
}

View File

@@ -1,6 +1,7 @@
{
"login": {
"title": "Event-Admin",
"pageTitle": "Login",
"badge": "Fotospiel Event Admin",
"hero_tagline": "Kontrolle behalten, entspannt bleiben",
"hero_title": "Das Cockpit für dein Fotospiel Event",
@@ -90,6 +91,9 @@
"appearance_label": "Darstellung"
},
"redirecting": "Weiterleitung zum Login …",
"logout": {
"title": "Abmeldung wird vorbereitet …"
},
"processing": {
"title": "Anmeldung wird verarbeitet …",
"copy": "Einen Moment bitte, wir bereiten dein Dashboard vor."

View File

@@ -983,6 +983,7 @@
"layouts": "Druck-Layouts",
"preview": "Anpassen & Exportieren",
"createLink": "Neuen QR-Link erstellen",
"createLinkConfirm": "Neuen QR-Link erstellen? Dadurch werden alle bisherigen Ausdrucke ungültig und alle Personen mit dem alten Link verlieren den Zugang.",
"mobileLinkLabel": "Mobiler Link",
"created": "Neuer QR-Link erstellt",
"createFailed": "Link konnte nicht erstellt werden.",

View File

@@ -14,6 +14,7 @@
},
"header": {
"appName": "Event Admin",
"documentTitle": "Fotospiel.App Event Admin",
"selectEvent": "Wähle ein Event, um fortzufahren",
"empty": "Lege dein erstes Event an, um zu starten",
"eventSwitcher": "Event auswählen",

View File

@@ -1,6 +1,7 @@
{
"login": {
"title": "Event Admin",
"pageTitle": "Login",
"badge": "Fotospiel Event Admin",
"hero_tagline": "Stay in control, stay relaxed",
"hero_title": "Your cockpit for every Fotospiel event",
@@ -90,6 +91,9 @@
"appearance_label": "Appearance"
},
"redirecting": "Redirecting to login …",
"logout": {
"title": "Signing out …"
},
"processing": {
"title": "Signing you in …",
"copy": "One moment please while we prepare your dashboard."

View File

@@ -979,6 +979,7 @@
"layouts": "Print Layouts",
"preview": "Customize & Export",
"createLink": "Create new QR link",
"createLinkConfirm": "Create a new QR link? This will invalidate all printed materials and everyone with the old link will lose access.",
"mobileLinkLabel": "Mobile link",
"created": "New QR link created",
"createFailed": "Could not create link.",

View File

@@ -14,6 +14,7 @@
},
"header": {
"appName": "Event Admin",
"documentTitle": "Fotospiel.App Event Admin",
"selectEvent": "Select an event to continue",
"empty": "Create your first event to get started",
"eventSwitcher": "Choose an event",

View File

@@ -9,6 +9,7 @@ import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
import { useAdminTheme } from './theme';
import { useDocumentTitle } from './hooks/useDocumentTitle';
export default function AuthCallbackPage(): React.ReactElement {
const { status } = useAuth();
@@ -21,6 +22,8 @@ export default function AuthCallbackPage(): React.ReactElement {
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
};
useDocumentTitle(t('processing.title', 'Signing you in …'));
const searchParams = React.useMemo(() => new URLSearchParams(window.location.search), []);
const rawReturnTo = searchParams.get('return_to');

View File

@@ -1324,7 +1324,7 @@ function LabeledSlider({
max={max}
step={step}
value={[value]}
onValueChange={(next) => onChange(next[0] ?? value)}
onValueChange={(next: number[]) => onChange(next[0] ?? value)}
disabled={disabled}
>
<Slider.Track height={6} borderRadius={999} backgroundColor={border}>

View File

@@ -108,12 +108,13 @@ function SectionHeader({
function StatusBadge({ status }: { status: string }) {
const { t } = useTranslation('management');
const config =
{
type StatusTone = 'success' | 'warning' | 'danger' | 'muted';
const statuses: Record<string, { tone: StatusTone; label: string }> = {
published: { tone: 'success', label: t('events.status.published', 'Live') },
draft: { tone: 'warning', label: t('events.status.draft', 'Draft') },
archived: { tone: 'muted', label: t('events.status.archived', 'Archived') },
}[status] || { tone: 'muted', label: status };
};
const config = statuses[status] ?? { tone: 'muted', label: status };
return <PillBadge tone={config.tone}>{config.label}</PillBadge>;
}
@@ -408,7 +409,7 @@ function LifecycleHero({
<DashboardCard variant={cardVariant} padding={cardPadding}>
<YStack space={isEmbedded ? '$2.5' : '$3'}>
<XStack alignItems="center" space="$2.5">
<YStack width={40} height={40} borderRadius={20} backgroundColor={theme.success} alignItems="center" justifyContent="center">
<YStack width={40} height={40} borderRadius={20} backgroundColor={theme.successText} alignItems="center" justifyContent="center">
<CheckCircle2 size={20} color="white" />
</YStack>
<YStack>
@@ -486,7 +487,7 @@ function LifecycleHero({
</Text>
<Switch
checked={published}
onCheckedChange={(checked) => handlePublishChange(Boolean(checked))}
onCheckedChange={(checked: boolean) => handlePublishChange(Boolean(checked))}
size="$2"
disabled={isPublishing}
aria-label={t('eventForm.fields.publish.label', 'Publish immediately')}
@@ -625,26 +626,27 @@ function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }
const { t } = useTranslation(['management', 'dashboard']);
const slug = event?.slug;
if (!slug) return null;
type ToolItem = { label: string; icon: any; path: string; color?: string };
const experienceItems = [
const experienceItems: ToolItem[] = [
{ label: t('management:photos.gallery.title', 'Photos'), icon: ImageIcon, path: `/mobile/events/${slug}/control-room`, color: theme.primary },
!isCompleted ? { label: t('management:events.quick.liveShowSettings', 'Slide Show'), icon: Tv, path: `/mobile/events/${slug}/live-show/settings`, color: '#F59E0B' } : null,
!isCompleted ? { label: t('events.tasks.badge', 'Photo tasks'), icon: ListTodo, path: `/mobile/events/${slug}/tasks`, color: theme.accent } : null,
!isCompleted ? { label: t('management:events.quick.photobooth', 'Photobooth'), icon: Camera, path: `/mobile/events/${slug}/photobooth`, color: '#8B5CF6' } : null,
].filter((item): item is { label: string; icon: any; path: string; color?: string } => Boolean(item));
].filter(Boolean) as ToolItem[];
const operationsItems = [
const operationsItems: ToolItem[] = [
!isCompleted ? { label: t('management:invites.badge', 'QR Codes'), icon: QrCode, path: `/mobile/events/${slug}/qr`, color: '#10B981' } : null,
{ label: t('management:events.quick.guests', 'Guests'), icon: Users, path: `/mobile/events/${slug}/members`, color: ADMIN_ACTION_COLORS.guests },
!isCompleted ? { label: t('management:events.quick.guestMessages', 'Messages'), icon: Megaphone, path: `/mobile/events/${slug}/guest-notifications`, color: ADMIN_ACTION_COLORS.guestMessages } : null,
!isCompleted ? { label: t('events.branding.titleShort', 'Branding'), icon: Layout, path: `/mobile/events/${slug}/branding`, color: ADMIN_ACTION_COLORS.branding } : null,
].filter((item): item is { label: string; icon: any; path: string; color?: string } => Boolean(item));
].filter(Boolean) as ToolItem[];
const adminItems = [
const adminItems: ToolItem[] = [
{ 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,
{ label: t('management:mobileProfile.settings', 'Settings'), icon: Settings, path: `/mobile/events/${slug}/edit`, color: ADMIN_ACTION_COLORS.settings },
].filter((item): item is { label: string; icon: any; path: string; color?: string } => Boolean(item));
].filter(Boolean) as ToolItem[];
const sections = [
{
@@ -719,11 +721,11 @@ function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }
);
}
function RecentPhotosSection({ photos, navigate, slug }: { photos: TenantPhoto[], navigate: any, slug: string }) {
function RecentPhotosSection({ photos, navigate, slug }: { photos: TenantPhoto[]; navigate: any; slug?: string }) {
const theme = useAdminTheme();
const { t } = useTranslation('management');
if (!photos.length) return null;
if (!photos.length || !slug) return null;
return (
<DashboardCard>

View File

@@ -1110,7 +1110,7 @@ export default function MobileEventControlRoomPage() {
size="$3"
checked={autoApproveHighlights}
disabled={controlRoomSaving}
onCheckedChange={(checked) =>
onCheckedChange={(checked: boolean) =>
saveControlRoomSettings({
...controlRoomSettings,
auto_approve_highlights: Boolean(checked),
@@ -1139,7 +1139,7 @@ export default function MobileEventControlRoomPage() {
size="$3"
checked={autoAddApprovedToLive}
disabled={controlRoomSaving || isImmediateUploads}
onCheckedChange={(checked) => {
onCheckedChange={(checked: boolean) => {
if (isImmediateUploads) {
return;
}
@@ -1164,7 +1164,7 @@ export default function MobileEventControlRoomPage() {
size="$3"
checked={autoRemoveLiveOnHide}
disabled={controlRoomSaving}
onCheckedChange={(checked) =>
onCheckedChange={(checked: boolean) =>
saveControlRoomSettings({
...controlRoomSettings,
auto_remove_live_on_hide: Boolean(checked),
@@ -1388,7 +1388,7 @@ export default function MobileEventControlRoomPage() {
<ToggleGroup
type="single"
value={moderationFilter}
onValueChange={(value) => value && setModerationFilter(value as ModerationFilter)}
onValueChange={(value: string) => value && setModerationFilter(value as ModerationFilter)}
>
<XStack space="$1.5">
{MODERATION_FILTERS.map((option) => {
@@ -1576,7 +1576,7 @@ export default function MobileEventControlRoomPage() {
<ToggleGroup
type="single"
value={liveStatusFilter}
onValueChange={(value) => value && setLiveStatusFilter(value as LiveShowQueueStatus)}
onValueChange={(value: string) => value && setLiveStatusFilter(value as LiveShowQueueStatus)}
>
<XStack space="$1.5">
{LIVE_STATUS_OPTIONS.map((option) => {

View File

@@ -527,7 +527,7 @@ export default function MobileEventFormPage() {
<XStack alignItems="center" space="$2">
<Switch
checked={form.published}
onCheckedChange={(checked) =>
onCheckedChange={(checked: boolean) =>
setForm((prev) => ({ ...prev, published: Boolean(checked) }))
}
size="$3"
@@ -546,7 +546,7 @@ export default function MobileEventFormPage() {
<XStack alignItems="center" space="$2">
<Switch
checked={form.tasksEnabled}
onCheckedChange={(checked) =>
onCheckedChange={(checked: boolean) =>
setForm((prev) => ({ ...prev, tasksEnabled: Boolean(checked) }))
}
size="$3"
@@ -577,7 +577,7 @@ export default function MobileEventFormPage() {
<XStack alignItems="center" space="$2">
<Switch
checked={form.autoApproveUploads}
onCheckedChange={(checked) =>
onCheckedChange={(checked: boolean) =>
setForm((prev) => ({ ...prev, autoApproveUploads: Boolean(checked) }))
}
size="$3"

View File

@@ -677,7 +677,7 @@ function EffectSlider({
max={max}
step={step}
value={[value]}
onValueChange={(next) => onChange(next[0] ?? value)}
onValueChange={(next: number[]) => onChange(next[0] ?? value)}
disabled={disabled}
>
<Slider.Track height={6} borderRadius={999} backgroundColor={border}>

View File

@@ -1158,7 +1158,7 @@ export default function MobileEventTasksPage() {
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as TaskSectionKey)}
onValueChange={(value: string) => setActiveTab(value as TaskSectionKey)}
flexDirection="column"
alignItems="stretch"
width="100%"
@@ -1411,7 +1411,7 @@ export default function MobileEventTasksPage() {
>
<RadioGroup
value={emotionFilter}
onValueChange={(val) => {
onValueChange={(val: string) => {
setEmotionFilter(val);
setShowEmotionFilterSheet(false);
}}
@@ -1441,7 +1441,7 @@ export default function MobileEventTasksPage() {
<AlertDialog
open={Boolean(deleteCandidate)}
onOpenChange={(open) => {
onOpenChange={(open: boolean) => {
if (!open) {
setDeleteCandidate(null);
}
@@ -1496,7 +1496,7 @@ export default function MobileEventTasksPage() {
<AlertDialog
open={bulkDeleteOpen}
onOpenChange={(open) => {
onOpenChange={(open: boolean) => {
if (!open) {
setBulkDeleteOpen(false);
}

View File

@@ -324,7 +324,7 @@ function EventsList({
<ToggleGroup
type="single"
value={statusFilter}
onValueChange={(value) => value && onStatusChange(value as EventStatusKey)}
onValueChange={(value: string) => value && onStatusChange(value as EventStatusKey)}
>
<XStack space="$1.5">
{filters.map((filter) => {

View File

@@ -10,6 +10,7 @@ import { ADMIN_LOGIN_PATH } from '../constants';
import { MobileCard } from './components/Primitives';
import { MobileField, MobileInput } from './components/FormControls';
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
import { useDocumentTitle } from './hooks/useDocumentTitle';
type ResetResponse = {
status: string;
@@ -51,6 +52,8 @@ export default function ForgotPasswordPage() {
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
};
useDocumentTitle(t('login.forgot.title', 'Forgot your password?'));
const mutation = useMutation({
mutationKey: ['tenantAdminForgotPassword'],
mutationFn: requestPasswordReset,

View File

@@ -20,6 +20,7 @@ import { Button } from '@tamagui/button';
import { MobileCard } from './components/Primitives';
import { MobileField, MobileInput } from './components/FormControls';
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
import { useDocumentTitle } from './hooks/useDocumentTitle';
type LoginResponse = {
token: string;
@@ -66,6 +67,8 @@ export default function MobileLoginPage() {
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
};
useDocumentTitle(t('login.pageTitle', 'Login'));
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
const rawReturnTo = searchParams.get('return_to');
const oauthError = searchParams.get('error');

View File

@@ -8,6 +8,7 @@ import { SizableText as Text } from '@tamagui/text';
import { Spinner } from 'tamagui';
import { ADMIN_LOGIN_PATH } from '../constants';
import { useAdminTheme } from './theme';
import { useDocumentTitle } from './hooks/useDocumentTitle';
export default function LoginStartPage(): React.ReactElement {
const location = useLocation();
@@ -19,6 +20,8 @@ export default function LoginStartPage(): React.ReactElement {
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
};
useDocumentTitle(t('redirecting', 'Redirecting to login …'));
useEffect(() => {
const params = new URLSearchParams(location.search);
const returnTo = params.get('return_to');

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@tamagui/card';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
@@ -6,15 +7,19 @@ import { Spinner } from 'tamagui';
import { useAuth } from '../auth/context';
import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
import { useAdminTheme } from './theme';
import { useDocumentTitle } from './hooks/useDocumentTitle';
export default function LogoutPage() {
const { logout } = useAuth();
const { t } = useTranslation('auth');
const { textStrong, muted, border, surface, shadow, appBackground } = useAdminTheme();
const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
};
useDocumentTitle(t('logout.title', 'Signing out …'));
React.useEffect(() => {
logout({ redirect: ADMIN_PUBLIC_LANDING_PATH });
}, [logout]);

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { Check, ChevronRight, ShieldCheck, Sparkles, X } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
@@ -395,7 +396,11 @@ function PackageShopCompareView({
}
}
if (row.type === 'feature') {
return t(`shop.features.${row.featureKey}`, row.featureKey);
}
return row.id;
};
const formatLimitValue = (value: number | null) => {
@@ -562,7 +567,7 @@ function getPackageStatusLabel({
isActive,
owned,
}: {
t: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
t: TFunction;
isActive?: boolean;
owned?: TenantPackageSummary;
}): string | null {
@@ -622,7 +627,7 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
id="agb"
size="$4"
checked={agbAccepted}
onCheckedChange={(checked) => setAgbAccepted(!!checked)}
onCheckedChange={(checked: boolean) => setAgbAccepted(!!checked)}
>
<Checkbox.Indicator>
<Check />
@@ -638,7 +643,7 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
id="withdrawal"
size="$4"
checked={withdrawalAccepted}
onCheckedChange={(checked) => setWithdrawalAccepted(!!checked)}
onCheckedChange={(checked: boolean) => setWithdrawalAccepted(!!checked)}
>
<Checkbox.Indicator>
<Check />
@@ -687,7 +692,7 @@ function aggregateOwnedEntries(entries: TenantPackageSummary[]): TenantPackageSu
}
function resolveIncludedTierLabel(
t: (key: string, fallback?: string, options?: Record<string, unknown>) => string,
t: TFunction,
slug: string | null
): string | null {
if (!slug) {

View File

@@ -8,6 +8,7 @@ import { Button } from '@tamagui/button';
import { ADMIN_LOGIN_PATH } from '../constants';
import { MobileCard, CTAButton } from './components/Primitives';
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
import { useDocumentTitle } from './hooks/useDocumentTitle';
type FaqItem = {
question: string;
@@ -44,6 +45,8 @@ export default function PublicHelpPage() {
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
};
useDocumentTitle(t('login.help_title', 'Help for event admins'));
return (
<YStack
minHeight="100vh"

View File

@@ -234,6 +234,16 @@ export default function MobileQrPrintPage() {
label={t('events.qr.createLink', 'Neuen QR-Link erstellen')}
onPress={async () => {
if (!slug) return;
if (
!window.confirm(
t(
'events.qr.createLinkConfirm',
'Neuen QR-Link erstellen? Dadurch werden alle bisherigen Ausdrucke ungültig und alle Personen mit dem alten Link verlieren den Zugang.'
)
)
) {
return;
}
try {
const invite = await createQrInvite(slug, { label: t('events.qr.mobileLinkLabel', 'Mobile link') });
setQrUrl(invite.url);

View File

@@ -10,6 +10,7 @@ import { ADMIN_LOGIN_PATH } from '../constants';
import { MobileCard } from './components/Primitives';
import { MobileField, MobileInput } from './components/FormControls';
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
import { useDocumentTitle } from './hooks/useDocumentTitle';
type ResetResponse = {
status: string;
@@ -68,6 +69,8 @@ export default function ResetPasswordPage() {
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
};
useDocumentTitle(t('login.reset.title', 'Reset password'));
const mutation = useMutation({
mutationKey: ['tenantAdminResetPassword'],
mutationFn: resetPassword,

View File

@@ -264,7 +264,7 @@ export default function MobileSettingsPage() {
<Switch
size="$4"
checked={pushState.subscribed}
onCheckedChange={(value) => {
onCheckedChange={(value: boolean) => {
if (value) {
void pushState.enable();
} else {

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import { ADMIN_EVENTS_PATH } from '../../constants';
import type { TenantEvent } from '../../api';
const fixtures = vi.hoisted(() => ({
event: {
@@ -9,14 +10,17 @@ const fixtures = vi.hoisted(() => ({
name: 'Demo Wedding',
slug: 'demo-event',
event_date: '2026-02-19',
status: 'published' as const,
settings: { location: 'Berlin' },
event_type_id: null,
event_type: null,
status: 'published',
engagement_mode: undefined,
settings: { location: 'Berlin', guest_upload_visibility: 'immediate' },
tasks_count: 4,
photo_count: 12,
active_invites_count: 3,
total_invites_count: 5,
member_permissions: ['photos:moderate', 'tasks:manage', 'join-tokens:manage'],
},
} as TenantEvent,
activePackage: {
id: 1,
package_id: 1,

View File

@@ -138,7 +138,9 @@ vi.mock('../components/Primitives', () => ({
}));
vi.mock('../components/FormControls', () => ({
MobileInput: ({ compact: _compact, ...props }: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileInput: ({ compact: _compact, ...props }: { compact?: boolean } & React.InputHTMLAttributes<HTMLInputElement>) => (
<input {...props} />
),
}));
vi.mock('@tamagui/card', () => ({

View File

@@ -13,18 +13,36 @@ const fixtures = vi.hoisted(() => ({
invites: [
{
id: 1,
token: 'invite-token',
url: 'https://example.test/guest/demo-event',
qr_code_data_url: '',
label: null,
qr_code_data_url: null,
usage_limit: null,
usage_count: 0,
expires_at: null,
revoked_at: null,
is_active: true,
created_at: '2026-01-15T12:00:00Z',
metadata: {},
layouts: [
{
id: 'layout-1',
name: 'Poster Layout',
description: 'Layout description',
subtitle: 'Layout subtitle',
paper: 'a4',
orientation: 'portrait',
panel_mode: 'single',
preview: {
background: null,
background_gradient: null,
accent: null,
text: null,
},
formats: [],
},
],
layouts_url: null,
},
],
}));

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const fixtures = vi.hoisted(() => ({
event: {
@@ -74,7 +75,11 @@ vi.mock('../components/MobileShell', () => ({
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
CTAButton: ({ label, onPress, disabled }: { label: string; onPress?: () => void; disabled?: boolean }) => (
<button type="button" onClick={onPress} disabled={disabled}>
{label}
</button>
),
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
@@ -116,6 +121,7 @@ vi.mock('../theme', () => ({
}));
import MobileQrPrintPage from '../QrPrintPage';
import { createQrInvite } from '../../api';
describe('MobileQrPrintPage', () => {
it('renders QR overview content', async () => {
@@ -125,4 +131,19 @@ describe('MobileQrPrintPage', () => {
expect(screen.getByText('Schritt 1: Format wählen')).toBeInTheDocument();
expect(screen.getAllByText('Neuen QR-Link erstellen').length).toBeGreaterThan(0);
});
it('requires confirmation before creating a new QR link', async () => {
const user = userEvent.setup();
const confirmSpy = vi.fn().mockReturnValue(false);
window.confirm = confirmSpy;
render(<MobileQrPrintPage />);
const createButton = await screen.findByRole('button', { name: 'Neuen QR-Link erstellen' });
await user.click(createButton);
expect(confirmSpy).toHaveBeenCalled();
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Ausdrucke'));
expect(createQrInvite).not.toHaveBeenCalled();
});
});

View File

@@ -62,7 +62,7 @@ describe('selectRecommendedPackageId', () => {
] as any;
it('returns null when no feature is requested', () => {
expect(selectRecommendedPackageId(packages, null, 100)).toBeNull();
expect(selectRecommendedPackageId(packages, null, null)).toBeNull();
});
it('selects the cheapest upgrade with the feature', () => {

View File

@@ -184,7 +184,7 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
{...props}
{...({ type } as any)}
secureTextEntry={isPassword}
onChangeText={(value) => {
onChangeText={(value: string) => {
onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
}}
size={compact ? '$3' : '$4'}
@@ -222,7 +222,7 @@ export const MobileTextArea = React.forwardRef<
ref={ref as React.Ref<any>}
{...props}
{...({ minHeight: compact ? 72 : 96 } as any)}
onChangeText={(value) => {
onChangeText={(value: string) => {
onChange?.({ target: { value } } as React.ChangeEvent<HTMLTextAreaElement>);
}}
size={compact ? '$3' : '$4'}
@@ -292,7 +292,7 @@ export function MobileSelect({
<Select
value={selectValue}
defaultValue={selectValue === undefined ? selectDefault : undefined}
onValueChange={(next) => {
onValueChange={(next: string) => {
props.onChange?.({ target: { value: next } } as React.ChangeEvent<HTMLSelectElement>);
}}
size={compact ? '$3' : '$4'}

View File

@@ -107,7 +107,7 @@ export function LegalConsentSheet({
id="legal-terms"
size="$4"
checked={acceptedTerms}
onCheckedChange={(checked) => setAcceptedTerms(Boolean(checked))}
onCheckedChange={(checked: boolean) => setAcceptedTerms(Boolean(checked))}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
@@ -135,7 +135,7 @@ export function LegalConsentSheet({
id="legal-waiver"
size="$4"
checked={acceptedWaiver}
onCheckedChange={(checked) => setAcceptedWaiver(Boolean(checked))}
onCheckedChange={(checked: boolean) => setAcceptedWaiver(Boolean(checked))}
borderWidth={1}
borderColor={border}
backgroundColor={surface}

View File

@@ -18,6 +18,7 @@ import { TenantEvent, getEvents } from '../../api';
import { setTabHistory } from '../lib/tabHistory';
import { loadPhotoQueue } from '../lib/photoModerationQueue';
import { countQueuedPhotoActions } from '../lib/queueStatus';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
import { useAdminTheme } from '../theme';
import { useAuth } from '../../auth/context';
import { EventSwitcherSheet } from './EventSwitcherSheet';
@@ -25,13 +26,14 @@ import { UserMenuSheet } from './UserMenuSheet';
type MobileShellProps = {
title?: string;
subtitle?: string;
children: React.ReactNode;
activeTab: NavKey;
onBack?: () => void;
headerActions?: React.ReactNode;
};
export function MobileShell({ title, children, activeTab, onBack, headerActions }: MobileShellProps) {
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
const { events, activeEvent, selectEvent } = useEventContext();
const { user } = useAuth();
const { go } = useMobileNav(activeEvent?.slug, activeTab);
@@ -41,6 +43,8 @@ export function MobileShell({ title, children, activeTab, onBack, headerActions
const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus();
useDocumentTitle(title);
const theme = useAdminTheme();
const backgroundColor = theme.background;
@@ -360,6 +364,18 @@ export function MobileShell({ title, children, activeTab, onBack, headerActions
) : null}
</MobileCard>
) : null}
{subtitle ? (
<YStack space="$1">
{title ? (
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong}>
{title}
</Text>
) : null}
<Text fontSize="$sm" color={theme.muted}>
{subtitle}
</Text>
</YStack>
) : null}
{children}
</YStack>

View File

@@ -5,6 +5,7 @@ import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useTranslation } from 'react-i18next';
import { useAdminTheme } from '../theme';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
type OnboardingShellProps = {
eyebrow?: string;
@@ -34,6 +35,8 @@ export function OnboardingShell({
const resolvedBackLabel = backLabel ?? t('layout.back', 'Back');
const resolvedSkipLabel = skipLabel ?? t('layout.skip', 'Skip');
useDocumentTitle(title);
return (
<YStack minHeight="100vh" backgroundColor={background} alignItems="center">
<YStack

View File

@@ -98,7 +98,7 @@ export function CTAButton({
iconRight,
}: {
label: string;
onPress: () => void;
onPress?: () => void;
tone?: 'primary' | 'ghost' | 'danger';
fullWidth?: boolean;
disabled?: boolean;
@@ -110,7 +110,7 @@ export function CTAButton({
const { primary, surface, border, text, danger, glassSurfaceStrong } = useAdminTheme();
const isPrimary = tone === 'primary';
const isDanger = tone === 'danger';
const isDisabled = disabled || loading;
const isDisabled = disabled || loading || !onPress;
const backgroundColor = isDanger ? danger : isPrimary ? primary : glassSurfaceStrong ?? surface;
const borderColor = isPrimary || isDanger ? 'transparent' : border;
const labelColor = isPrimary || isDanger ? 'white' : text;

View File

@@ -66,8 +66,7 @@ export function SetupChecklist({
height={6}
>
<Progress.Indicator
backgroundColor={isAllComplete ? theme.success : theme.primary}
animation="bouncy"
backgroundColor={isAllComplete ? theme.successText : theme.primary}
/>
</Progress>
</YStack>

View File

@@ -17,7 +17,7 @@ import { MobileSelect } from './FormControls';
type UserMenuSheetProps = {
open: boolean;
onClose: () => void;
user?: { name?: string | null; email?: string | null; avatar_url?: string | null };
user?: { name?: string | null; email?: string | null; avatar_url?: string | null } | null;
isMember: boolean;
navigate: (path: string) => void;
};
@@ -245,7 +245,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
<Switch
size="$2"
checked={isDark}
onCheckedChange={(next) => updateAppearance(next ? 'dark' : 'light')}
onCheckedChange={(next: boolean) => updateAppearance(next ? 'dark' : 'light')}
aria-label={t('mobileProfile.theme', 'Dark Mode')}
>
<Switch.Thumb />

View File

@@ -2,6 +2,8 @@ import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import type { EventContextValue } from '../../../context/EventContext';
import type { TenantEvent } from '../../../api';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, i18n: { language: 'en-GB' } }),
@@ -55,12 +57,27 @@ vi.mock('../UserMenuSheet', () => ({
UserMenuSheet: () => <div data-testid="user-menu-sheet" />,
}));
const eventContext = {
const baseEvent: TenantEvent = {
id: 1,
name: 'Test Event',
slug: 'event-1',
event_date: '2024-01-01',
event_type_id: null,
event_type: null,
status: 'published',
settings: {},
};
const eventContext: EventContextValue = {
events: [],
activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
isLoading: false,
isError: false,
activeEvent: baseEvent,
activeSlug: baseEvent.slug,
hasMultipleEvents: false,
hasEvents: true,
selectEvent: vi.fn(),
refetch: vi.fn(),
};
vi.mock('../../../context/EventContext', () => ({
@@ -129,9 +146,25 @@ describe('MobileShell', () => {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
document.title = '';
eventContext.events = [];
eventContext.hasMultipleEvents = false;
eventContext.activeEvent = { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} };
eventContext.activeEvent = { ...baseEvent };
eventContext.activeSlug = baseEvent.slug;
});
it('sets the document title with the app prefix', async () => {
await act(async () => {
render(
<MemoryRouter>
<MobileShell activeTab="home" title="Dashboard">
<div>Body</div>
</MobileShell>
</MemoryRouter>
);
});
expect(document.title).toBe('Fotospiel.App Event Admin · Dashboard');
});
it('renders quick QR as icon-only button', async () => {
@@ -171,8 +204,14 @@ describe('MobileShell', () => {
it('shows the event switcher when multiple events are available', async () => {
eventContext.events = [
{ slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
{ slug: 'event-2', name: 'Second Event', event_date: '2024-02-01', status: 'active', settings: {} },
{ ...baseEvent },
{
...baseEvent,
id: 2,
slug: 'event-2',
name: 'Second Event',
event_date: '2024-02-01',
},
];
await act(async () => {
@@ -190,8 +229,14 @@ describe('MobileShell', () => {
it('hides the event switcher on the events list page', async () => {
eventContext.events = [
{ slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
{ slug: 'event-2', name: 'Second Event', event_date: '2024-02-01', status: 'active', settings: {} },
{ ...baseEvent },
{
...baseEvent,
id: 2,
slug: 'event-2',
name: 'Second Event',
event_date: '2024-02-01',
},
];
await act(async () => {

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
const TITLE_SEPARATOR = ' · ';
export function useDocumentTitle(title?: string | null) {
const { t, i18n } = useTranslation('mobile');
const language = i18n?.language;
React.useEffect(() => {
if (typeof document === 'undefined') {
return;
}
const baseTitle = t('header.documentTitle', 'Fotospiel.App Event Admin');
const resolvedTitle = typeof title === 'string' ? title.trim() : '';
document.title = resolvedTitle ? `${baseTitle}${TITLE_SEPARATOR}${resolvedTitle}` : baseTitle;
}, [language, t, title]);
}

View File

@@ -106,7 +106,7 @@ export function classifyPackageChange(pkg: Package, active: Package | null): Pac
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !featureSatisfied(feature, activeFeatures));
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !featureSatisfied(feature, candidateFeatures));
const limitKeys: Array<keyof Package> = ['max_photos', 'max_guests', 'gallery_days'];
const limitKeys: Array<'max_photos' | 'max_guests' | 'gallery_days'> = ['max_photos', 'max_guests', 'gallery_days'];
let hasLimitUpgrade = false;
let hasLimitDowngrade = false;

View File

@@ -1,16 +1,17 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Sparkles, Flame, UserRound, Camera } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useTranslation } from '../i18n/useTranslation';
export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
type FilterConfig = Array<{ value: GalleryFilter; labelKey: string; icon: React.ReactNode }>;
type FilterConfig = Array<{ value: GalleryFilter; labelKey: string; icon: LucideIcon }>;
const baseFilters: FilterConfig = [
{ value: 'latest', labelKey: 'galleryPage.filters.latest', icon: <Sparkles className="h-4 w-4" aria-hidden /> },
{ value: 'popular', labelKey: 'galleryPage.filters.popular', icon: <Flame className="h-4 w-4" aria-hidden /> },
{ value: 'mine', labelKey: 'galleryPage.filters.mine', icon: <UserRound className="h-4 w-4" aria-hidden /> },
{ value: 'latest', labelKey: 'galleryPage.filters.latest', icon: Sparkles },
{ value: 'popular', labelKey: 'galleryPage.filters.popular', icon: Flame },
{ value: 'mine', labelKey: 'galleryPage.filters.mine', icon: UserRound },
];
export default function FiltersBar({
@@ -29,7 +30,7 @@ export default function FiltersBar({
const { t } = useTranslation();
const filters: FilterConfig = React.useMemo(
() => (showPhotobooth
? [...baseFilters, { value: 'photobooth', labelKey: 'galleryPage.filters.photobooth', icon: <Camera className="h-4 w-4" aria-hidden /> }]
? [...baseFilters, { value: 'photobooth', labelKey: 'galleryPage.filters.photobooth', icon: Camera }]
: baseFilters),
[showPhotobooth],
);
@@ -45,6 +46,7 @@ export default function FiltersBar({
<div className="inline-flex items-center rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
{filters.map((filter, index) => {
const isActive = value === filter.value;
const Icon = filter.icon;
return (
<div key={filter.value} className="flex items-center">
<button
@@ -57,7 +59,7 @@ export default function FiltersBar({
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white',
)}
>
{React.cloneElement(filter.icon as React.ReactElement, { className: 'h-3.5 w-3.5' })}
<Icon className="h-3.5 w-3.5" aria-hidden />
<span className="whitespace-nowrap">{t(filter.labelKey)}</span>
</button>
{index < filters.length - 1 && (

View File

@@ -127,7 +127,7 @@ export default function TaskPickerPage() {
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
const taskList = Array.isArray(payload)
const taskList: Task[] = Array.isArray(payload)
? payload
: Array.isArray(payload?.data)
? payload.data

View File

@@ -15,15 +15,85 @@ declare module '@tamagui/button' {
export { Button };
}
declare module '@tamagui/card' {
export const Card: any;
}
declare module '@tamagui/group' {
export const XGroup: any;
export const YGroup: any;
}
declare module '@tamagui/list-item' {
export const ListItem: any;
}
declare module '@tamagui/checkbox' {
export const Checkbox: any;
}
declare module '@tamagui/switch' {
export const Switch: any;
}
declare module '@tamagui/accordion' {
export const Accordion: any;
}
declare module '@tamagui/scroll-view' {
export const ScrollView: any;
}
declare module '@tamagui/toggle-group' {
export const ToggleGroup: any;
}
declare module '@tamagui/alert-dialog' {
export const AlertDialog: any;
}
declare module '@tamagui/select' {
export const Select: any;
}
declare module '@tamagui/sheet' {
export const Sheet: any;
}
declare module '@tamagui/avatar' {
export const Avatar: any;
}
declare module '@tamagui/radio-group' {
export const RadioGroup: any;
}
declare module '@tamagui/progress' {
export const Progress: any;
}
declare module '@tamagui/slider' {
export const Slider: any;
}
declare module '@tamagui/image' {
export const Image: any;
}
declare module '@tamagui/react-native-web-lite' {
export const Pressable: any;
export * from 'react-native';
}
declare module 'tamagui' {
export const Tabs: any;
export const Separator: any;
export const Slider: any;
export const Input: any;
export const TextArea: any;
export const Spinner: any;
}
declare module '@/actions/*' {
const mod: any;
export = mod;