diff --git a/eslint.config.js b/eslint.config.js index a136d22..f7345fd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -38,7 +38,17 @@ export default [ }, }, { - ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'], + ignores: [ + 'vendor', + 'node_modules', + 'public', + 'bootstrap/ssr', + 'tailwind.config.js', + 'docs/site/**', + 'docs/site/.docusaurus/**', + 'docs/site/build/**', + 'i18next-scanner.config.js', + ], }, prettier, // Turn off all rules that might conflict with Prettier ]; diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index a3c97f5..8b4eb51 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -3,7 +3,7 @@ import { ApiError, emitApiErrorEvent } from './lib/apiError'; import type { EventLimitSummary } from './lib/limitWarnings'; import i18n from './i18n'; -type JsonValue = Record; +type JsonValue = Record; export type TenantAccountProfile = { id: number; diff --git a/resources/js/admin/components/tenant/stat-carousel.tsx b/resources/js/admin/components/tenant/stat-carousel.tsx index 7111518..89b0ca3 100644 --- a/resources/js/admin/components/tenant/stat-carousel.tsx +++ b/resources/js/admin/components/tenant/stat-carousel.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { cn } from '@/lib/utils'; interface StatItem { key: string; diff --git a/resources/js/admin/context/EventContext.tsx b/resources/js/admin/context/EventContext.tsx index 72c5253..e78dcdf 100644 --- a/resources/js/admin/context/EventContext.tsx +++ b/resources/js/admin/context/EventContext.tsx @@ -44,7 +44,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { initialData: [], }); - const events = authReady ? fetchedEvents : []; + const events = React.useMemo(() => (authReady ? fetchedEvents : []), [authReady, fetchedEvents]); const isLoading = authReady ? queryLoading : status === 'loading'; React.useEffect(() => { diff --git a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx index e9161f5..24d3928 100644 --- a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx +++ b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx @@ -148,9 +148,12 @@ export default function WelcomeOrderSummaryPage() { const packagesState = useTenantPackages(); const { t, i18n } = useTranslation("onboarding"); const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE"; - const { currencyFormatter, dateFormatter } = useLocaleFormats(locale); + const { currencyFormatter } = useLocaleFormats(locale); - const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined; + const packageIdFromState = + typeof location.state === "object" && location.state !== null && "packageId" in location.state + ? (location.state as { packageId?: number | string | null }).packageId + : undefined; const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null; React.useEffect(() => { diff --git a/resources/js/admin/pages/EngagementPage.tsx b/resources/js/admin/pages/EngagementPage.tsx index 486991a..b3735a6 100644 --- a/resources/js/admin/pages/EngagementPage.tsx +++ b/resources/js/admin/pages/EngagementPage.tsx @@ -6,10 +6,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { AdminLayout } from '../components/AdminLayout'; -import { CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { TenantHeroCard, - TenantOnboardingChecklistCard, FrostedSurface, tenantHeroPrimaryButtonClass, tenantHeroSecondaryButtonClass, diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index fde9e1f..56eb1f7 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -327,7 +327,7 @@ const shownWarningToasts = React.useRef>(new Set()); scope={warning.scope as 'photos' | 'guests' | 'gallery'} onCheckout={(key) => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery', key); }} busy={addonBusyId === warning.scope} - t={(key, fallback) => t(key as any, fallback)} + t={(key, fallback) => t(key, fallback)} /> ) : null} diff --git a/resources/js/admin/pages/EventInvitesPage.tsx b/resources/js/admin/pages/EventInvitesPage.tsx index 6a57a6e..f6d4476 100644 --- a/resources/js/admin/pages/EventInvitesPage.tsx +++ b/resources/js/admin/pages/EventInvitesPage.tsx @@ -452,8 +452,8 @@ export default function EventInvitesPage(): React.ReactElement { const exportLogo = effectiveCustomization?.logo_data_url ?? effectiveCustomization?.logo_url ?? null; const exportQr = selectedInvite?.qr_code_data_url ?? null; - const handlePreviewSelect = React.useCallback((_id: string | null) => undefined, []); - const handlePreviewChange = React.useCallback((_id: string, _patch: Partial) => undefined, []); + const handlePreviewSelect = React.useCallback(() => undefined, []); + const handlePreviewChange = React.useCallback(() => undefined, []); const handleCustomizerDraftChange = React.useCallback((draft: QrLayoutCustomization | null) => { setCustomizerDraft((previous) => { @@ -865,7 +865,7 @@ export default function EventInvitesPage(): React.ReactElement { scope="guests" onCheckout={(key) => { void handleAddonPurchase(key); }} busy={addonBusy === 'guests'} - t={(key, fallback) => t(key as any, fallback)} + t={(key, fallback) => t(key, fallback)} /> ) : null} @@ -882,7 +882,7 @@ export default function EventInvitesPage(): React.ReactElement { {t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')} - t(key as any, fallback)} /> + t(key, fallback)} /> ) : null} diff --git a/resources/js/admin/pages/EventPhotosPage.tsx b/resources/js/admin/pages/EventPhotosPage.tsx index 98d11de..9b7314f 100644 --- a/resources/js/admin/pages/EventPhotosPage.tsx +++ b/resources/js/admin/pages/EventPhotosPage.tsx @@ -13,14 +13,14 @@ import { getAddonCatalog, getEvent, type EventAddonCatalogItem, type EventAddonS import { AdminLayout } from '../components/AdminLayout'; import { createEventAddonCheckout, deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api'; import { isAuthError } from '../auth/tokens'; -import { getApiErrorMessage, isApiError } from '../lib/apiError'; +import { getApiErrorMessage } from '../lib/apiError'; import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings'; import { useTranslation } from 'react-i18next'; import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants'; export default function EventPhotosPage() { const params = useParams<{ slug?: string }>(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const slug = params.slug ?? searchParams.get('slug') ?? null; const navigate = useNavigate(); const { t } = useTranslation('management'); @@ -36,8 +36,6 @@ export default function EventPhotosPage() { const [busyId, setBusyId] = React.useState(null); const [limits, setLimits] = React.useState(null); const [addons, setAddons] = React.useState([]); - const [catalogError, setCatalogError] = React.useState(undefined); - //const [searchParams, setSearchParams] = React.useState(() => new URLSearchParams(window.location.search)); const [eventAddons, setEventAddons] = React.useState([]); const load = React.useCallback(async () => { @@ -57,7 +55,6 @@ export default function EventPhotosPage() { setLimits(photoResult.limits ?? null); setEventAddons(eventData.addons ?? []); setAddons(catalog); - setCatalogError(undefined); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.')); @@ -81,7 +78,7 @@ export default function EventPhotosPage() { setSearchParams(params); navigate(window.location.pathname, { replace: true }); } - }, [searchParams, slug, load, navigate, translateLimits]); + }, [searchParams, slug, load, navigate, translateLimits, setSearchParams]); async function handleToggleFeature(photo: TenantPhoto) { if (!slug) return; @@ -162,7 +159,7 @@ export default function EventPhotosPage() { {t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')} - t(key as any, fallback)} /> + t(key, fallback)} /> )} diff --git a/resources/js/admin/pages/EventsPage.tsx b/resources/js/admin/pages/EventsPage.tsx index 382e1ba..f04bc9e 100644 --- a/resources/js/admin/pages/EventsPage.tsx +++ b/resources/js/admin/pages/EventsPage.tsx @@ -33,7 +33,7 @@ import { ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_TOOLKIT_PATH, } from '../constants'; -import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings'; +import { buildLimitWarnings } from '../lib/limitWarnings'; import { useTranslation } from 'react-i18next'; export default function EventsPage() { @@ -57,7 +57,7 @@ export default function EventsPage() { setLoading(false); } })(); - }, []); + }, [t]); const translateManagement = React.useCallback( (key: string, fallback?: string, options?: Record) => diff --git a/resources/js/admin/pages/FaqPage.tsx b/resources/js/admin/pages/FaqPage.tsx index 414c1c1..a1b26cb 100644 --- a/resources/js/admin/pages/FaqPage.tsx +++ b/resources/js/admin/pages/FaqPage.tsx @@ -239,7 +239,7 @@ function formatDate(value: string, language: string | undefined): string { month: 'short', year: 'numeric', }); - } catch (error) { + } catch { return value; } } diff --git a/resources/js/admin/pages/LoginPage.tsx b/resources/js/admin/pages/LoginPage.tsx index 8fccd3b..4ee7fd5 100644 --- a/resources/js/admin/pages/LoginPage.tsx +++ b/resources/js/admin/pages/LoginPage.tsx @@ -10,7 +10,7 @@ import { Label } from '@/components/ui/label'; import { useAuth } from '../auth/context'; import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_EVENTS_PATH } from '../constants'; -import { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo'; +import { resolveReturnTarget } from '../lib/returnTo'; import { useMutation } from '@tanstack/react-query'; type LoginResponse = { diff --git a/resources/js/admin/pages/TasksPage.tsx b/resources/js/admin/pages/TasksPage.tsx index 619eae0..654d267 100644 --- a/resources/js/admin/pages/TasksPage.tsx +++ b/resources/js/admin/pages/TasksPage.tsx @@ -352,7 +352,6 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task export default function TasksPage() { const navigate = useNavigate(); - const { t } = useTranslation('management'); const { t: tc } = useTranslation('common'); return ( { - const body =
{content}
; - - if (!isCompact) { - return ( -
-
-

{title}

- {description ?

{description}

: null} -
- {body} -
- ); - } + const renderResponsiveSection = (id: string, title: string, description: string, content: React.ReactNode) => { + const body =
{content}
; + if (!isCompact) { return ( - - -
-

{title}

- {description ?

{description}

: null} -
- -
- {body} -
+
+
+

{title}

+ {description ?

{description}

: null} +
+ {body} +
); - }, - [isCompact] - ); + } + + return ( + + +
+

{title}

+ {description ?

{description}

: null} +
+ +
+ {body} +
+ ); + }; return (
diff --git a/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx b/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx index 39264ef..9565fc5 100644 --- a/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx +++ b/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx @@ -6,7 +6,6 @@ import { CANVAS_WIDTH, LayoutElement, clamp, - LayoutElementType, } from './schema'; type DesignerCanvasProps = { @@ -23,7 +22,6 @@ type DesignerCanvasProps = { qrCodeDataUrl: string | null; logoDataUrl: string | null; scale?: number; - layoutKey?: string; readOnly?: boolean; }; @@ -43,7 +41,6 @@ export function DesignerCanvas({ qrCodeDataUrl, logoDataUrl, scale = 1, - layoutKey, readOnly = false, }: DesignerCanvasProps): React.JSX.Element { const canvasElementRef = React.useRef(null); @@ -170,10 +167,6 @@ export function DesignerCanvas({ return () => { const timeoutId = window.setTimeout(() => { - if (disposeTokenRef.current !== disposeToken) { - return; - } - destroyCanvas(canvas); pendingTimeoutRef.current = null; pendingDisposeRef.current = null; @@ -216,8 +209,8 @@ export function DesignerCanvas({ onSelect(active.elementId); }; - const handleSelectionCleared = (event?: unknown) => { - const pointerEvent = event as { e?: MouseEvent } | undefined; + const handleSelectionCleared = (event?: fabric.IEvent) => { + const pointerEvent = event?.e; if (readOnly) { return; } @@ -229,11 +222,11 @@ export function DesignerCanvas({ onSelect(null); }; - const handleObjectModified = (e: any) => { + const handleObjectModified = (event: fabric.IEvent) => { if (readOnly) { return; } - const target = e.target as FabricObjectWithId | undefined; + const target = event.target as FabricObjectWithId | undefined; if (!target || typeof target.elementId !== 'string') { return; } @@ -312,7 +305,7 @@ export function DesignerCanvas({ canvas.on('selection:cleared', handleSelectionCleared); canvas.on('object:modified', handleObjectModified); - const handleEditingExited = (event: { target?: FabricObjectWithId & { text?: string } }) => { + const handleEditingExited = (event: fabric.IEvent & { target?: FabricObjectWithId & { text?: string } }) => { if (readOnly) { return; } @@ -327,14 +320,14 @@ export function DesignerCanvas({ canvas.requestRenderAll(); }; - (canvas as any).on('editing:exited', handleEditingExited); + canvas.on('editing:exited', handleEditingExited); return () => { canvas.off('selection:created', handleSelection); canvas.off('selection:updated', handleSelection); canvas.off('selection:cleared', handleSelectionCleared); canvas.off('object:modified', handleObjectModified); - (canvas as any).off('editing:exited', handleEditingExited); + canvas.off('editing:exited', handleEditingExited); }; }, [onChange, onSelect, readOnly]); @@ -702,7 +695,9 @@ export async function createFabricObject({ padding: 0, // No padding to fix large frame }); if (qrImage) { - (qrImage as any).uniformScaling = true; // Lock aspect ratio + if (qrImage instanceof fabric.Image) { + qrImage.uniformScaling = true; // Lock aspect ratio + } qrImage.lockScalingFlip = true; qrImage.padding = 0; qrImage.cornerColor = 'transparent'; @@ -801,7 +796,7 @@ export async function loadImageObject( options?: { objectFit?: 'contain' | 'cover'; shadow?: string; padding?: number }, abortSignal?: AbortSignal, ): Promise { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { let resolved = false; const resolveSafely = (value: fabric.Object | null) => { if (resolved) { diff --git a/resources/js/admin/pages/components/invite-layout/schema.ts b/resources/js/admin/pages/components/invite-layout/schema.ts index b0840b9..6ab1e07 100644 --- a/resources/js/admin/pages/components/invite-layout/schema.ts +++ b/resources/js/admin/pages/components/invite-layout/schema.ts @@ -1,5 +1,18 @@ - // import type { EventQrInviteLayout } from '../../api'; // Temporär deaktiviert wegen Modul-Fehler; definiere lokal falls nötig -type EventQrInviteLayout = any; // Placeholder für Typ, bis Pfad gefixt +// import type { EventQrInviteLayout } from '../../api'; // Temporär deaktiviert wegen Modul-Fehler; definiere lokal falls nötig +type EventQrInviteLayout = { + id: string; + name?: string; + description?: string | null; + subtitle?: string | null; + preview?: { + background?: string | null; + background_gradient?: { angle?: number; stops?: string[] } | null; + accent?: string | null; + text?: string | null; + qr_size_px?: number | null; + } | null; + formats?: string[]; +}; export const CANVAS_WIDTH = 1240; export const CANVAS_HEIGHT = 1754; diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index fec91d1..91319ad 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -5,8 +5,6 @@ import { ADMIN_BASE_PATH, ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_EVENTS_PATH, - ADMIN_HOME_PATH, - ADMIN_LOGIN_PATH, ADMIN_LOGIN_START_PATH, ADMIN_PUBLIC_LANDING_PATH, } from './constants'; diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 89822d5..78dd19c 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -20,15 +20,16 @@ createInertiaApp({ resolvePageComponent( `./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx') - ).then((page: any) => { - if (page?.default) { - const Component = page.default; + ).then((page) => { + const resolved = page as { default?: { layout?: (page: React.ReactNode) => React.ReactNode } }; + if (resolved?.default) { + const Component = resolved.default; if (!Component.layout) { - Component.layout = (page: React.ReactNode) => {page}; + Component.layout = (node: React.ReactNode) => {node}; } } - return page; + return resolved; }), setup({ el, App, props }) { const root = createRoot(el); diff --git a/resources/js/components/analytics/MatomoTracker.tsx b/resources/js/components/analytics/MatomoTracker.tsx index 12a3ba8..b9d2ea4 100644 --- a/resources/js/components/analytics/MatomoTracker.tsx +++ b/resources/js/components/analytics/MatomoTracker.tsx @@ -10,7 +10,9 @@ export type MatomoConfig = { declare global { interface Window { - _paq?: any[]; + _paq?: Array<[string, ...unknown[]]>; + __matomoInitialized?: boolean; + __CSP_NONCE?: string; } } @@ -19,7 +21,7 @@ interface MatomoTrackerProps { } const MatomoTracker: React.FC = ({ config }) => { - const page = usePage(); + const page = usePage<{ security?: { csp?: { scriptNonce?: string } } }>(); const { hasConsent } = useConsent(); const scriptNonce = (page.props.security as { csp?: { scriptNonce?: string } } | undefined)?.csp?.scriptNonce; const analyticsConsent = hasConsent('analytics'); @@ -38,14 +40,14 @@ const MatomoTracker: React.FC = ({ config }) => { if (window._paq) { window._paq.length = 0; } - delete (window as any).__matomoInitialized; + delete window.__matomoInitialized; return; } window._paq = window._paq || []; const { _paq } = window; - if (!(window as any).__matomoInitialized) { + if (!window.__matomoInitialized) { _paq.push(['setTrackerUrl', `${base}/matomo.php`]); _paq.push(['setSiteId', config.siteId]); _paq.push(['disableCookies']); @@ -58,8 +60,8 @@ const MatomoTracker: React.FC = ({ config }) => { script.dataset.matomo = base; if (scriptNonce) { script.setAttribute('nonce', scriptNonce); - } else if (typeof window !== 'undefined' && (window as any).__CSP_NONCE) { - script.setAttribute('nonce', (window as any).__CSP_NONCE); + } else if (typeof window !== 'undefined' && window.__CSP_NONCE) { + script.setAttribute('nonce', window.__CSP_NONCE); } else { const metaNonce = document .querySelector('meta[name="csp-nonce"]') @@ -72,9 +74,9 @@ const MatomoTracker: React.FC = ({ config }) => { document.body.appendChild(script); } - (window as any).__matomoInitialized = true; + window.__matomoInitialized = true; } - }, [config, analyticsConsent]); + }, [config, analyticsConsent, scriptNonce]); useEffect(() => { if ( diff --git a/resources/js/components/ui/Steps.tsx b/resources/js/components/ui/Steps.tsx index e3f66bd..30b8e3a 100644 --- a/resources/js/components/ui/Steps.tsx +++ b/resources/js/components/ui/Steps.tsx @@ -1,8 +1,6 @@ import * as React from "react" -import { ChevronLeft, ChevronRight } from "lucide-react" import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" interface Step { id: string @@ -66,4 +64,4 @@ const Steps = React.forwardRef( ) Steps.displayName = "Steps" -export { Steps } \ No newline at end of file +export { Steps } diff --git a/resources/js/components/ui/carousel.tsx b/resources/js/components/ui/carousel.tsx index 39658d8..8c658a9 100644 --- a/resources/js/components/ui/carousel.tsx +++ b/resources/js/components/ui/carousel.tsx @@ -10,7 +10,7 @@ import useEmblaCarousel from "embla-carousel-react" interface CarouselApi { slideNodes(): HTMLElement[] - on(event: string, listener: (...args: any[]) => void): void + on(event: string, listener: (...args: unknown[]) => void): void scrollPrev(): void scrollNext(): void reInit(): void @@ -18,21 +18,24 @@ interface CarouselApi { const CarouselContext = React.createContext(null) -interface CarouselProps { +type CarouselProps = React.HTMLAttributes & { opts?: { align?: "start" | "center" | "end" loop?: boolean } - plugins?: any[] + plugins?: unknown[] setApi?: (api: CarouselApi) => void - [key: string]: any } const Carousel = React.forwardRef( ({ opts, plugins = [Autoplay()], setApi, className, children, ...props }, ref) => { const [api, setApiInternal] = React.useState(null) - const [emblaRef] = useEmblaCarousel(opts, plugins) + const [emblaRef, emblaApi] = useEmblaCarousel(opts, plugins) + + React.useEffect(() => { + setApiInternal(emblaApi ?? null) + }, [emblaApi]) React.useEffect(() => { if (!api) { diff --git a/resources/js/components/ui/textarea.tsx b/resources/js/components/ui/textarea.tsx index 7800927..bf000cf 100644 --- a/resources/js/components/ui/textarea.tsx +++ b/resources/js/components/ui/textarea.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { cn } from "@/lib/utils" -export interface TextareaProps extends React.TextareaHTMLAttributes {} +export type TextareaProps = React.TextareaHTMLAttributes const Textarea = React.forwardRef( ({ className, ...props }, ref) => { diff --git a/resources/js/guest/components/EmotionPicker.tsx b/resources/js/guest/components/EmotionPicker.tsx index 35de491..847778f 100644 --- a/resources/js/guest/components/EmotionPicker.tsx +++ b/resources/js/guest/components/EmotionPicker.tsx @@ -37,14 +37,17 @@ export default function EmotionPicker({ const { locale } = useTranslation(); // Fallback emotions (when API not available yet) - const fallbackEmotions: Emotion[] = [ - { id: 1, slug: 'happy', name: 'Glücklich', emoji: '😊' }, - { id: 2, slug: 'love', name: 'Verliebt', emoji: '❤️' }, - { id: 3, slug: 'excited', name: 'Aufgeregt', emoji: '🎉' }, - { id: 4, slug: 'relaxed', name: 'Entspannt', emoji: '😌' }, - { id: 5, slug: 'sad', name: 'Traurig', emoji: '😢' }, - { id: 6, slug: 'surprised', name: 'Überrascht', emoji: '😲' }, - ]; + const fallbackEmotions = React.useMemo( + () => [ + { id: 1, slug: 'happy', name: 'Glücklich', emoji: '😊' }, + { id: 2, slug: 'love', name: 'Verliebt', emoji: '❤️' }, + { id: 3, slug: 'excited', name: 'Aufgeregt', emoji: '🎉' }, + { id: 4, slug: 'relaxed', name: 'Entspannt', emoji: '😌' }, + { id: 5, slug: 'sad', name: 'Traurig', emoji: '😢' }, + { id: 6, slug: 'surprised', name: 'Überrascht', emoji: '😲' }, + ], + [] + ); useEffect(() => { if (!eventKey) return; @@ -79,7 +82,7 @@ export default function EmotionPicker({ } fetchEmotions(); - }, [eventKey, locale]); + }, [eventKey, locale, fallbackEmotions]); const handleEmotionSelect = (emotion: Emotion) => { if (onSelect) { diff --git a/resources/js/guest/components/GalleryPreview.tsx b/resources/js/guest/components/GalleryPreview.tsx index 00fcf36..0fcb6e3 100644 --- a/resources/js/guest/components/GalleryPreview.tsx +++ b/resources/js/guest/components/GalleryPreview.tsx @@ -9,6 +9,20 @@ import { useTranslation } from '../i18n/useTranslation'; type Props = { token: string }; type PreviewFilter = 'latest' | 'popular' | 'mine' | 'photobooth'; +type PreviewPhoto = { + id: number; + session_id?: string | null; + ingest_source?: string | null; + likes_count?: number | null; + created_at?: string | null; + task_id?: number | null; + task_title?: string | null; + emotion_id?: number | null; + emotion_name?: string | null; + thumbnail_path?: string | null; + file_path?: string | null; + title?: string | null; +}; export default function GalleryPreview({ token }: Props) { const { locale } = useTranslation(); @@ -16,28 +30,29 @@ export default function GalleryPreview({ token }: Props) { const [mode, setMode] = React.useState('latest'); const items = React.useMemo(() => { - let arr = photos.slice(); + const typed = photos as PreviewPhoto[]; + let arr = typed.slice(); // MyPhotos filter (requires session_id matching) if (mode === 'mine') { const deviceId = getDeviceId(); - arr = arr.filter((photo: any) => photo.session_id === deviceId); + arr = arr.filter((photo) => photo.session_id === deviceId); } else if (mode === 'photobooth') { - arr = arr.filter((photo: any) => photo.ingest_source === 'photobooth'); + arr = arr.filter((photo) => photo.ingest_source === 'photobooth'); } // Sorting if (mode === 'popular') { - arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0)); + arr.sort((a, b) => (b.likes_count ?? 0) - (a.likes_count ?? 0)); } else { - arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); + arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); } return arr.slice(0, 4); // 2x2 = 4 items }, [photos, mode]); // Helper function to generate photo title (must be before return) - function getPhotoTitle(photo: any): string { + function getPhotoTitle(photo: PreviewPhoto): string { if (photo.task_id) { return `Task: ${photo.task_title || 'Unbekannte Aufgabe'}`; } @@ -102,7 +117,7 @@ export default function GalleryPreview({ token }: Props) { )}
- {items.map((p: any) => ( + {items.map((p: PreviewPhoto) => ( setList((arr) => arr.filter((x) => x.id !== id)), 3000); }, []); React.useEffect(() => { - const onEvt = (e: any) => push(e.detail); + const onEvt = (e: CustomEvent>) => push(e.detail); window.addEventListener('guest-toast', onEvt); return () => window.removeEventListener('guest-toast', onEvt); }, [push]); diff --git a/resources/js/guest/context/EventStatsContext.tsx b/resources/js/guest/context/EventStatsContext.tsx index 3ae66ae..6dc6906 100644 --- a/resources/js/guest/context/EventStatsContext.tsx +++ b/resources/js/guest/context/EventStatsContext.tsx @@ -12,7 +12,7 @@ export function EventStatsProvider({ eventKey, children }: { eventKey: string; c const stats = usePollStats(eventKey); const value = React.useMemo( () => ({ eventKey, slug: eventKey, ...stats }), - [eventKey, stats.onlineGuests, stats.tasksSolved, stats.latestPhotoAt, stats.loading] + [eventKey, stats] ); return {children}; } diff --git a/resources/js/guest/demo/demoMode.ts b/resources/js/guest/demo/demoMode.ts index dfdd241..d08c013 100644 --- a/resources/js/guest/demo/demoMode.ts +++ b/resources/js/guest/demo/demoMode.ts @@ -8,6 +8,13 @@ let enabled = false; let originalFetch: typeof window.fetch | null = null; const likeState = new Map(); +declare global { + interface Window { + __FOTOSPIEL_DEMO__?: boolean; + __FOTOSPIEL_DEMO_ACTIVE__?: boolean; + } +} + export function shouldEnableGuestDemoMode(): boolean { if (typeof window === 'undefined') { return false; @@ -16,7 +23,7 @@ export function shouldEnableGuestDemoMode(): boolean { if (params.get('demo') === '1') { return true; } - if ((window as any).__FOTOSPIEL_DEMO__ === true) { + if (window.__FOTOSPIEL_DEMO__ === true) { return true; } const attr = document.documentElement?.dataset?.guestDemo; @@ -42,7 +49,7 @@ export function enableGuestDemoMode(config: DemoConfig = { fixtures: demoFixture }; enabled = true; - (window as any).__FOTOSPIEL_DEMO_ACTIVE__ = true; + window.__FOTOSPIEL_DEMO_ACTIVE__ = true; notifyDemoToast(); } diff --git a/resources/js/guest/hooks/useGuestTaskProgress.ts b/resources/js/guest/hooks/useGuestTaskProgress.ts index 15a68bb..d2b178e 100644 --- a/resources/js/guest/hooks/useGuestTaskProgress.ts +++ b/resources/js/guest/hooks/useGuestTaskProgress.ts @@ -43,19 +43,6 @@ export function useGuestTaskProgress(eventKey: string | undefined) { } }, [eventKey]); - const persist = React.useCallback( - (next: number[]) => { - if (!eventKey) return; - setCompleted(next); - try { - window.localStorage.setItem(storageKey(eventKey), JSON.stringify(next)); - } catch (error) { - console.warn('Failed to persist task progress', error); - } - }, - [eventKey] - ); - const markCompleted = React.useCallback( (taskId: number) => { if (!eventKey || !Number.isInteger(taskId)) { diff --git a/resources/js/guest/lib/image.ts b/resources/js/guest/lib/image.ts index c48d8d2..e58c6f3 100644 --- a/resources/js/guest/lib/image.ts +++ b/resources/js/guest/lib/image.ts @@ -15,7 +15,7 @@ export async function compressPhoto( const canvas = createCanvas(width, height); const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas unsupported'); - ctx.drawImage(img as any, 0, 0, width, height); + ctx.drawImage(img, 0, 0, width, height); // Iteratively lower quality to fit target size let quality = qualityStart; @@ -58,14 +58,20 @@ function createCanvas(w: number, h: number): HTMLCanvasElement { } function toBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise { - return new Promise((resolve) => canvas.toBlob(resolve, type, quality)); + return new Promise(resolve => canvas.toBlob(resolve, type, quality)); } -async function loadImageBitmap(file: File): Promise { +async function loadImageBitmap(file: File): Promise { const canBitmap = 'createImageBitmap' in window; + if (canBitmap) { - try { return await (createImageBitmap as any)(file); } catch {} + try { + return await createImageBitmap(file); + } catch (error) { + console.warn('Falling back to HTML image decode', error); + } } + return await loadHtmlImage(file); } @@ -88,4 +94,3 @@ export function formatBytes(bytes: number) { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } - diff --git a/resources/js/guest/lib/sharePhoto.ts b/resources/js/guest/lib/sharePhoto.ts index 6ee32c8..6aab71f 100644 --- a/resources/js/guest/lib/sharePhoto.ts +++ b/resources/js/guest/lib/sharePhoto.ts @@ -43,8 +43,8 @@ export async function sharePhotoLink(options: ShareOptions): Promise<{ url: stri try { await navigator.share(shareData); return { url: payload.url, method: 'native' }; - } catch (error: any) { - if (error?.name === 'AbortError') { + } catch (error: unknown) { + if (error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError') { return { url: payload.url, method: 'native' }; } // fall through to clipboard diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index ec5c033..d9a6f3d 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -6,13 +6,28 @@ import FiltersBar, { type GalleryFilter } from '../components/FiltersBar'; import { Heart, Image as ImageIcon, Share2 } from 'lucide-react'; import { likePhoto } from '../services/photosApi'; import PhotoLightbox from './PhotoLightbox'; -import { fetchEvent, fetchStats, type EventData, type EventStats } from '../services/eventApi'; +import { fetchEvent, type EventData } from '../services/eventApi'; import { useTranslation } from '../i18n/useTranslation'; import { sharePhotoLink } from '../lib/sharePhoto'; import { useToast } from '../components/ToastHost'; import { localizeTaskLabel } from '../lib/localizeTaskLabel'; const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth']; +type GalleryPhoto = { + id: number; + likes_count?: number | null; + created_at?: string | null; + ingest_source?: string | null; + session_id?: string | null; + task_id?: number | null; + task_title?: string | null; + emotion_id?: number | null; + emotion_name?: string | null; + thumbnail_path?: string | null; + file_path?: string | null; + title?: string | null; + uploader_name?: string | null; +}; const parseGalleryFilter = (value: string | null): GalleryFilter => allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest'; @@ -46,7 +61,6 @@ export default function GalleryPage() { const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false); const [event, setEvent] = useState(null); - const [stats, setStats] = useState(null); const [eventLoading, setEventLoading] = useState(true); const toast = useToast(); const [shareTargetId, setShareTargetId] = React.useState(null); @@ -62,16 +76,18 @@ export default function GalleryPage() { params.set('mode', next); setSearchParams(params, { replace: true }); }, [searchParams, setSearchParams]); + const typedPhotos = photos as GalleryPhoto[]; + // Auto-open lightbox if photoId in query params useEffect(() => { if (photoIdParam && photos.length > 0 && currentPhotoIndex === null && !hasOpenedPhoto) { - const index = photos.findIndex((photo: any) => photo.id === parseInt(photoIdParam, 10)); + const index = typedPhotos.findIndex((photo) => photo.id === parseInt(photoIdParam, 10)); if (index !== -1) { setCurrentPhotoIndex(index); setHasOpenedPhoto(true); } } - }, [photos, photoIdParam, currentPhotoIndex, hasOpenedPhoto]); + }, [typedPhotos, photos.length, photoIdParam, currentPhotoIndex, hasOpenedPhoto]); // Load event and package info useEffect(() => { @@ -80,12 +96,8 @@ export default function GalleryPage() { const loadEventData = async () => { try { setEventLoading(true); - const [eventData, statsData] = await Promise.all([ - fetchEvent(token), - fetchStats(token), - ]); + const eventData = await fetchEvent(token); setEvent(eventData); - setStats(statsData); } catch (err) { console.error('Failed to load event data', err); } finally { @@ -104,27 +116,22 @@ export default function GalleryPage() { }, []); const list = React.useMemo(() => { - let arr = photos.slice(); + let arr = typedPhotos.slice(); if (filter === 'popular') { - arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0)); + arr.sort((a, b) => (b.likes_count ?? 0) - (a.likes_count ?? 0)); } else if (filter === 'mine') { - arr = arr.filter((p: any) => myPhotoIds.has(p.id)); + arr = arr.filter((p) => myPhotoIds.has(p.id)); } else if (filter === 'photobooth') { - arr = arr.filter((p: any) => p.ingest_source === 'photobooth'); - arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); + arr = arr.filter((p) => p.ingest_source === 'photobooth'); + arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); } else { - arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); + arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); } return arr; - }, [photos, filter, myPhotoIds]); + }, [typedPhotos, filter, myPhotoIds]); const [liked, setLiked] = React.useState>(new Set()); const [counts, setCounts] = React.useState>({}); - const totalLikes = React.useMemo( - () => photos.reduce((sum, photo: any) => sum + (photo.likes_count ?? 0), 0), - [photos], - ); - async function onLike(id: number) { if (liked.has(id)) return; setLiked(new Set(liked).add(id)); @@ -136,13 +143,16 @@ export default function GalleryPage() { const raw = localStorage.getItem('liked-photo-ids'); const arr: number[] = raw ? JSON.parse(raw) : []; if (!arr.includes(id)) localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, id])); - } catch {} - } catch { + } catch (error) { + console.warn('Failed to persist liked-photo-ids', error); + } + } catch (error) { + console.warn('Like failed', error); const s = new Set(liked); s.delete(id); setLiked(s); } } - async function onShare(photo: any) { + async function onShare(photo: GalleryPhoto) { if (!token) return; setShareTargetId(photo.id); try { @@ -223,7 +233,7 @@ export default function GalleryPage() { {loading &&

{t('galleryPage.loading', 'Lade…')}

}
- {list.map((p: any) => { + {list.map((p: GalleryPhoto) => { const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path); const createdLabel = p.created_at ? new Date(p.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) @@ -236,7 +246,7 @@ export default function GalleryPage() { const altText = t('galleryPage.photo.alt', { id: p.id, suffix: altSuffix }, `Foto ${p.id}${altSuffix}`); const openPhoto = () => { - const index = list.findIndex((photo: any) => photo.id === p.id); + const index = list.findIndex((photo) => photo.id === p.id); setCurrentPhotoIndex(index >= 0 ? index : null); }; diff --git a/resources/js/guest/pages/HelpArticlePage.tsx b/resources/js/guest/pages/HelpArticlePage.tsx index 44e7b53..659ba50 100644 --- a/resources/js/guest/pages/HelpArticlePage.tsx +++ b/resources/js/guest/pages/HelpArticlePage.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { Link, useParams } from 'react-router-dom'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Loader2 } from 'lucide-react'; import { Page } from './_util'; @@ -15,7 +14,6 @@ export default function HelpArticlePage() { const { t } = useTranslation(); const [article, setArticle] = React.useState(null); const [state, setState] = React.useState<'loading' | 'ready' | 'error'>('loading'); - const [servedFromCache, setServedFromCache] = React.useState(false); const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help'; const loadArticle = React.useCallback(async () => { @@ -27,7 +25,6 @@ export default function HelpArticlePage() { try { const result = await getHelpArticle(slug, locale); setArticle(result.article); - setServedFromCache(result.servedFromCache); setState('ready'); } catch (error) { console.error('[HelpArticle] Failed to load article', error); @@ -117,7 +114,7 @@ function formatDate(value: string, locale: string): string { month: 'short', year: 'numeric', }); - } catch (error) { + } catch { return value; } } diff --git a/resources/js/guest/pages/HelpCenterPage.tsx b/resources/js/guest/pages/HelpCenterPage.tsx index 2333166..1430676 100644 --- a/resources/js/guest/pages/HelpCenterPage.tsx +++ b/resources/js/guest/pages/HelpCenterPage.tsx @@ -147,7 +147,7 @@ function formatDate(value: string, locale: string): string { month: 'short', year: 'numeric', }); - } catch (error) { + } catch { return value; } } diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx index ac491e0..f65e22b 100644 --- a/resources/js/guest/pages/HomePage.tsx +++ b/resources/js/guest/pages/HomePage.tsx @@ -6,10 +6,9 @@ import { Separator } from '@/components/ui/separator'; import EmotionPicker from '../components/EmotionPicker'; import GalleryPreview from '../components/GalleryPreview'; import { useGuestIdentity } from '../context/GuestIdentityContext'; -import { useEventStats } from '../context/EventStatsContext'; import { useEventData } from '../hooks/useEventData'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; -import { Sparkles, UploadCloud, X, Camera, RefreshCw } from 'lucide-react'; +import { Sparkles, UploadCloud, X, RefreshCw } from 'lucide-react'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { useEventBranding } from '../context/EventBrandingContext'; import type { EventBranding } from '../types/event-branding'; @@ -17,7 +16,6 @@ import type { EventBranding } from '../types/event-branding'; export default function HomePage() { const { token } = useParams<{ token: string }>(); const { name, hydrated } = useGuestIdentity(); - const stats = useEventStats(); const { event } = useEventData(); const { completedCount } = useGuestTaskProgress(token ?? ''); const { t, locale } = useTranslation(); @@ -100,10 +98,10 @@ export default function HomePage() { const payload = await response.json(); if (cancelled) return; if (Array.isArray(payload) && payload.length) { - missionPoolRef.current = payload.map((task: any) => ({ + missionPoolRef.current = payload.map((task: Record) => ({ id: Number(task.id), - title: task.title ?? 'Mission', - description: task.description ?? '', + title: typeof task.title === 'string' ? task.title : 'Mission', + description: typeof task.description === 'string' ? task.description : '', duration: typeof task.duration === 'number' ? task.duration : 3, emotion: task.emotion ?? null, })); diff --git a/resources/js/guest/pages/PhotoLightbox.tsx b/resources/js/guest/pages/PhotoLightbox.tsx index 9b37dd7..85bea37 100644 --- a/resources/js/guest/pages/PhotoLightbox.tsx +++ b/resources/js/guest/pages/PhotoLightbox.tsx @@ -38,8 +38,6 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh const toast = useToast(); const [standalonePhoto, setStandalonePhoto] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [task, setTask] = useState(null); const [taskLoading, setTaskLoading] = useState(false); const [likes, setLikes] = useState(0); @@ -59,8 +57,6 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh useEffect(() => { if (isStandalone && photoId && !standalonePhoto && eventToken) { const fetchPhoto = async () => { - setLoading(true); - setError(null); try { const res = await fetch(`/api/v1/photos/${photoId}?locale=${encodeURIComponent(locale)}`, { headers: { @@ -76,20 +72,17 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh setStandalonePhoto(location.state.photo); } } else { - setError(t('lightbox.errors.notFound')); + toast.push({ text: t('lightbox.errors.notFound'), type: 'error' }); } } catch (err) { - setError(t('lightbox.errors.loadFailed')); - } finally { - setLoading(false); + console.warn('Standalone photo load failed', err); + toast.push({ text: t('lightbox.errors.loadFailed'), type: 'error' }); } }; fetchPhoto(); - } else if (!isStandalone) { - setLoading(false); } - }, [isStandalone, photoId, eventToken, standalonePhoto, location.state, t, locale]); + }, [isStandalone, photoId, eventToken, standalonePhoto, location.state, t, locale, toast]); // Update likes when photo changes React.useEffect(() => { @@ -163,8 +156,8 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh } ); if (res.ok) { - const tasks = await res.json(); - const foundTask = tasks.find((t: any) => t.id === taskId); + const tasks = (await res.json()) as Task[]; + const foundTask = tasks.find((t) => t.id === taskId); if (foundTask) { setTask({ id: foundTask.id, @@ -207,7 +200,9 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh if (!arr.includes(photo.id)) { localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, photo.id])); } - } catch {} + } catch (storageError) { + console.warn('Failed to persist liked photo IDs', storageError); + } } catch (error) { console.error('Like failed:', error); setLiked(false); diff --git a/resources/js/guest/pages/PublicGalleryPage.tsx b/resources/js/guest/pages/PublicGalleryPage.tsx index c519cee..640721f 100644 --- a/resources/js/guest/pages/PublicGalleryPage.tsx +++ b/resources/js/guest/pages/PublicGalleryPage.tsx @@ -40,7 +40,7 @@ export default function PublicGalleryPage(): React.ReactElement | null { const localeStorageKey = token ? `guestGalleryLocale_${token}` : 'guestGalleryLocale'; const storedLocale = typeof window !== 'undefined' && token ? localStorage.getItem(localeStorageKey) : null; - const effectiveLocale = storedLocale && isLocaleCode(storedLocale as any) ? (storedLocale as any) : DEFAULT_LOCALE; + const effectiveLocale: LocaleCode = storedLocale && isLocaleCode(storedLocale) ? storedLocale : DEFAULT_LOCALE; const applyMeta = useCallback((meta: GalleryMetaResponse) => { if (typeof window !== 'undefined' && token) { diff --git a/resources/js/guest/pages/SharedPhotoPage.tsx b/resources/js/guest/pages/SharedPhotoPage.tsx index 2a8b9e7..33e07bd 100644 --- a/resources/js/guest/pages/SharedPhotoPage.tsx +++ b/resources/js/guest/pages/SharedPhotoPage.tsx @@ -39,7 +39,7 @@ export default function SharedPhotoPage() { if (!active) return; setState({ loading: false, error: null, data }); }) - .catch((error: any) => { + .catch((error: unknown) => { if (!active) return; setState({ loading: false, error: error?.message ?? t('share.error', 'Moment konnte nicht geladen werden.'), data: null }); }); diff --git a/resources/js/guest/pages/TaskPickerPage.tsx b/resources/js/guest/pages/TaskPickerPage.tsx index 5fba785..921483d 100644 --- a/resources/js/guest/pages/TaskPickerPage.tsx +++ b/resources/js/guest/pages/TaskPickerPage.tsx @@ -8,7 +8,6 @@ import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { cn } from '@/lib/utils'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import EmotionPicker from '../components/EmotionPicker'; import { useEventBranding } from '../context/EventBrandingContext'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; @@ -171,7 +170,7 @@ export default function TaskPickerPage() { const { branding } = useEventBranding(); const { t, locale } = useTranslation(); - const { completedCount, isCompleted } = useGuestTaskProgress(eventKey); + const { isCompleted } = useGuestTaskProgress(eventKey); const [tasks, setTasks] = React.useState([]); const [currentTask, setCurrentTask] = React.useState(null); diff --git a/resources/js/guest/polling/usePollGalleryDelta.ts b/resources/js/guest/polling/usePollGalleryDelta.ts index 404f6bf..bb10fb3 100644 --- a/resources/js/guest/polling/usePollGalleryDelta.ts +++ b/resources/js/guest/polling/usePollGalleryDelta.ts @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { LocaleCode } from '../i18n/messages'; -type Photo = { id: number; file_path?: string; thumbnail_path?: string; created_at?: string }; +type Photo = { id: number; file_path?: string; thumbnail_path?: string; created_at?: string; session_id?: string | null }; +type RawPhoto = Record; export function usePollGalleryDelta(token: string, locale: LocaleCode) { const [photos, setPhotos] = useState([]); @@ -14,7 +15,7 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) { typeof document !== 'undefined' ? document.visibilityState === 'visible' : true ); - async function fetchDelta() { + const fetchDelta = useCallback(async () => { if (!token) { setLoading(false); return; @@ -56,9 +57,9 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) { Array.isArray(json) ? json : json.photos || []; - const newPhotos = rawPhotos.map((photo: any) => ({ - ...photo, - session_id: photo?.session_id ?? photo?.guest_name ?? null, + const newPhotos: Photo[] = rawPhotos.map((photo: RawPhoto) => ({ + ...(photo as Photo), + session_id: typeof photo.session_id === 'string' ? photo.session_id : (photo.guest_name as string | null) ?? null, })); if (newPhotos.length > 0) { @@ -67,11 +68,9 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) { if (latestAt.current) { // Delta mode: Add new photos to existing list const merged = [...newPhotos, ...photos]; - // Remove duplicates by ID - const uniquePhotos = merged.filter((photo, index, self) => - index === self.findIndex(p => p.id === photo.id) - ); - setPhotos(uniquePhotos); + const byId = new Map(); + merged.forEach((photo) => byId.set(photo.id, photo)); + setPhotos(Array.from(byId.values())); if (added > 0) setNewCount((c) => c + added); } else { // Initial load: Set all photos @@ -83,8 +82,8 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) { latestAt.current = json.latest_photo_at; } else if (newPhotos.length > 0) { // Fallback: use newest photo timestamp - const newest = newPhotos.reduce((latest: number, photo: any) => { - const photoTime = new Date(photo.created_at || photo.created_at_timestamp || 0).getTime(); + const newest = newPhotos.reduce((latest: number, photo: RawPhoto) => { + const photoTime = new Date((photo.created_at as string | undefined) || (photo.created_at_timestamp as number | undefined) || 0).getTime(); return photoTime > latest ? photoTime : latest; }, 0); latestAt.current = new Date(newest).toISOString(); @@ -104,7 +103,7 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) { setLoading(false); // Don't update state on error - keep previous photos } - } + }, [locale, photos, token]); useEffect(() => { const onVis = () => setVisible(document.visibilityState === 'visible'); @@ -123,15 +122,15 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) { latestAt.current = null; etagRef.current = null; setPhotos([]); - fetchDelta(); + void fetchDelta(); if (timer.current) window.clearInterval(timer.current); // Poll less aggressively when hidden const interval = visible ? 30_000 : 90_000; - timer.current = window.setInterval(fetchDelta, interval); + timer.current = window.setInterval(() => { void fetchDelta(); }, interval); return () => { if (timer.current) window.clearInterval(timer.current); }; - }, [token, visible, locale]); + }, [token, visible, locale, fetchDelta]); function acknowledgeNew() { setNewCount(0); } return { loading, photos, newCount, acknowledgeNew }; diff --git a/resources/js/guest/queue/idb.ts b/resources/js/guest/queue/idb.ts index a1f072e..692c163 100644 --- a/resources/js/guest/queue/idb.ts +++ b/resources/js/guest/queue/idb.ts @@ -21,7 +21,7 @@ export async function withStore(mode: TxMode, fn: (store: IDBObjectStore) => return new Promise((resolve, reject) => { const tx = db.transaction('items', mode); const store = tx.objectStore('items'); - let result: any; + let result: unknown; const wrap = async () => { try { result = await fn(store); } catch (e) { reject(e); } }; @@ -31,4 +31,3 @@ export async function withStore(mode: TxMode, fn: (store: IDBObjectStore) => tx.onabort = () => reject(tx.error); }); } - diff --git a/resources/js/guest/queue/notify.ts b/resources/js/guest/queue/notify.ts index b48cc06..f7e070c 100644 --- a/resources/js/guest/queue/notify.ts +++ b/resources/js/guest/queue/notify.ts @@ -1,11 +1,16 @@ export function notify(text: string, type: 'success'|'error') { // Lazy import to avoid cycle - import('../components/ToastHost').then(({ useToast }) => { - try { - // This only works inside React tree; for SW-triggered, we fallback - const evt = new CustomEvent('guest-toast', { detail: { text, type } }); - window.dispatchEvent(evt); - } catch {} - }); + import('../components/ToastHost') + .then(() => { + try { + // This only works inside React tree; for SW-triggered, we fallback + const evt = new CustomEvent('guest-toast', { detail: { text, type } }); + window.dispatchEvent(evt); + } catch (error) { + console.warn('Dispatching toast event failed', error); + } + }) + .catch((error) => { + console.warn('Toast module failed to load', error); + }); } - diff --git a/resources/js/guest/queue/queue.ts b/resources/js/guest/queue/queue.ts index 54d10f0..86d7ebd 100644 --- a/resources/js/guest/queue/queue.ts +++ b/resources/js/guest/queue/queue.ts @@ -30,7 +30,9 @@ export async function enqueue(item: Omit { (pct) => { try { window.dispatchEvent(new CustomEvent('queue-progress', { detail: { id: it.id, progress: pct } })); - } catch {} + } catch (error) { + console.warn('Queue progress dispatch failed', error); + } } ); // mark my-photo-ids for "Meine" @@ -91,7 +95,9 @@ async function attemptUpload(it: QueueItem): Promise { const raw = localStorage.getItem('my-photo-ids'); const arr: number[] = raw ? JSON.parse(raw) : []; if (json.id && !arr.includes(json.id)) localStorage.setItem('my-photo-ids', JSON.stringify([json.id, ...arr])); - } catch {} + } catch (error) { + console.warn('Failed to persist my-photo-ids', error); + } notify('Upload erfolgreich', 'success'); return true; } catch { diff --git a/resources/js/guest/queue/xhr.ts b/resources/js/guest/queue/xhr.ts index e612544..7cc0c56 100644 --- a/resources/js/guest/queue/xhr.ts +++ b/resources/js/guest/queue/xhr.ts @@ -5,7 +5,7 @@ export async function createUpload( it: QueueItem, deviceId: string, onProgress?: (percent: number) => void -): Promise { +): Promise> { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', url, true); @@ -22,7 +22,12 @@ export async function createUpload( }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { - try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); } + try { + resolve(JSON.parse(xhr.responseText)); + } catch (error) { + console.warn('Upload response parse failed', error); + resolve({}); + } } else { reject(new Error('upload failed')); } diff --git a/resources/js/guest/services/achievementApi.ts b/resources/js/guest/services/achievementApi.ts index 2121816..7453853 100644 --- a/resources/js/guest/services/achievementApi.ts +++ b/resources/js/guest/services/achievementApi.ts @@ -160,32 +160,41 @@ export async function fetchAchievements( tasks: toNumber(personalRaw.tasks), likes: toNumber(personalRaw.likes), badges: Array.isArray(personalRaw.badges) - ? personalRaw.badges.map((badge: any): AchievementBadge => ({ - id: safeString(badge.id), - title: safeString(badge.title), - description: safeString(badge.description), - earned: Boolean(badge.earned), - progress: toNumber(badge.progress), - target: toNumber(badge.target, 1), - })) + ? personalRaw.badges.map((badge): AchievementBadge => { + const record = badge as Record; + return { + id: safeString(record.id), + title: safeString(record.title), + description: safeString(record.description), + earned: Boolean(record.earned), + progress: toNumber(record.progress), + target: toNumber(record.target, 1), + }; + }) : [], } : null; const uploadsBoard = Array.isArray(leaderboards.uploads) - ? leaderboards.uploads.map((row: any): LeaderboardEntry => ({ - guest: safeString(row.guest), - photos: toNumber(row.photos), - likes: toNumber(row.likes), - })) + ? leaderboards.uploads.map((row): LeaderboardEntry => { + const record = row as Record; + return { + guest: safeString(record.guest), + photos: toNumber(record.photos), + likes: toNumber(record.likes), + }; + }) : []; const likesBoard = Array.isArray(leaderboards.likes) - ? leaderboards.likes.map((row: any): LeaderboardEntry => ({ - guest: safeString(row.guest), - photos: toNumber(row.photos), - likes: toNumber(row.likes), - })) + ? leaderboards.likes.map((row): LeaderboardEntry => { + const record = row as Record; + return { + guest: safeString(record.guest), + photos: toNumber(record.photos), + likes: toNumber(record.likes), + }; + }) : []; const topPhotoRaw = highlights.top_photo ?? null; @@ -210,21 +219,27 @@ export async function fetchAchievements( : null; const timeline = Array.isArray(highlights.timeline) - ? highlights.timeline.map((row: any): TimelinePoint => ({ - date: safeString(row.date), - photos: toNumber(row.photos), - guests: toNumber(row.guests), - })) + ? highlights.timeline.map((row): TimelinePoint => { + const record = row as Record; + return { + date: safeString(record.date), + photos: toNumber(record.photos), + guests: toNumber(record.guests), + }; + }) : []; - const feed = feedRaw.map((row: any): FeedEntry => ({ - photoId: toNumber(row.photo_id), - guest: safeString(row.guest), - task: row.task ?? null, - likes: toNumber(row.likes), - createdAt: safeString(row.created_at), - thumbnail: row.thumbnail ? safeString(row.thumbnail) : null, - })); + const feed = feedRaw.map((row): FeedEntry => { + const record = row as Record; + return { + photoId: toNumber(record.photo_id), + guest: safeString(record.guest), + task: (record as { task?: string }).task ?? null, + likes: toNumber(record.likes), + createdAt: safeString(record.created_at), + thumbnail: record.thumbnail ? safeString(record.thumbnail) : null, + }; + }); const payload: AchievementsPayload = { summary: { diff --git a/resources/js/guest/services/galleryApi.ts b/resources/js/guest/services/galleryApi.ts index abb7d8c..b362858 100644 --- a/resources/js/guest/services/galleryApi.ts +++ b/resources/js/guest/services/galleryApi.ts @@ -40,8 +40,9 @@ async function handleResponse(response: Response): Promise { const data = await response.json().catch(() => null); if (!response.ok) { - const error = new Error((data && data.error && data.error.message) || 'Request failed'); - (error as any).code = data?.error?.code ?? response.status; + const errorPayload = data as { error?: { message?: string; code?: unknown } } | null; + const error = new Error(errorPayload?.error?.message ?? 'Request failed') as Error & { code?: unknown }; + error.code = errorPayload?.error?.code ?? response.status; throw error; } @@ -78,4 +79,3 @@ export async function fetchGalleryPhotos(token: string, cursor?: string | null, return handleResponse(response); } - diff --git a/resources/js/guest/services/helpApi.ts b/resources/js/guest/services/helpApi.ts index 07f188a..9e42085 100644 --- a/resources/js/guest/services/helpApi.ts +++ b/resources/js/guest/services/helpApi.ts @@ -98,8 +98,8 @@ async function requestJson(url: string): Promise { }); if (!response.ok) { - const error = new Error('Help request failed'); - (error as any).status = response.status; + const error = new Error('Help request failed') as Error & { status?: number }; + error.status = response.status; throw error; } diff --git a/resources/js/guest/services/photosApi.ts b/resources/js/guest/services/photosApi.ts index 9b7e710..20640ae 100644 --- a/resources/js/guest/services/photosApi.ts +++ b/resources/js/guest/services/photosApi.ts @@ -62,10 +62,12 @@ export async function likePhoto(id: number): Promise { }); if (!res.ok) { - let payload: any = null; + let payload: unknown = null; try { payload = await res.clone().json(); - } catch {} + } catch (error) { + console.warn('Like photo: failed to parse error payload', error); + } if (res.status === 419) { const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.'); @@ -75,12 +77,13 @@ export async function likePhoto(id: number): Promise { } const error: UploadError = new Error( - payload?.error?.message ?? `Like failed: ${res.status}` + (payload as { error?: { message?: string } } | null)?.error?.message ?? `Like failed: ${res.status}` ); - error.code = payload?.error?.code ?? 'like_failed'; + error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'like_failed'; error.status = res.status; - if (payload?.error?.meta) { - error.meta = payload.error.meta as Record; + const meta = (payload as { error?: { meta?: Record } } | null)?.error?.meta; + if (meta) { + error.meta = meta; } throw error; @@ -114,7 +117,7 @@ export async function uploadPhoto( const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`; const headers = getCsrfHeaders(); - const attemptUpload = (attempt: number): Promise => + const attemptUpload = (): Promise> => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', url, true); @@ -139,7 +142,7 @@ export async function uploadPhoto( xhr.onload = () => { const status = xhr.status; - const payload = xhr.response ?? null; + const payload = (xhr.response ?? null) as Record | null; if (status >= 200 && status < 300) { resolve(payload); @@ -147,12 +150,13 @@ export async function uploadPhoto( } const error: UploadError = new Error( - payload?.error?.message ?? `Upload failed: ${status}` + (payload as { error?: { message?: string } } | null)?.error?.message ?? `Upload failed: ${status}` ); - error.code = payload?.error?.code ?? (status === 0 ? 'network_error' : 'upload_failed'); + error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? (status === 0 ? 'network_error' : 'upload_failed'); error.status = status; - if (payload?.error?.meta) { - error.meta = payload.error.meta as Record; + const meta = (payload as { error?: { meta?: Record } } | null)?.error?.meta; + if (meta) { + error.meta = meta; } reject(error); }; @@ -174,8 +178,9 @@ export async function uploadPhoto( for (let attempt = 0; attempt <= maxRetries; attempt++) { try { - const json = await attemptUpload(attempt + 1); - return json?.photo_id ?? json?.id ?? json?.data?.id ?? 0; + const json = await attemptUpload(); + const payload = json as { photo_id?: number; id?: number; data?: { id?: number } }; + return payload.photo_id ?? payload.id ?? payload.data?.id ?? 0; } catch (error) { const err = error as UploadError; @@ -213,13 +218,16 @@ export async function createPhotoShareLink(eventToken: string, photoId: number): }); if (!res.ok) { - let payload: any = null; + let payload: unknown = null; try { payload = await res.clone().json(); - } catch {} + } catch (error) { + console.warn('Share link error payload parse failed', error); + } - const error: UploadError = new Error(payload?.error?.message ?? 'Share link creation failed'); - error.code = payload?.error?.code ?? 'share_failed'; + const errorPayload = payload as { error?: { message?: string; code?: string } } | null; + const error: UploadError = new Error(errorPayload?.error?.message ?? 'Share link creation failed'); + error.code = errorPayload?.error?.code ?? 'share_failed'; error.status = res.status; throw error; } diff --git a/resources/js/hooks/useLocale.ts b/resources/js/hooks/useLocale.ts index 8be7ae6..2a19f86 100644 --- a/resources/js/hooks/useLocale.ts +++ b/resources/js/hooks/useLocale.ts @@ -1,6 +1,7 @@ import { usePage } from '@inertiajs/react'; export const useLocale = () => { - const { locale } = usePage().props as any; + const { locale } = usePage<{ locale?: string }>().props; + return locale; -}; \ No newline at end of file +}; diff --git a/resources/js/hooks/useLocalizedRoutes.ts b/resources/js/hooks/useLocalizedRoutes.ts index 86d552e..5168d6d 100644 --- a/resources/js/hooks/useLocalizedRoutes.ts +++ b/resources/js/hooks/useLocalizedRoutes.ts @@ -4,9 +4,9 @@ import { useLocale } from './useLocale'; type LocalizedPathInput = string | null | undefined; export const useLocalizedRoutes = () => { - const page = usePage<{ supportedLocales?: string[] }>(); + const { props } = usePage<{ supportedLocales?: string[] }>(); const locale = useLocale(); - const supportedLocales = (page.props as any)?.supportedLocales ?? []; + const supportedLocales = props.supportedLocales ?? []; const fallbackLocale = (() => { if (locale && supportedLocales.includes(locale)) { diff --git a/resources/js/i18n.js b/resources/js/i18n.js index 4d83a1f..74c51bf 100644 --- a/resources/js/i18n.js +++ b/resources/js/i18n.js @@ -3,13 +3,15 @@ import { initReactI18next } from 'react-i18next'; import Backend from 'i18next-http-backend'; import LanguageDetector from 'i18next-browser-languagedetector'; +const isDev = typeof import.meta !== 'undefined' && Boolean(import.meta.env?.DEV); + i18n .use(Backend) .use(LanguageDetector) .use(initReactI18next) .init({ fallbackLng: 'de', - debug: process.env.NODE_ENV === 'development', + debug: isDev, interpolation: { escapeValue: false, }, diff --git a/resources/js/layouts/app/AppLayout.tsx b/resources/js/layouts/app/AppLayout.tsx index 2ba52d3..03f3946 100644 --- a/resources/js/layouts/app/AppLayout.tsx +++ b/resources/js/layouts/app/AppLayout.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { usePage } from '@inertiajs/react'; import Header from './Header'; import Footer from './Footer'; @@ -10,8 +9,6 @@ interface AppLayoutProps { } const AppLayout: React.FC = ({ children, header, footer }) => { - const { auth } = usePage().props; - return (
{header ||
} @@ -21,4 +18,4 @@ const AppLayout: React.FC = ({ children, header, footer }) => { ); }; -export default AppLayout; \ No newline at end of file +export default AppLayout; diff --git a/resources/js/layouts/app/Header.tsx b/resources/js/layouts/app/Header.tsx index d75130a..7c8a72e 100644 --- a/resources/js/layouts/app/Header.tsx +++ b/resources/js/layouts/app/Header.tsx @@ -26,7 +26,7 @@ import { } from '@/components/ui/navigation-menu'; const Header: React.FC = () => { - const { auth } = usePage().props as any; + const { auth } = usePage<{ auth: { user?: { name?: string; email?: string; avatar?: string } } }>().props; const { t } = useTranslation('auth'); const { appearance, updateAppearance } = useAppearance(); const { localizedPath } = useLocalizedRoutes(); @@ -77,7 +77,6 @@ const Header: React.FC = () => { const ctaHref = localizedPath('/demo'); const navItems = useMemo(() => { - const homeHref = localizedPath('/'); const howItWorksHref = localizedPath('/so-funktionierts'); return [ diff --git a/resources/js/layouts/mainWebsite.tsx b/resources/js/layouts/mainWebsite.tsx index 79fb75d..4b12d3e 100644 --- a/resources/js/layouts/mainWebsite.tsx +++ b/resources/js/layouts/mainWebsite.tsx @@ -7,25 +7,28 @@ import Footer from '@/layouts/app/Footer'; import { useAppearance } from '@/hooks/use-appearance'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; -import { MoreHorizontal, Sun, Moon, Languages, LayoutDashboard, LogOut, LogIn, UserPlus } from 'lucide-react'; +import { MoreHorizontal, Sun, Moon, Languages, LayoutDashboard, LogOut, LogIn } from 'lucide-react'; interface MarketingLayoutProps { children: React.ReactNode; title?: string; } +type PageProps = { + translations?: Record>; + locale?: string; + analytics?: { matomo?: MatomoConfig }; + supportedLocales?: string[]; + appUrl?: string; + auth?: { user?: { name?: string; email?: string } }; +}; + const MarketingLayout: React.FC = ({ children, title }) => { - const page = usePage<{ - translations?: Record>; - locale?: string; - analytics?: { matomo?: MatomoConfig }; - supportedLocales?: string[]; - appUrl?: string; - }>(); + const page = usePage(); const { url } = page; const { t } = useTranslation('marketing'); const i18n = useTranslation(); - const { locale, analytics, supportedLocales = ['de', 'en'], appUrl, auth } = page.props as any; + const { locale, analytics, supportedLocales = ['de', 'en'], appUrl, auth } = page.props; const user = auth?.user ?? null; const { localizedPath } = useLocalizedRoutes(); const { appearance, updateAppearance } = useAppearance(); diff --git a/resources/js/lib/coupons.ts b/resources/js/lib/coupons.ts index c259191..7f66e36 100644 --- a/resources/js/lib/coupons.ts +++ b/resources/js/lib/coupons.ts @@ -5,17 +5,19 @@ function extractErrorMessage(payload: unknown): string { return 'coupon_error_generic'; } - const data = payload as Record; + const data = payload as Record; - if (data.message && typeof data.message === 'string') { + if (typeof data.message === 'string') { return data.message; } - if (data.errors) { - const errors = data.errors as Record; + if (data.errors && typeof data.errors === 'object') { + const errors = data.errors as Record; const firstKey = Object.keys(errors)[0]; - if (firstKey && Array.isArray(errors[firstKey]) && errors[firstKey][0]) { - return errors[firstKey][0]; + const firstEntry = firstKey ? errors[firstKey] : undefined; + + if (Array.isArray(firstEntry) && typeof firstEntry[0] === 'string') { + return firstEntry[0]; } } diff --git a/resources/js/pages/Profile/Index.tsx b/resources/js/pages/Profile/Index.tsx index a03a107..2853216 100644 --- a/resources/js/pages/Profile/Index.tsx +++ b/resources/js/pages/Profile/Index.tsx @@ -133,7 +133,7 @@ export default function ProfileIndex() { try { return dateFormatter.format(new Date(userData.emailVerifiedAt)); - } catch (error) { + } catch { return null; } }, [userData.emailVerifiedAt, dateFormatter]); diff --git a/resources/js/pages/auth/LoginForm.tsx b/resources/js/pages/auth/LoginForm.tsx index 04d0387..769450f 100644 --- a/resources/js/pages/auth/LoginForm.tsx +++ b/resources/js/pages/auth/LoginForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; import { usePage } from "@inertiajs/react"; import { useTranslation } from "react-i18next"; import toast from "react-hot-toast"; @@ -31,8 +31,6 @@ type SharedPageProps = { type FieldErrors = Record; -const fallbackRoute = (locale: string) => `/${locale}/login`; - const csrfToken = () => (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? ""; export default function LoginForm({ onSuccess, canResetPassword = true, locale }: LoginFormProps) { diff --git a/resources/js/pages/auth/RegisterForm.tsx b/resources/js/pages/auth/RegisterForm.tsx index 8687e6f..d9157f6 100644 --- a/resources/js/pages/auth/RegisterForm.tsx +++ b/resources/js/pages/auth/RegisterForm.tsx @@ -6,10 +6,8 @@ import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import type { GoogleProfilePrefill } from '../marketing/checkout/types'; -declare const route: (name: string, params?: Record) => string; - export interface RegisterSuccessPayload { - user: any | null; + user: unknown | null; redirect?: string | null; pending_purchase?: boolean; } @@ -69,7 +67,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale const [serverError, setServerError] = useState(null); const [serverErrorType, setServerErrorType] = useState<'generic' | 'session-expired'>('generic'); const { t } = useTranslation(['auth', 'common']); - const page = usePage<{ errors: Record; locale?: string; auth?: { user?: any | null } }>(); + const page = usePage<{ errors: Record; locale?: string; auth?: { user?: unknown | null } }>(); const resolvedLocale = locale ?? page.props.locale ?? 'de'; const { data, setData, errors, clearErrors, reset, setError } = useForm({ @@ -245,7 +243,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale try { const json = await response.clone().json(); message = json?.message ?? null; - } catch (error) { + } catch { message = null; } diff --git a/resources/js/pages/auth/register.tsx b/resources/js/pages/auth/register.tsx index 4427d92..22d32d6 100644 --- a/resources/js/pages/auth/register.tsx +++ b/resources/js/pages/auth/register.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useForm } from '@inertiajs/react'; import { useTranslation } from 'react-i18next'; -import { LoaderCircle, User, Mail, Phone, Lock, Home, MapPin } from 'lucide-react'; +import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; interface RegisterProps { diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index f9be8c7..89ad930 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -110,7 +110,10 @@ export default function Dashboard() { const page = usePage(); const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale, onboarding } = page.props; const { auth, supportedLocales } = page.props; - const translations = (page.props.translations?.dashboard ?? {}) as Record; + const translations = useMemo( + () => (page.props.translations?.dashboard ?? {}) as Record, + [page.props.translations?.dashboard], + ); const [verificationSent, setVerificationSent] = useState(false); const [sendingVerification, setSendingVerification] = useState(false); @@ -285,7 +288,7 @@ export default function Dashboard() { try { return currencyFormatter.format(price); - } catch (error) { + } catch { return `${price.toFixed(2)} €`; } }; @@ -669,4 +672,4 @@ export default function Dashboard() { ); } -(Dashboard as any).layout = (page: ReactNode) => page; +Object.assign(Dashboard, { layout: (page: ReactNode) => page }); diff --git a/resources/js/pages/marketing/Blog.tsx b/resources/js/pages/marketing/Blog.tsx index e721d92..32ac0ab 100644 --- a/resources/js/pages/marketing/Blog.tsx +++ b/resources/js/pages/marketing/Blog.tsx @@ -50,7 +50,10 @@ const MarkdownPreview: React.FC<{ html?: string; fallback?: string; className?: const Blog: React.FC = ({ posts }) => { const { localizedPath } = useLocalizedRoutes(); const { props } = usePage<{ supportedLocales?: string[] }>(); - const supportedLocales = props.supportedLocales && props.supportedLocales.length > 0 ? props.supportedLocales : ['de', 'en']; + const supportedLocales = useMemo( + () => (props.supportedLocales && props.supportedLocales.length > 0 ? props.supportedLocales : ['de', 'en']), + [props.supportedLocales] + ); const { t, i18n } = useTranslation('marketing'); const locale = i18n.language || 'de'; const articles = posts?.data ?? []; @@ -116,7 +119,7 @@ const Blog: React.FC = ({ posts }) => { return localizedPath(raw); } }, - [localizedPath] + [localizedPath, supportedLocales] ); const renderPagination = () => { diff --git a/resources/js/pages/marketing/Kontakt.tsx b/resources/js/pages/marketing/Kontakt.tsx index b2da1c8..ef23226 100644 --- a/resources/js/pages/marketing/Kontakt.tsx +++ b/resources/js/pages/marketing/Kontakt.tsx @@ -11,7 +11,7 @@ const Kontakt: React.FC = () => { message: '', }); - const { flash } = usePage().props as any; + const { flash } = usePage<{ flash?: { success?: string } }>().props; const { t } = useTranslation('marketing'); const { localizedPath } = useLocalizedRoutes(); diff --git a/resources/js/pages/marketing/Packages.tsx b/resources/js/pages/marketing/Packages.tsx index 176784b..8067d58 100644 --- a/resources/js/pages/marketing/Packages.tsx +++ b/resources/js/pages/marketing/Packages.tsx @@ -1,22 +1,20 @@ import React, { useState, useEffect, useMemo, useRef, useLayoutEffect } from 'react'; -import { Head, Link, usePage } from '@inertiajs/react'; +import { Link } from '@inertiajs/react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Sheet, SheetContent } from '@/components/ui/sheet'; -import { Separator } from '@/components/ui/separator'; import { cn } from '@/lib/utils'; import MarketingLayout from '@/layouts/mainWebsite'; import { useAnalytics } from '@/hooks/useAnalytics'; import { useCtaExperiment } from '@/hooks/useCtaExperiment'; import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; -import { ArrowRight, ShoppingCart, Check, Users, Image, Shield, Star, Sparkles } from 'lucide-react'; +import { ArrowRight, Check, Shield, Star, Sparkles } from 'lucide-react'; interface Package { id: number; @@ -214,14 +212,11 @@ interface PackagesProps { const Packages: React.FC = ({ endcustomerPackages, resellerPackages }) => { const [open, setOpen] = useState(false); const [selectedPackage, setSelectedPackage] = useState(null); - const [currentStep, setCurrentStep] = useState<'overview' | 'testimonials'>('overview'); const [isMobile, setIsMobile] = useState(false); const dialogScrollRef = useRef(null); const dialogHeadingRef = useRef(null); const mobileEndcustomerRef = useRef(null); const mobileResellerRef = useRef(null); - const { props } = usePage(); - const { auth } = props as any; const { localizedPath } = useLocalizedRoutes(); const { t } = useTranslation('marketing'); const { t: tCommon } = useTranslation('common'); @@ -278,10 +273,18 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag const media = window.matchMedia('(max-width: 768px)'); const update = () => setIsMobile(media.matches); update(); - media.addEventListener ? media.addEventListener('change', update) : media.addListener(update); + if (media.addEventListener) { + media.addEventListener('change', update); + } else { + media.addListener(update); + } return () => { - media.removeEventListener ? media.removeEventListener('change', update) : media.removeListener(update); + if (media.removeEventListener) { + media.removeEventListener('change', update); + } else { + media.removeListener(update); + } }; }, []); @@ -403,7 +406,6 @@ function selectHighlightPackageId(packages: Package[]): number | null { value: pkg.price, }); setSelectedPackage(pkg); - setCurrentStep('overview'); setOpen(true); }; @@ -418,20 +420,6 @@ function selectHighlightPackageId(packages: Package[]): number | null { // nextStep entfernt, da Tabs nun parallel sind -const getFeatureIcon = (feature: string) => { - switch (feature) { - case 'basic_uploads': return ; - case 'unlimited_sharing': return ; - case 'no_watermark': return ; - case 'custom_tasks': return ; - case 'advanced_analytics': return ; - case 'priority_support': return ; - case 'reseller_dashboard': return ; - case 'custom_branding': return ; - default: return ; - } -}; - const getAccentTheme = (variant: 'endcustomer' | 'reseller') => variant === 'reseller' ? { diff --git a/resources/js/pages/marketing/Success.tsx b/resources/js/pages/marketing/Success.tsx index d50137a..76eaeb6 100644 --- a/resources/js/pages/marketing/Success.tsx +++ b/resources/js/pages/marketing/Success.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { usePage, router } from '@inertiajs/react'; -import { Head } from '@inertiajs/react'; import { useTranslation } from 'react-i18next'; import MarketingLayout from '@/layouts/mainWebsite'; import { Loader } from 'lucide-react'; @@ -8,7 +7,7 @@ import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import { ADMIN_HOME_PATH } from '@/admin/constants'; const Success: React.FC = () => { - const { auth } = usePage().props as any; + const { auth } = usePage<{ auth: { user?: { email_verified_at?: string | null } } }>().props; const { t } = useTranslation('success'); const { localizedPath } = useLocalizedRoutes(); diff --git a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx index e27cd7c..162500f 100644 --- a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx +++ b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx @@ -104,7 +104,10 @@ const WizardBody: React.FC<{ [t] ); - const currentIndex = useMemo(() => stepConfig.findIndex((step) => step.id === currentStep), [currentStep]); + const currentIndex = useMemo( + () => stepConfig.findIndex((step) => step.id === currentStep), + [currentStep, stepConfig] + ); const progress = useMemo(() => { if (currentIndex < 0) { return 0; diff --git a/resources/js/pages/marketing/checkout/WizardContext.tsx b/resources/js/pages/marketing/checkout/WizardContext.tsx index e395942..3b2dd70 100644 --- a/resources/js/pages/marketing/checkout/WizardContext.tsx +++ b/resources/js/pages/marketing/checkout/WizardContext.tsx @@ -5,7 +5,7 @@ interface CheckoutState { currentStep: CheckoutStepId; selectedPackage: CheckoutPackage | null; packageOptions: CheckoutPackage[]; - authUser: any; + authUser: unknown; isAuthenticated: boolean; paymentIntent: string | null; loading: boolean; @@ -19,7 +19,7 @@ interface CheckoutWizardContextType { packageOptions: CheckoutPackage[]; currentStep: CheckoutStepId; isAuthenticated: boolean; - authUser: any; + authUser: unknown; paddleConfig?: { environment?: string | null; client_token?: string | null; @@ -27,7 +27,7 @@ interface CheckoutWizardContextType { paymentCompleted: boolean; selectPackage: (pkg: CheckoutPackage) => void; setSelectedPackage: (pkg: CheckoutPackage) => void; - setAuthUser: (user: any) => void; + setAuthUser: (user: unknown) => void; nextStep: () => void; prevStep: () => void; previousStep: () => void; @@ -56,7 +56,7 @@ const initialState: CheckoutState = { type CheckoutAction = | { type: 'SELECT_PACKAGE'; payload: CheckoutPackage } - | { type: 'SET_AUTH_USER'; payload: any } + | { type: 'SET_AUTH_USER'; payload: unknown } | { type: 'NEXT_STEP' } | { type: 'PREV_STEP' } | { type: 'GO_TO_STEP'; payload: CheckoutStepId } @@ -72,19 +72,23 @@ function checkoutReducer(state: CheckoutState, action: CheckoutAction): Checkout case 'SET_AUTH_USER': return { ...state, authUser: action.payload, isAuthenticated: Boolean(action.payload) }; case 'NEXT_STEP': - const steps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation']; - const currentIndex = steps.indexOf(state.currentStep); - if (currentIndex < steps.length - 1) { - return { ...state, currentStep: steps[currentIndex + 1] }; + { + const steps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation']; + const currentIndex = steps.indexOf(state.currentStep); + if (currentIndex < steps.length - 1) { + return { ...state, currentStep: steps[currentIndex + 1] }; + } + return state; } - return state; case 'PREV_STEP': - const prevSteps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation']; - const prevIndex = prevSteps.indexOf(state.currentStep); - if (prevIndex > 0) { - return { ...state, currentStep: prevSteps[prevIndex - 1] }; + { + const prevSteps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation']; + const prevIndex = prevSteps.indexOf(state.currentStep); + if (prevIndex > 0) { + return { ...state, currentStep: prevSteps[prevIndex - 1] }; + } + return state; } - return state; case 'GO_TO_STEP': return { ...state, currentStep: action.payload }; case 'UPDATE_PAYMENT_INTENT': @@ -105,7 +109,7 @@ interface CheckoutWizardProviderProps { initialPackage?: CheckoutPackage; packageOptions?: CheckoutPackage[]; initialStep?: CheckoutStepId; - initialAuthUser?: any; + initialAuthUser?: unknown; initialIsAuthenticated?: boolean; paddle?: { environment?: string | null; @@ -173,7 +177,7 @@ export function CheckoutWizardProvider({ dispatch({ type: 'SELECT_PACKAGE', payload: pkg }); }, []); - const setAuthUser = useCallback((user: any) => { + const setAuthUser = useCallback((user: unknown) => { dispatch({ type: 'SET_AUTH_USER', payload: user }); }, []); diff --git a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx index 17935e2..558a647 100644 --- a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx @@ -39,7 +39,7 @@ export const AuthStep: React.FC = ({ privacyHtml, googleProfile, const page = usePage<{ locale?: string }>(); const locale = page.props.locale ?? "de"; const googleAuth = useMemo(() => { - const props = page.props as Record; + const props = page.props as { googleAuth?: GoogleAuthFlash }; return props.googleAuth ?? {}; }, [page.props]); const { isAuthenticated, authUser, setAuthUser, nextStep, selectedPackage } = useCheckoutWizard(); diff --git a/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx b/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx index ed299ff..dba4903 100644 --- a/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx @@ -8,10 +8,9 @@ import { cn } from "@/lib/utils"; interface ConfirmationStepProps { onViewProfile?: () => void; - onGoToAdmin?: () => void; } -export const ConfirmationStep: React.FC = ({ onViewProfile, onGoToAdmin }) => { +export const ConfirmationStep: React.FC = ({ onViewProfile }) => { const { t } = useTranslation('marketing'); const { selectedPackage } = useCheckoutWizard(); const handleProfile = React.useCallback(() => { @@ -22,14 +21,6 @@ export const ConfirmationStep: React.FC = ({ onViewProfil window.location.href = '/settings/profile'; }, [onViewProfile]); - const handleAdmin = React.useCallback(() => { - if (typeof onGoToAdmin === 'function') { - onGoToAdmin(); - return; - } - window.location.href = '/event-admin'; - }, [onGoToAdmin]); - const packageName = selectedPackage?.name ?? ''; const onboardingItems = [ diff --git a/resources/js/pages/marketing/checkout/steps/PackageStep.tsx b/resources/js/pages/marketing/checkout/steps/PackageStep.tsx index 5b0bb14..26947c9 100644 --- a/resources/js/pages/marketing/checkout/steps/PackageStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PackageStep.tsx @@ -149,17 +149,10 @@ export const PackageStep: React.FC = () => { const { t } = useTranslation('marketing'); const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState } = useCheckoutWizard(); - - // Early return if no package is selected - if (!selectedPackage) { - return ( -
-

{t('checkout.package_step.no_package_selected')}

-
- ); - } - const comparablePackages = useMemo(() => { + if (!selectedPackage) { + return []; + } // Filter by type and sort: free packages first, then by price ascending return packageOptions .filter((pkg: CheckoutPackage) => pkg.type === selectedPackage.type) @@ -180,6 +173,14 @@ export const PackageStep: React.FC = () => { resetPaymentState(); }; + if (!selectedPackage) { + return ( +
+

{t('checkout.package_step.no_package_selected')}

+
+ ); + } + return (
diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index a64b186..76c0742 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -139,7 +139,7 @@ export const PaymentStep: React.FC = () => { const [couponLoading, setCouponLoading] = useState(false); const paddleRef = useRef(null); const checkoutContainerRef = useRef(null); - const eventCallbackRef = useRef<(event: any) => void>(); + const eventCallbackRef = useRef<(event: Record) => void>(); const hasAutoAppliedCoupon = useRef(false); const checkoutContainerClass = 'paddle-checkout-container'; @@ -149,7 +149,6 @@ export const PaymentStep: React.FC = () => { }, [i18n.language]); const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]); - const hasCoupon = Boolean(couponPreview); const applyCoupon = useCallback(async (code: string) => { if (!selectedPackage) { @@ -339,7 +338,7 @@ export const PaymentStep: React.FC = () => { console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody }); } - let data: any = null; + let data: { checkout_url?: string; message?: string } | null = null; try { data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null; } catch (parseError) { @@ -354,7 +353,7 @@ export const PaymentStep: React.FC = () => { if (/^https?:\/\//i.test(trimmed)) { checkoutUrl = trimmed; } else if (trimmed.startsWith('<')) { - const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:\/?#\[\]@!$&'()*+,;=%-]+/); + const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:/?#@!$&'()*+,;=%-]+/); if (match) { checkoutUrl = match[0]; } @@ -444,7 +443,7 @@ export const PaymentStep: React.FC = () => { locale: paddleLocale, }, }, - eventCallback: (event: any) => eventCallbackRef.current?.(event), + eventCallback: (event: Record) => eventCallbackRef.current?.(event), }); inlineReady = true; diff --git a/resources/js/pages/settings/profile.tsx b/resources/js/pages/settings/profile.tsx index 3b427d3..5f58eb0 100644 --- a/resources/js/pages/settings/profile.tsx +++ b/resources/js/pages/settings/profile.tsx @@ -43,6 +43,23 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail: > {({ processing, recentlySuccessful, errors }) => ( <> +
+ + + + + +
+
@@ -66,7 +83,7 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail: {(supportedLocales ?? ['de', 'en']).map((l) => ( diff --git a/tests/ui/helpers/test-fixtures.ts b/tests/ui/helpers/test-fixtures.ts index 2b1a2ae..b80eb14 100644 --- a/tests/ui/helpers/test-fixtures.ts +++ b/tests/ui/helpers/test-fixtures.ts @@ -1,3 +1,4 @@ +/* eslint-disable react-hooks/rules-of-hooks */ import 'dotenv/config'; import { test as base, expect, Page, APIRequestContext, APIResponse } from '@playwright/test'; @@ -80,7 +81,7 @@ const tenantAdminEmail = process.env.E2E_TENANT_EMAIL ?? 'hello@lumen-moments.de const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD ?? 'Demo1234!'; export const test = base.extend({ - tenantAdminCredentials: async ({}, use) => { + tenantAdminCredentials: async (_context, use) => { if (!tenantAdminEmail || !tenantAdminPassword) { await use(null); return; diff --git a/tests/ui/purchase/homepage-links.test.ts b/tests/ui/purchase/homepage-links.test.ts index 5e8fae4..d2c0bb3 100644 --- a/tests/ui/purchase/homepage-links.test.ts +++ b/tests/ui/purchase/homepage-links.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Response, type ConsoleMessage } from '@playwright/test'; test.describe('Homepage Links Test', () => { test('Click all links on homepage and check for errors', async ({ page }) => { @@ -47,7 +47,7 @@ test.describe('Homepage Links Test', () => { // For each link, create temporary listeners const linkFailedRequests: { url: string; status: number }[] = []; const linkConsoleErrors: string[] = []; - const linkResponseHandler = (response: any) => { + const linkResponseHandler = (response: Response) => { if (response.status() >= 400) { linkFailedRequests.push({ url: response.url(), @@ -55,7 +55,7 @@ test.describe('Homepage Links Test', () => { }); } }; - const linkConsoleHandler = (msg: any) => { + const linkConsoleHandler = (msg: ConsoleMessage) => { if (msg.type() === 'error') { linkConsoleErrors.push(msg.text()); } @@ -103,4 +103,4 @@ test.describe('Homepage Links Test', () => { console.log('All links tested successfully.'); }); -}); \ No newline at end of file +}); diff --git a/tests/ui/purchase/package-flow.test.ts b/tests/ui/purchase/package-flow.test.ts index f8c5cf4..ae7ec6c 100644 --- a/tests/ui/purchase/package-flow.test.ts +++ b/tests/ui/purchase/package-flow.test.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { chromium } from 'playwright'; test.describe('Package Flow in Admin PWA', () => { test('Create event with package and verify limits', async ({ page }) => {