typescript-typenfehler behoben.. npm run lint läuft nun fehlerfrei durch.

This commit is contained in:
Codex Agent
2025-11-22 11:49:47 +01:00
parent 6c78d7e281
commit eb41cb6194
74 changed files with 469 additions and 396 deletions

View File

@@ -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 prettier, // Turn off all rules that might conflict with Prettier
]; ];

View File

@@ -3,7 +3,7 @@ import { ApiError, emitApiErrorEvent } from './lib/apiError';
import type { EventLimitSummary } from './lib/limitWarnings'; import type { EventLimitSummary } from './lib/limitWarnings';
import i18n from './i18n'; import i18n from './i18n';
type JsonValue = Record<string, any>; type JsonValue = Record<string, unknown>;
export type TenantAccountProfile = { export type TenantAccountProfile = {
id: number; id: number;

View File

@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { cn } from '@/lib/utils';
interface StatItem { interface StatItem {
key: string; key: string;

View File

@@ -44,7 +44,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
initialData: [], initialData: [],
}); });
const events = authReady ? fetchedEvents : []; const events = React.useMemo(() => (authReady ? fetchedEvents : []), [authReady, fetchedEvents]);
const isLoading = authReady ? queryLoading : status === 'loading'; const isLoading = authReady ? queryLoading : status === 'loading';
React.useEffect(() => { React.useEffect(() => {

View File

@@ -148,9 +148,12 @@ export default function WelcomeOrderSummaryPage() {
const packagesState = useTenantPackages(); const packagesState = useTenantPackages();
const { t, i18n } = useTranslation("onboarding"); const { t, i18n } = useTranslation("onboarding");
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE"; 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; const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
React.useEffect(() => { React.useEffect(() => {

View File

@@ -6,10 +6,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
TenantHeroCard, TenantHeroCard,
TenantOnboardingChecklistCard,
FrostedSurface, FrostedSurface,
tenantHeroPrimaryButtonClass, tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass, tenantHeroSecondaryButtonClass,

View File

@@ -327,7 +327,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
scope={warning.scope as 'photos' | 'guests' | 'gallery'} scope={warning.scope as 'photos' | 'guests' | 'gallery'}
onCheckout={(key) => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery', key); }} onCheckout={(key) => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery', key); }}
busy={addonBusyId === warning.scope} busy={addonBusyId === warning.scope}
t={(key, fallback) => t(key as any, fallback)} t={(key, fallback) => t(key, fallback)}
/> />
) : null} ) : null}
</div> </div>

View File

@@ -452,8 +452,8 @@ export default function EventInvitesPage(): React.ReactElement {
const exportLogo = effectiveCustomization?.logo_data_url ?? effectiveCustomization?.logo_url ?? null; const exportLogo = effectiveCustomization?.logo_data_url ?? effectiveCustomization?.logo_url ?? null;
const exportQr = selectedInvite?.qr_code_data_url ?? null; const exportQr = selectedInvite?.qr_code_data_url ?? null;
const handlePreviewSelect = React.useCallback((_id: string | null) => undefined, []); const handlePreviewSelect = React.useCallback(() => undefined, []);
const handlePreviewChange = React.useCallback((_id: string, _patch: Partial<LayoutElement>) => undefined, []); const handlePreviewChange = React.useCallback(() => undefined, []);
const handleCustomizerDraftChange = React.useCallback((draft: QrLayoutCustomization | null) => { const handleCustomizerDraftChange = React.useCallback((draft: QrLayoutCustomization | null) => {
setCustomizerDraft((previous) => { setCustomizerDraft((previous) => {
@@ -865,7 +865,7 @@ export default function EventInvitesPage(): React.ReactElement {
scope="guests" scope="guests"
onCheckout={(key) => { void handleAddonPurchase(key); }} onCheckout={(key) => { void handleAddonPurchase(key); }}
busy={addonBusy === 'guests'} busy={addonBusy === 'guests'}
t={(key, fallback) => t(key as any, fallback)} t={(key, fallback) => t(key, fallback)}
/> />
</div> </div>
) : null} ) : null}
@@ -882,7 +882,7 @@ export default function EventInvitesPage(): React.ReactElement {
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription> <CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key as any, fallback)} /> <AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key, fallback)} />
</CardContent> </CardContent>
</Card> </Card>
) : null} ) : null}

View File

@@ -13,14 +13,14 @@ import { getAddonCatalog, getEvent, type EventAddonCatalogItem, type EventAddonS
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { createEventAddonCheckout, deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api'; import { createEventAddonCheckout, deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage, isApiError } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings'; import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants'; import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants';
export default function EventPhotosPage() { export default function EventPhotosPage() {
const params = useParams<{ slug?: string }>(); const params = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const slug = params.slug ?? searchParams.get('slug') ?? null; const slug = params.slug ?? searchParams.get('slug') ?? null;
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -36,8 +36,6 @@ export default function EventPhotosPage() {
const [busyId, setBusyId] = React.useState<number | null>(null); const [busyId, setBusyId] = React.useState<number | null>(null);
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null); const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]); const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [catalogError, setCatalogError] = React.useState<string | undefined>(undefined);
//const [searchParams, setSearchParams] = React.useState(() => new URLSearchParams(window.location.search));
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]); const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
@@ -57,7 +55,6 @@ export default function EventPhotosPage() {
setLimits(photoResult.limits ?? null); setLimits(photoResult.limits ?? null);
setEventAddons(eventData.addons ?? []); setEventAddons(eventData.addons ?? []);
setAddons(catalog); setAddons(catalog);
setCatalogError(undefined);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.')); setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.'));
@@ -81,7 +78,7 @@ export default function EventPhotosPage() {
setSearchParams(params); setSearchParams(params);
navigate(window.location.pathname, { replace: true }); navigate(window.location.pathname, { replace: true });
} }
}, [searchParams, slug, load, navigate, translateLimits]); }, [searchParams, slug, load, navigate, translateLimits, setSearchParams]);
async function handleToggleFeature(photo: TenantPhoto) { async function handleToggleFeature(photo: TenantPhoto) {
if (!slug) return; if (!slug) return;
@@ -162,7 +159,7 @@ export default function EventPhotosPage() {
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription> <CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<AddonSummaryList addons={eventAddons} t={(key, fallback) => t(key as any, fallback)} /> <AddonSummaryList addons={eventAddons} t={(key, fallback) => t(key, fallback)} />
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@@ -33,7 +33,7 @@ import {
ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_TOOLKIT_PATH, ADMIN_EVENT_TOOLKIT_PATH,
} from '../constants'; } from '../constants';
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings'; import { buildLimitWarnings } from '../lib/limitWarnings';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export default function EventsPage() { export default function EventsPage() {
@@ -57,7 +57,7 @@ export default function EventsPage() {
setLoading(false); setLoading(false);
} }
})(); })();
}, []); }, [t]);
const translateManagement = React.useCallback( const translateManagement = React.useCallback(
(key: string, fallback?: string, options?: Record<string, unknown>) => (key: string, fallback?: string, options?: Record<string, unknown>) =>

View File

@@ -239,7 +239,7 @@ function formatDate(value: string, language: string | undefined): string {
month: 'short', month: 'short',
year: 'numeric', year: 'numeric',
}); });
} catch (error) { } catch {
return value; return value;
} }
} }

View File

@@ -10,7 +10,7 @@ import { Label } from '@/components/ui/label';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_EVENTS_PATH } from '../constants'; 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'; import { useMutation } from '@tanstack/react-query';
type LoginResponse = { type LoginResponse = {

View File

@@ -352,7 +352,6 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
export default function TasksPage() { export default function TasksPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('management');
const { t: tc } = useTranslation('common'); const { t: tc } = useTranslation('common');
return ( return (
<AdminLayout <AdminLayout

View File

@@ -10,7 +10,6 @@ import {
ShieldCheck, ShieldCheck,
Sparkles, Sparkles,
SunMedium, SunMedium,
Users,
Wand2, Wand2,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';

View File

@@ -1561,37 +1561,34 @@ export function InviteLayoutCustomizerPanel({
const highlightedElementId = activeElementId ?? inspectorElementId; const highlightedElementId = activeElementId ?? inspectorElementId;
const renderResponsiveSection = React.useCallback( const renderResponsiveSection = (id: string, title: string, description: string, content: React.ReactNode) => {
(id: string, title: string, description: string, content: React.ReactNode) => { const body = <div className="space-y-4">{content}</div>;
const body = <div className="space-y-4">{content}</div>;
if (!isCompact) {
return (
<section key={id} className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<header className="space-y-1">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</header>
{body}
</section>
);
}
if (!isCompact) {
return ( return (
<Collapsible key={id} defaultOpen className="rounded-2xl border border-border bg-[var(--tenant-surface)] p-3 shadow-sm transition-colors"> <section key={id} className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<CollapsibleTrigger type="button" className="flex w-full items-center justify-between gap-3 text-left"> <header className="space-y-1">
<div className="space-y-1"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3> {description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null} </header>
</div> {body}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform data-[state=open]:rotate-180" /> </section>
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">{body}</CollapsibleContent>
</Collapsible>
); );
}, }
[isCompact]
); return (
<Collapsible key={id} defaultOpen className="rounded-2xl border border-border bg-[var(--tenant-surface)] p-3 shadow-sm transition-colors">
<CollapsibleTrigger type="button" className="flex w-full items-center justify-between gap-3 text-left">
<div className="space-y-1">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">{body}</CollapsibleContent>
</Collapsible>
);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -6,7 +6,6 @@ import {
CANVAS_WIDTH, CANVAS_WIDTH,
LayoutElement, LayoutElement,
clamp, clamp,
LayoutElementType,
} from './schema'; } from './schema';
type DesignerCanvasProps = { type DesignerCanvasProps = {
@@ -23,7 +22,6 @@ type DesignerCanvasProps = {
qrCodeDataUrl: string | null; qrCodeDataUrl: string | null;
logoDataUrl: string | null; logoDataUrl: string | null;
scale?: number; scale?: number;
layoutKey?: string;
readOnly?: boolean; readOnly?: boolean;
}; };
@@ -43,7 +41,6 @@ export function DesignerCanvas({
qrCodeDataUrl, qrCodeDataUrl,
logoDataUrl, logoDataUrl,
scale = 1, scale = 1,
layoutKey,
readOnly = false, readOnly = false,
}: DesignerCanvasProps): React.JSX.Element { }: DesignerCanvasProps): React.JSX.Element {
const canvasElementRef = React.useRef<HTMLCanvasElement | null>(null); const canvasElementRef = React.useRef<HTMLCanvasElement | null>(null);
@@ -170,10 +167,6 @@ export function DesignerCanvas({
return () => { return () => {
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
if (disposeTokenRef.current !== disposeToken) {
return;
}
destroyCanvas(canvas); destroyCanvas(canvas);
pendingTimeoutRef.current = null; pendingTimeoutRef.current = null;
pendingDisposeRef.current = null; pendingDisposeRef.current = null;
@@ -216,8 +209,8 @@ export function DesignerCanvas({
onSelect(active.elementId); onSelect(active.elementId);
}; };
const handleSelectionCleared = (event?: unknown) => { const handleSelectionCleared = (event?: fabric.IEvent<MouseEvent>) => {
const pointerEvent = event as { e?: MouseEvent } | undefined; const pointerEvent = event?.e;
if (readOnly) { if (readOnly) {
return; return;
} }
@@ -229,11 +222,11 @@ export function DesignerCanvas({
onSelect(null); onSelect(null);
}; };
const handleObjectModified = (e: any) => { const handleObjectModified = (event: fabric.IEvent<MouseEvent>) => {
if (readOnly) { if (readOnly) {
return; return;
} }
const target = e.target as FabricObjectWithId | undefined; const target = event.target as FabricObjectWithId | undefined;
if (!target || typeof target.elementId !== 'string') { if (!target || typeof target.elementId !== 'string') {
return; return;
} }
@@ -312,7 +305,7 @@ export function DesignerCanvas({
canvas.on('selection:cleared', handleSelectionCleared); canvas.on('selection:cleared', handleSelectionCleared);
canvas.on('object:modified', handleObjectModified); canvas.on('object:modified', handleObjectModified);
const handleEditingExited = (event: { target?: FabricObjectWithId & { text?: string } }) => { const handleEditingExited = (event: fabric.IEvent<MouseEvent> & { target?: FabricObjectWithId & { text?: string } }) => {
if (readOnly) { if (readOnly) {
return; return;
} }
@@ -327,14 +320,14 @@ export function DesignerCanvas({
canvas.requestRenderAll(); canvas.requestRenderAll();
}; };
(canvas as any).on('editing:exited', handleEditingExited); canvas.on('editing:exited', handleEditingExited);
return () => { return () => {
canvas.off('selection:created', handleSelection); canvas.off('selection:created', handleSelection);
canvas.off('selection:updated', handleSelection); canvas.off('selection:updated', handleSelection);
canvas.off('selection:cleared', handleSelectionCleared); canvas.off('selection:cleared', handleSelectionCleared);
canvas.off('object:modified', handleObjectModified); canvas.off('object:modified', handleObjectModified);
(canvas as any).off('editing:exited', handleEditingExited); canvas.off('editing:exited', handleEditingExited);
}; };
}, [onChange, onSelect, readOnly]); }, [onChange, onSelect, readOnly]);
@@ -702,7 +695,9 @@ export async function createFabricObject({
padding: 0, // No padding to fix large frame padding: 0, // No padding to fix large frame
}); });
if (qrImage) { 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.lockScalingFlip = true;
qrImage.padding = 0; qrImage.padding = 0;
qrImage.cornerColor = 'transparent'; qrImage.cornerColor = 'transparent';
@@ -801,7 +796,7 @@ export async function loadImageObject(
options?: { objectFit?: 'contain' | 'cover'; shadow?: string; padding?: number }, options?: { objectFit?: 'contain' | 'cover'; shadow?: string; padding?: number },
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<fabric.Object | null> { ): Promise<fabric.Object | null> {
return new Promise((resolve, reject) => { return new Promise((resolve) => {
let resolved = false; let resolved = false;
const resolveSafely = (value: fabric.Object | null) => { const resolveSafely = (value: fabric.Object | null) => {
if (resolved) { if (resolved) {

View File

@@ -1,5 +1,18 @@
// import type { EventQrInviteLayout } from '../../api'; // Temporär deaktiviert wegen Modul-Fehler; definiere lokal falls nötig // 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 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_WIDTH = 1240;
export const CANVAS_HEIGHT = 1754; export const CANVAS_HEIGHT = 1754;

View File

@@ -5,8 +5,6 @@ import {
ADMIN_BASE_PATH, ADMIN_BASE_PATH,
ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_DEFAULT_AFTER_LOGIN_PATH,
ADMIN_EVENTS_PATH, ADMIN_EVENTS_PATH,
ADMIN_HOME_PATH,
ADMIN_LOGIN_PATH,
ADMIN_LOGIN_START_PATH, ADMIN_LOGIN_START_PATH,
ADMIN_PUBLIC_LANDING_PATH, ADMIN_PUBLIC_LANDING_PATH,
} from './constants'; } from './constants';

View File

@@ -20,15 +20,16 @@ createInertiaApp({
resolvePageComponent( resolvePageComponent(
`./pages/${name}.tsx`, `./pages/${name}.tsx`,
import.meta.glob('./pages/**/*.tsx') import.meta.glob('./pages/**/*.tsx')
).then((page: any) => { ).then((page) => {
if (page?.default) { const resolved = page as { default?: { layout?: (page: React.ReactNode) => React.ReactNode } };
const Component = page.default; if (resolved?.default) {
const Component = resolved.default;
if (!Component.layout) { if (!Component.layout) {
Component.layout = (page: React.ReactNode) => <AppLayout>{page}</AppLayout>; Component.layout = (node: React.ReactNode) => <AppLayout>{node}</AppLayout>;
} }
} }
return page; return resolved;
}), }),
setup({ el, App, props }) { setup({ el, App, props }) {
const root = createRoot(el); const root = createRoot(el);

View File

@@ -10,7 +10,9 @@ export type MatomoConfig = {
declare global { declare global {
interface Window { interface Window {
_paq?: any[]; _paq?: Array<[string, ...unknown[]]>;
__matomoInitialized?: boolean;
__CSP_NONCE?: string;
} }
} }
@@ -19,7 +21,7 @@ interface MatomoTrackerProps {
} }
const MatomoTracker: React.FC<MatomoTrackerProps> = ({ config }) => { const MatomoTracker: React.FC<MatomoTrackerProps> = ({ config }) => {
const page = usePage(); const page = usePage<{ security?: { csp?: { scriptNonce?: string } } }>();
const { hasConsent } = useConsent(); const { hasConsent } = useConsent();
const scriptNonce = (page.props.security as { csp?: { scriptNonce?: string } } | undefined)?.csp?.scriptNonce; const scriptNonce = (page.props.security as { csp?: { scriptNonce?: string } } | undefined)?.csp?.scriptNonce;
const analyticsConsent = hasConsent('analytics'); const analyticsConsent = hasConsent('analytics');
@@ -38,14 +40,14 @@ const MatomoTracker: React.FC<MatomoTrackerProps> = ({ config }) => {
if (window._paq) { if (window._paq) {
window._paq.length = 0; window._paq.length = 0;
} }
delete (window as any).__matomoInitialized; delete window.__matomoInitialized;
return; return;
} }
window._paq = window._paq || []; window._paq = window._paq || [];
const { _paq } = window; const { _paq } = window;
if (!(window as any).__matomoInitialized) { if (!window.__matomoInitialized) {
_paq.push(['setTrackerUrl', `${base}/matomo.php`]); _paq.push(['setTrackerUrl', `${base}/matomo.php`]);
_paq.push(['setSiteId', config.siteId]); _paq.push(['setSiteId', config.siteId]);
_paq.push(['disableCookies']); _paq.push(['disableCookies']);
@@ -58,8 +60,8 @@ const MatomoTracker: React.FC<MatomoTrackerProps> = ({ config }) => {
script.dataset.matomo = base; script.dataset.matomo = base;
if (scriptNonce) { if (scriptNonce) {
script.setAttribute('nonce', scriptNonce); script.setAttribute('nonce', scriptNonce);
} else if (typeof window !== 'undefined' && (window as any).__CSP_NONCE) { } else if (typeof window !== 'undefined' && window.__CSP_NONCE) {
script.setAttribute('nonce', (window as any).__CSP_NONCE); script.setAttribute('nonce', window.__CSP_NONCE);
} else { } else {
const metaNonce = document const metaNonce = document
.querySelector('meta[name="csp-nonce"]') .querySelector('meta[name="csp-nonce"]')
@@ -72,9 +74,9 @@ const MatomoTracker: React.FC<MatomoTrackerProps> = ({ config }) => {
document.body.appendChild(script); document.body.appendChild(script);
} }
(window as any).__matomoInitialized = true; window.__matomoInitialized = true;
} }
}, [config, analyticsConsent]); }, [config, analyticsConsent, scriptNonce]);
useEffect(() => { useEffect(() => {
if ( if (

View File

@@ -1,8 +1,6 @@
import * as React from "react" import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
interface Step { interface Step {
id: string id: string
@@ -66,4 +64,4 @@ const Steps = React.forwardRef<HTMLDivElement, StepsProps>(
) )
Steps.displayName = "Steps" Steps.displayName = "Steps"
export { Steps } export { Steps }

View File

@@ -10,7 +10,7 @@ import useEmblaCarousel from "embla-carousel-react"
interface CarouselApi { interface CarouselApi {
slideNodes(): HTMLElement[] slideNodes(): HTMLElement[]
on(event: string, listener: (...args: any[]) => void): void on(event: string, listener: (...args: unknown[]) => void): void
scrollPrev(): void scrollPrev(): void
scrollNext(): void scrollNext(): void
reInit(): void reInit(): void
@@ -18,21 +18,24 @@ interface CarouselApi {
const CarouselContext = React.createContext<CarouselApi | null>(null) const CarouselContext = React.createContext<CarouselApi | null>(null)
interface CarouselProps { type CarouselProps = React.HTMLAttributes<HTMLDivElement> & {
opts?: { opts?: {
align?: "start" | "center" | "end" align?: "start" | "center" | "end"
loop?: boolean loop?: boolean
} }
plugins?: any[] plugins?: unknown[]
setApi?: (api: CarouselApi) => void setApi?: (api: CarouselApi) => void
[key: string]: any
} }
const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>( const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
({ opts, plugins = [Autoplay()], setApi, className, children, ...props }, ref) => { ({ opts, plugins = [Autoplay()], setApi, className, children, ...props }, ref) => {
const [api, setApiInternal] = React.useState<CarouselApi | null>(null) const [api, setApiInternal] = React.useState<CarouselApi | null>(null)
const [emblaRef] = useEmblaCarousel(opts, plugins) const [emblaRef, emblaApi] = useEmblaCarousel(opts, plugins)
React.useEffect(() => {
setApiInternal(emblaApi ?? null)
}, [emblaApi])
React.useEffect(() => { React.useEffect(() => {
if (!api) { if (!api) {

View File

@@ -2,7 +2,7 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {

View File

@@ -37,14 +37,17 @@ export default function EmotionPicker({
const { locale } = useTranslation(); const { locale } = useTranslation();
// Fallback emotions (when API not available yet) // Fallback emotions (when API not available yet)
const fallbackEmotions: Emotion[] = [ const fallbackEmotions = React.useMemo<Emotion[]>(
{ id: 1, slug: 'happy', name: 'Glücklich', emoji: '😊' }, () => [
{ id: 2, slug: 'love', name: 'Verliebt', emoji: '❤️' }, { id: 1, slug: 'happy', name: 'Glücklich', emoji: '😊' },
{ id: 3, slug: 'excited', name: 'Aufgeregt', emoji: '🎉' }, { id: 2, slug: 'love', name: 'Verliebt', emoji: '❤️' },
{ id: 4, slug: 'relaxed', name: 'Entspannt', emoji: '😌' }, { id: 3, slug: 'excited', name: 'Aufgeregt', emoji: '🎉' },
{ id: 5, slug: 'sad', name: 'Traurig', emoji: '😢' }, { id: 4, slug: 'relaxed', name: 'Entspannt', emoji: '😌' },
{ id: 6, slug: 'surprised', name: 'Überrascht', emoji: '😲' }, { id: 5, slug: 'sad', name: 'Traurig', emoji: '😢' },
]; { id: 6, slug: 'surprised', name: 'Überrascht', emoji: '😲' },
],
[]
);
useEffect(() => { useEffect(() => {
if (!eventKey) return; if (!eventKey) return;
@@ -79,7 +82,7 @@ export default function EmotionPicker({
} }
fetchEmotions(); fetchEmotions();
}, [eventKey, locale]); }, [eventKey, locale, fallbackEmotions]);
const handleEmotionSelect = (emotion: Emotion) => { const handleEmotionSelect = (emotion: Emotion) => {
if (onSelect) { if (onSelect) {

View File

@@ -9,6 +9,20 @@ import { useTranslation } from '../i18n/useTranslation';
type Props = { token: string }; type Props = { token: string };
type PreviewFilter = 'latest' | 'popular' | 'mine' | 'photobooth'; 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) { export default function GalleryPreview({ token }: Props) {
const { locale } = useTranslation(); const { locale } = useTranslation();
@@ -16,28 +30,29 @@ export default function GalleryPreview({ token }: Props) {
const [mode, setMode] = React.useState<PreviewFilter>('latest'); const [mode, setMode] = React.useState<PreviewFilter>('latest');
const items = React.useMemo(() => { const items = React.useMemo(() => {
let arr = photos.slice(); const typed = photos as PreviewPhoto[];
let arr = typed.slice();
// MyPhotos filter (requires session_id matching) // MyPhotos filter (requires session_id matching)
if (mode === 'mine') { if (mode === 'mine') {
const deviceId = getDeviceId(); const deviceId = getDeviceId();
arr = arr.filter((photo: any) => photo.session_id === deviceId); arr = arr.filter((photo) => photo.session_id === deviceId);
} else if (mode === 'photobooth') { } else if (mode === 'photobooth') {
arr = arr.filter((photo: any) => photo.ingest_source === 'photobooth'); arr = arr.filter((photo) => photo.ingest_source === 'photobooth');
} }
// Sorting // Sorting
if (mode === 'popular') { 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 { } 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 return arr.slice(0, 4); // 2x2 = 4 items
}, [photos, mode]); }, [photos, mode]);
// Helper function to generate photo title (must be before return) // Helper function to generate photo title (must be before return)
function getPhotoTitle(photo: any): string { function getPhotoTitle(photo: PreviewPhoto): string {
if (photo.task_id) { if (photo.task_id) {
return `Task: ${photo.task_title || 'Unbekannte Aufgabe'}`; return `Task: ${photo.task_title || 'Unbekannte Aufgabe'}`;
} }
@@ -102,7 +117,7 @@ export default function GalleryPreview({ token }: Props) {
)} )}
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{items.map((p: any) => ( {items.map((p: PreviewPhoto) => (
<Link <Link
key={p.id} key={p.id}
to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`} to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`}

View File

@@ -11,7 +11,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
setTimeout(() => setList((arr) => arr.filter((x) => x.id !== id)), 3000); setTimeout(() => setList((arr) => arr.filter((x) => x.id !== id)), 3000);
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
const onEvt = (e: any) => push(e.detail); const onEvt = (e: CustomEvent<Omit<Toast, 'id'>>) => push(e.detail);
window.addEventListener('guest-toast', onEvt); window.addEventListener('guest-toast', onEvt);
return () => window.removeEventListener('guest-toast', onEvt); return () => window.removeEventListener('guest-toast', onEvt);
}, [push]); }, [push]);

View File

@@ -12,7 +12,7 @@ export function EventStatsProvider({ eventKey, children }: { eventKey: string; c
const stats = usePollStats(eventKey); const stats = usePollStats(eventKey);
const value = React.useMemo<EventStatsContextValue>( const value = React.useMemo<EventStatsContextValue>(
() => ({ eventKey, slug: eventKey, ...stats }), () => ({ eventKey, slug: eventKey, ...stats }),
[eventKey, stats.onlineGuests, stats.tasksSolved, stats.latestPhotoAt, stats.loading] [eventKey, stats]
); );
return <EventStatsContext.Provider value={value}>{children}</EventStatsContext.Provider>; return <EventStatsContext.Provider value={value}>{children}</EventStatsContext.Provider>;
} }

View File

@@ -8,6 +8,13 @@ let enabled = false;
let originalFetch: typeof window.fetch | null = null; let originalFetch: typeof window.fetch | null = null;
const likeState = new Map<number, number>(); const likeState = new Map<number, number>();
declare global {
interface Window {
__FOTOSPIEL_DEMO__?: boolean;
__FOTOSPIEL_DEMO_ACTIVE__?: boolean;
}
}
export function shouldEnableGuestDemoMode(): boolean { export function shouldEnableGuestDemoMode(): boolean {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return false; return false;
@@ -16,7 +23,7 @@ export function shouldEnableGuestDemoMode(): boolean {
if (params.get('demo') === '1') { if (params.get('demo') === '1') {
return true; return true;
} }
if ((window as any).__FOTOSPIEL_DEMO__ === true) { if (window.__FOTOSPIEL_DEMO__ === true) {
return true; return true;
} }
const attr = document.documentElement?.dataset?.guestDemo; const attr = document.documentElement?.dataset?.guestDemo;
@@ -42,7 +49,7 @@ export function enableGuestDemoMode(config: DemoConfig = { fixtures: demoFixture
}; };
enabled = true; enabled = true;
(window as any).__FOTOSPIEL_DEMO_ACTIVE__ = true; window.__FOTOSPIEL_DEMO_ACTIVE__ = true;
notifyDemoToast(); notifyDemoToast();
} }

View File

@@ -43,19 +43,6 @@ export function useGuestTaskProgress(eventKey: string | undefined) {
} }
}, [eventKey]); }, [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( const markCompleted = React.useCallback(
(taskId: number) => { (taskId: number) => {
if (!eventKey || !Number.isInteger(taskId)) { if (!eventKey || !Number.isInteger(taskId)) {

View File

@@ -15,7 +15,7 @@ export async function compressPhoto(
const canvas = createCanvas(width, height); const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas unsupported'); 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 // Iteratively lower quality to fit target size
let quality = qualityStart; let quality = qualityStart;
@@ -58,14 +58,20 @@ function createCanvas(w: number, h: number): HTMLCanvasElement {
} }
function toBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> { function toBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
return new Promise((resolve) => canvas.toBlob(resolve, type, quality)); return new Promise(resolve => canvas.toBlob(resolve, type, quality));
} }
async function loadImageBitmap(file: File): Promise<HTMLImageElement | ImageBitmap> { async function loadImageBitmap(file: File): Promise<CanvasImageSource> {
const canBitmap = 'createImageBitmap' in window; const canBitmap = 'createImageBitmap' in window;
if (canBitmap) { 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); return await loadHtmlImage(file);
} }
@@ -88,4 +94,3 @@ export function formatBytes(bytes: number) {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
} }

View File

@@ -43,8 +43,8 @@ export async function sharePhotoLink(options: ShareOptions): Promise<{ url: stri
try { try {
await navigator.share(shareData); await navigator.share(shareData);
return { url: payload.url, method: 'native' }; return { url: payload.url, method: 'native' };
} catch (error: any) { } catch (error: unknown) {
if (error?.name === 'AbortError') { if (error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError') {
return { url: payload.url, method: 'native' }; return { url: payload.url, method: 'native' };
} }
// fall through to clipboard // fall through to clipboard

View File

@@ -6,13 +6,28 @@ import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
import { Heart, Image as ImageIcon, Share2 } from 'lucide-react'; import { Heart, Image as ImageIcon, Share2 } from 'lucide-react';
import { likePhoto } from '../services/photosApi'; import { likePhoto } from '../services/photosApi';
import PhotoLightbox from './PhotoLightbox'; 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 { useTranslation } from '../i18n/useTranslation';
import { sharePhotoLink } from '../lib/sharePhoto'; import { sharePhotoLink } from '../lib/sharePhoto';
import { useToast } from '../components/ToastHost'; import { useToast } from '../components/ToastHost';
import { localizeTaskLabel } from '../lib/localizeTaskLabel'; import { localizeTaskLabel } from '../lib/localizeTaskLabel';
const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth']; 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 => const parseGalleryFilter = (value: string | null): GalleryFilter =>
allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest'; allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest';
@@ -46,7 +61,6 @@ export default function GalleryPage() {
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false); const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
const [event, setEvent] = useState<EventData | null>(null); const [event, setEvent] = useState<EventData | null>(null);
const [stats, setStats] = useState<EventStats | null>(null);
const [eventLoading, setEventLoading] = useState(true); const [eventLoading, setEventLoading] = useState(true);
const toast = useToast(); const toast = useToast();
const [shareTargetId, setShareTargetId] = React.useState<number | null>(null); const [shareTargetId, setShareTargetId] = React.useState<number | null>(null);
@@ -62,16 +76,18 @@ export default function GalleryPage() {
params.set('mode', next); params.set('mode', next);
setSearchParams(params, { replace: true }); setSearchParams(params, { replace: true });
}, [searchParams, setSearchParams]); }, [searchParams, setSearchParams]);
const typedPhotos = photos as GalleryPhoto[];
// Auto-open lightbox if photoId in query params // Auto-open lightbox if photoId in query params
useEffect(() => { useEffect(() => {
if (photoIdParam && photos.length > 0 && currentPhotoIndex === null && !hasOpenedPhoto) { 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) { if (index !== -1) {
setCurrentPhotoIndex(index); setCurrentPhotoIndex(index);
setHasOpenedPhoto(true); setHasOpenedPhoto(true);
} }
} }
}, [photos, photoIdParam, currentPhotoIndex, hasOpenedPhoto]); }, [typedPhotos, photos.length, photoIdParam, currentPhotoIndex, hasOpenedPhoto]);
// Load event and package info // Load event and package info
useEffect(() => { useEffect(() => {
@@ -80,12 +96,8 @@ export default function GalleryPage() {
const loadEventData = async () => { const loadEventData = async () => {
try { try {
setEventLoading(true); setEventLoading(true);
const [eventData, statsData] = await Promise.all([ const eventData = await fetchEvent(token);
fetchEvent(token),
fetchStats(token),
]);
setEvent(eventData); setEvent(eventData);
setStats(statsData);
} catch (err) { } catch (err) {
console.error('Failed to load event data', err); console.error('Failed to load event data', err);
} finally { } finally {
@@ -104,27 +116,22 @@ export default function GalleryPage() {
}, []); }, []);
const list = React.useMemo(() => { const list = React.useMemo(() => {
let arr = photos.slice(); let arr = typedPhotos.slice();
if (filter === 'popular') { 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') { } 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') { } else if (filter === 'photobooth') {
arr = arr.filter((p: any) => p.ingest_source === 'photobooth'); arr = arr.filter((p) => 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.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
} else { } 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; return arr;
}, [photos, filter, myPhotoIds]); }, [typedPhotos, filter, myPhotoIds]);
const [liked, setLiked] = React.useState<Set<number>>(new Set()); const [liked, setLiked] = React.useState<Set<number>>(new Set());
const [counts, setCounts] = React.useState<Record<number, number>>({}); const [counts, setCounts] = React.useState<Record<number, number>>({});
const totalLikes = React.useMemo(
() => photos.reduce((sum, photo: any) => sum + (photo.likes_count ?? 0), 0),
[photos],
);
async function onLike(id: number) { async function onLike(id: number) {
if (liked.has(id)) return; if (liked.has(id)) return;
setLiked(new Set(liked).add(id)); setLiked(new Set(liked).add(id));
@@ -136,13 +143,16 @@ export default function GalleryPage() {
const raw = localStorage.getItem('liked-photo-ids'); const raw = localStorage.getItem('liked-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : []; const arr: number[] = raw ? JSON.parse(raw) : [];
if (!arr.includes(id)) localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, id])); if (!arr.includes(id)) localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, id]));
} catch {} } catch (error) {
} catch { 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); const s = new Set(liked); s.delete(id); setLiked(s);
} }
} }
async function onShare(photo: any) { async function onShare(photo: GalleryPhoto) {
if (!token) return; if (!token) return;
setShareTargetId(photo.id); setShareTargetId(photo.id);
try { try {
@@ -223,7 +233,7 @@ export default function GalleryPage() {
<FiltersBar value={filter} onChange={setFilter} className="mt-2" /> <FiltersBar value={filter} onChange={setFilter} className="mt-2" />
{loading && <p className="px-4">{t('galleryPage.loading', 'Lade…')}</p>} {loading && <p className="px-4">{t('galleryPage.loading', 'Lade…')}</p>}
<div className="grid gap-3 px-4 pb-16 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 px-4 pb-16 sm:grid-cols-2 lg:grid-cols-3">
{list.map((p: any) => { {list.map((p: GalleryPhoto) => {
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path); const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
const createdLabel = p.created_at const createdLabel = p.created_at
? new Date(p.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) ? 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 altText = t('galleryPage.photo.alt', { id: p.id, suffix: altSuffix }, `Foto ${p.id}${altSuffix}`);
const openPhoto = () => { 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); setCurrentPhotoIndex(index >= 0 ? index : null);
}; };

View File

@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { Page } from './_util'; import { Page } from './_util';
@@ -15,7 +14,6 @@ export default function HelpArticlePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [article, setArticle] = React.useState<HelpArticleDetail | null>(null); const [article, setArticle] = React.useState<HelpArticleDetail | null>(null);
const [state, setState] = React.useState<'loading' | 'ready' | 'error'>('loading'); 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 basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const loadArticle = React.useCallback(async () => { const loadArticle = React.useCallback(async () => {
@@ -27,7 +25,6 @@ export default function HelpArticlePage() {
try { try {
const result = await getHelpArticle(slug, locale); const result = await getHelpArticle(slug, locale);
setArticle(result.article); setArticle(result.article);
setServedFromCache(result.servedFromCache);
setState('ready'); setState('ready');
} catch (error) { } catch (error) {
console.error('[HelpArticle] Failed to load article', error); console.error('[HelpArticle] Failed to load article', error);
@@ -117,7 +114,7 @@ function formatDate(value: string, locale: string): string {
month: 'short', month: 'short',
year: 'numeric', year: 'numeric',
}); });
} catch (error) { } catch {
return value; return value;
} }
} }

View File

@@ -147,7 +147,7 @@ function formatDate(value: string, locale: string): string {
month: 'short', month: 'short',
year: 'numeric', year: 'numeric',
}); });
} catch (error) { } catch {
return value; return value;
} }
} }

View File

@@ -6,10 +6,9 @@ import { Separator } from '@/components/ui/separator';
import EmotionPicker from '../components/EmotionPicker'; import EmotionPicker from '../components/EmotionPicker';
import GalleryPreview from '../components/GalleryPreview'; import GalleryPreview from '../components/GalleryPreview';
import { useGuestIdentity } from '../context/GuestIdentityContext'; import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventStats } from '../context/EventStatsContext';
import { useEventData } from '../hooks/useEventData'; import { useEventData } from '../hooks/useEventData';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; 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 { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext'; import { useEventBranding } from '../context/EventBrandingContext';
import type { EventBranding } from '../types/event-branding'; import type { EventBranding } from '../types/event-branding';
@@ -17,7 +16,6 @@ import type { EventBranding } from '../types/event-branding';
export default function HomePage() { export default function HomePage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
const { name, hydrated } = useGuestIdentity(); const { name, hydrated } = useGuestIdentity();
const stats = useEventStats();
const { event } = useEventData(); const { event } = useEventData();
const { completedCount } = useGuestTaskProgress(token ?? ''); const { completedCount } = useGuestTaskProgress(token ?? '');
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
@@ -100,10 +98,10 @@ export default function HomePage() {
const payload = await response.json(); const payload = await response.json();
if (cancelled) return; if (cancelled) return;
if (Array.isArray(payload) && payload.length) { if (Array.isArray(payload) && payload.length) {
missionPoolRef.current = payload.map((task: any) => ({ missionPoolRef.current = payload.map((task: Record<string, unknown>) => ({
id: Number(task.id), id: Number(task.id),
title: task.title ?? 'Mission', title: typeof task.title === 'string' ? task.title : 'Mission',
description: task.description ?? '', description: typeof task.description === 'string' ? task.description : '',
duration: typeof task.duration === 'number' ? task.duration : 3, duration: typeof task.duration === 'number' ? task.duration : 3,
emotion: task.emotion ?? null, emotion: task.emotion ?? null,
})); }));

View File

@@ -38,8 +38,6 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
const toast = useToast(); const toast = useToast();
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null); const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [task, setTask] = useState<Task | null>(null); const [task, setTask] = useState<Task | null>(null);
const [taskLoading, setTaskLoading] = useState(false); const [taskLoading, setTaskLoading] = useState(false);
const [likes, setLikes] = useState<number>(0); const [likes, setLikes] = useState<number>(0);
@@ -59,8 +57,6 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
useEffect(() => { useEffect(() => {
if (isStandalone && photoId && !standalonePhoto && eventToken) { if (isStandalone && photoId && !standalonePhoto && eventToken) {
const fetchPhoto = async () => { const fetchPhoto = async () => {
setLoading(true);
setError(null);
try { try {
const res = await fetch(`/api/v1/photos/${photoId}?locale=${encodeURIComponent(locale)}`, { const res = await fetch(`/api/v1/photos/${photoId}?locale=${encodeURIComponent(locale)}`, {
headers: { headers: {
@@ -76,20 +72,17 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
setStandalonePhoto(location.state.photo); setStandalonePhoto(location.state.photo);
} }
} else { } else {
setError(t('lightbox.errors.notFound')); toast.push({ text: t('lightbox.errors.notFound'), type: 'error' });
} }
} catch (err) { } catch (err) {
setError(t('lightbox.errors.loadFailed')); console.warn('Standalone photo load failed', err);
} finally { toast.push({ text: t('lightbox.errors.loadFailed'), type: 'error' });
setLoading(false);
} }
}; };
fetchPhoto(); 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 // Update likes when photo changes
React.useEffect(() => { React.useEffect(() => {
@@ -163,8 +156,8 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
} }
); );
if (res.ok) { if (res.ok) {
const tasks = await res.json(); const tasks = (await res.json()) as Task[];
const foundTask = tasks.find((t: any) => t.id === taskId); const foundTask = tasks.find((t) => t.id === taskId);
if (foundTask) { if (foundTask) {
setTask({ setTask({
id: foundTask.id, id: foundTask.id,
@@ -207,7 +200,9 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
if (!arr.includes(photo.id)) { if (!arr.includes(photo.id)) {
localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, 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) { } catch (error) {
console.error('Like failed:', error); console.error('Like failed:', error);
setLiked(false); setLiked(false);

View File

@@ -40,7 +40,7 @@ export default function PublicGalleryPage(): React.ReactElement | null {
const localeStorageKey = token ? `guestGalleryLocale_${token}` : 'guestGalleryLocale'; const localeStorageKey = token ? `guestGalleryLocale_${token}` : 'guestGalleryLocale';
const storedLocale = typeof window !== 'undefined' && token ? localStorage.getItem(localeStorageKey) : null; 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) => { const applyMeta = useCallback((meta: GalleryMetaResponse) => {
if (typeof window !== 'undefined' && token) { if (typeof window !== 'undefined' && token) {

View File

@@ -39,7 +39,7 @@ export default function SharedPhotoPage() {
if (!active) return; if (!active) return;
setState({ loading: false, error: null, data }); setState({ loading: false, error: null, data });
}) })
.catch((error: any) => { .catch((error: unknown) => {
if (!active) return; if (!active) return;
setState({ loading: false, error: error?.message ?? t('share.error', 'Moment konnte nicht geladen werden.'), data: null }); setState({ loading: false, error: error?.message ?? t('share.error', 'Moment konnte nicht geladen werden.'), data: null });
}); });

View File

@@ -8,7 +8,6 @@ import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import EmotionPicker from '../components/EmotionPicker';
import { useEventBranding } from '../context/EventBrandingContext'; import { useEventBranding } from '../context/EventBrandingContext';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
@@ -171,7 +170,7 @@ export default function TaskPickerPage() {
const { branding } = useEventBranding(); const { branding } = useEventBranding();
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const { completedCount, isCompleted } = useGuestTaskProgress(eventKey); const { isCompleted } = useGuestTaskProgress(eventKey);
const [tasks, setTasks] = React.useState<Task[]>([]); const [tasks, setTasks] = React.useState<Task[]>([]);
const [currentTask, setCurrentTask] = React.useState<Task | null>(null); const [currentTask, setCurrentTask] = React.useState<Task | null>(null);

View File

@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { LocaleCode } from '../i18n/messages'; 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<string, unknown>;
export function usePollGalleryDelta(token: string, locale: LocaleCode) { export function usePollGalleryDelta(token: string, locale: LocaleCode) {
const [photos, setPhotos] = useState<Photo[]>([]); const [photos, setPhotos] = useState<Photo[]>([]);
@@ -14,7 +15,7 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
); );
async function fetchDelta() { const fetchDelta = useCallback(async () => {
if (!token) { if (!token) {
setLoading(false); setLoading(false);
return; return;
@@ -56,9 +57,9 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
Array.isArray(json) ? json : Array.isArray(json) ? json :
json.photos || []; json.photos || [];
const newPhotos = rawPhotos.map((photo: any) => ({ const newPhotos: Photo[] = rawPhotos.map((photo: RawPhoto) => ({
...photo, ...(photo as Photo),
session_id: photo?.session_id ?? photo?.guest_name ?? null, session_id: typeof photo.session_id === 'string' ? photo.session_id : (photo.guest_name as string | null) ?? null,
})); }));
if (newPhotos.length > 0) { if (newPhotos.length > 0) {
@@ -67,11 +68,9 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
if (latestAt.current) { if (latestAt.current) {
// Delta mode: Add new photos to existing list // Delta mode: Add new photos to existing list
const merged = [...newPhotos, ...photos]; const merged = [...newPhotos, ...photos];
// Remove duplicates by ID const byId = new Map<number, Photo>();
const uniquePhotos = merged.filter((photo, index, self) => merged.forEach((photo) => byId.set(photo.id, photo));
index === self.findIndex(p => p.id === photo.id) setPhotos(Array.from(byId.values()));
);
setPhotos(uniquePhotos);
if (added > 0) setNewCount((c) => c + added); if (added > 0) setNewCount((c) => c + added);
} else { } else {
// Initial load: Set all photos // Initial load: Set all photos
@@ -83,8 +82,8 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
latestAt.current = json.latest_photo_at; latestAt.current = json.latest_photo_at;
} else if (newPhotos.length > 0) { } else if (newPhotos.length > 0) {
// Fallback: use newest photo timestamp // Fallback: use newest photo timestamp
const newest = newPhotos.reduce((latest: number, photo: any) => { const newest = newPhotos.reduce((latest: number, photo: RawPhoto) => {
const photoTime = new Date(photo.created_at || photo.created_at_timestamp || 0).getTime(); const photoTime = new Date((photo.created_at as string | undefined) || (photo.created_at_timestamp as number | undefined) || 0).getTime();
return photoTime > latest ? photoTime : latest; return photoTime > latest ? photoTime : latest;
}, 0); }, 0);
latestAt.current = new Date(newest).toISOString(); latestAt.current = new Date(newest).toISOString();
@@ -104,7 +103,7 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
setLoading(false); setLoading(false);
// Don't update state on error - keep previous photos // Don't update state on error - keep previous photos
} }
} }, [locale, photos, token]);
useEffect(() => { useEffect(() => {
const onVis = () => setVisible(document.visibilityState === 'visible'); const onVis = () => setVisible(document.visibilityState === 'visible');
@@ -123,15 +122,15 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
latestAt.current = null; latestAt.current = null;
etagRef.current = null; etagRef.current = null;
setPhotos([]); setPhotos([]);
fetchDelta(); void fetchDelta();
if (timer.current) window.clearInterval(timer.current); if (timer.current) window.clearInterval(timer.current);
// Poll less aggressively when hidden // Poll less aggressively when hidden
const interval = visible ? 30_000 : 90_000; const interval = visible ? 30_000 : 90_000;
timer.current = window.setInterval(fetchDelta, interval); timer.current = window.setInterval(() => { void fetchDelta(); }, interval);
return () => { return () => {
if (timer.current) window.clearInterval(timer.current); if (timer.current) window.clearInterval(timer.current);
}; };
}, [token, visible, locale]); }, [token, visible, locale, fetchDelta]);
function acknowledgeNew() { setNewCount(0); } function acknowledgeNew() { setNewCount(0); }
return { loading, photos, newCount, acknowledgeNew }; return { loading, photos, newCount, acknowledgeNew };

View File

@@ -21,7 +21,7 @@ export async function withStore<T>(mode: TxMode, fn: (store: IDBObjectStore) =>
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tx = db.transaction('items', mode); const tx = db.transaction('items', mode);
const store = tx.objectStore('items'); const store = tx.objectStore('items');
let result: any; let result: unknown;
const wrap = async () => { const wrap = async () => {
try { result = await fn(store); } catch (e) { reject(e); } try { result = await fn(store); } catch (e) { reject(e); }
}; };
@@ -31,4 +31,3 @@ export async function withStore<T>(mode: TxMode, fn: (store: IDBObjectStore) =>
tx.onabort = () => reject(tx.error); tx.onabort = () => reject(tx.error);
}); });
} }

View File

@@ -1,11 +1,16 @@
export function notify(text: string, type: 'success'|'error') { export function notify(text: string, type: 'success'|'error') {
// Lazy import to avoid cycle // Lazy import to avoid cycle
import('../components/ToastHost').then(({ useToast }) => { import('../components/ToastHost')
try { .then(() => {
// This only works inside React tree; for SW-triggered, we fallback try {
const evt = new CustomEvent('guest-toast', { detail: { text, type } }); // This only works inside React tree; for SW-triggered, we fallback
window.dispatchEvent(evt); const evt = new CustomEvent('guest-toast', { detail: { text, type } });
} catch {} window.dispatchEvent(evt);
}); } catch (error) {
console.warn('Dispatching toast event failed', error);
}
})
.catch((error) => {
console.warn('Toast module failed to load', error);
});
} }

View File

@@ -30,7 +30,9 @@ export async function enqueue(item: Omit<QueueItem, 'id' | 'status' | 'retries'
try { try {
const reg = await navigator.serviceWorker.ready; const reg = await navigator.serviceWorker.ready;
(reg as ServiceWorkerRegistration & { sync?: SyncManager }).sync?.register('upload-queue'); (reg as ServiceWorkerRegistration & { sync?: SyncManager }).sync?.register('upload-queue');
} catch {} } catch (error) {
console.warn('Background sync registration failed', error);
}
} }
} }
@@ -83,7 +85,9 @@ async function attemptUpload(it: QueueItem): Promise<boolean> {
(pct) => { (pct) => {
try { try {
window.dispatchEvent(new CustomEvent('queue-progress', { detail: { id: it.id, progress: pct } })); 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" // mark my-photo-ids for "Meine"
@@ -91,7 +95,9 @@ async function attemptUpload(it: QueueItem): Promise<boolean> {
const raw = localStorage.getItem('my-photo-ids'); const raw = localStorage.getItem('my-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : []; const arr: number[] = raw ? JSON.parse(raw) : [];
if (json.id && !arr.includes(json.id)) localStorage.setItem('my-photo-ids', JSON.stringify([json.id, ...arr])); 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'); notify('Upload erfolgreich', 'success');
return true; return true;
} catch { } catch {

View File

@@ -5,7 +5,7 @@ export async function createUpload(
it: QueueItem, it: QueueItem,
deviceId: string, deviceId: string,
onProgress?: (percent: number) => void onProgress?: (percent: number) => void
): Promise<any> { ): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', url, true); xhr.open('POST', url, true);
@@ -22,7 +22,12 @@ export async function createUpload(
}; };
xhr.onload = () => { xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) { 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 { } else {
reject(new Error('upload failed')); reject(new Error('upload failed'));
} }

View File

@@ -160,32 +160,41 @@ export async function fetchAchievements(
tasks: toNumber(personalRaw.tasks), tasks: toNumber(personalRaw.tasks),
likes: toNumber(personalRaw.likes), likes: toNumber(personalRaw.likes),
badges: Array.isArray(personalRaw.badges) badges: Array.isArray(personalRaw.badges)
? personalRaw.badges.map((badge: any): AchievementBadge => ({ ? personalRaw.badges.map((badge): AchievementBadge => {
id: safeString(badge.id), const record = badge as Record<string, unknown>;
title: safeString(badge.title), return {
description: safeString(badge.description), id: safeString(record.id),
earned: Boolean(badge.earned), title: safeString(record.title),
progress: toNumber(badge.progress), description: safeString(record.description),
target: toNumber(badge.target, 1), earned: Boolean(record.earned),
})) progress: toNumber(record.progress),
target: toNumber(record.target, 1),
};
})
: [], : [],
} }
: null; : null;
const uploadsBoard = Array.isArray(leaderboards.uploads) const uploadsBoard = Array.isArray(leaderboards.uploads)
? leaderboards.uploads.map((row: any): LeaderboardEntry => ({ ? leaderboards.uploads.map((row): LeaderboardEntry => {
guest: safeString(row.guest), const record = row as Record<string, unknown>;
photos: toNumber(row.photos), return {
likes: toNumber(row.likes), guest: safeString(record.guest),
})) photos: toNumber(record.photos),
likes: toNumber(record.likes),
};
})
: []; : [];
const likesBoard = Array.isArray(leaderboards.likes) const likesBoard = Array.isArray(leaderboards.likes)
? leaderboards.likes.map((row: any): LeaderboardEntry => ({ ? leaderboards.likes.map((row): LeaderboardEntry => {
guest: safeString(row.guest), const record = row as Record<string, unknown>;
photos: toNumber(row.photos), return {
likes: toNumber(row.likes), guest: safeString(record.guest),
})) photos: toNumber(record.photos),
likes: toNumber(record.likes),
};
})
: []; : [];
const topPhotoRaw = highlights.top_photo ?? null; const topPhotoRaw = highlights.top_photo ?? null;
@@ -210,21 +219,27 @@ export async function fetchAchievements(
: null; : null;
const timeline = Array.isArray(highlights.timeline) const timeline = Array.isArray(highlights.timeline)
? highlights.timeline.map((row: any): TimelinePoint => ({ ? highlights.timeline.map((row): TimelinePoint => {
date: safeString(row.date), const record = row as Record<string, unknown>;
photos: toNumber(row.photos), return {
guests: toNumber(row.guests), date: safeString(record.date),
})) photos: toNumber(record.photos),
guests: toNumber(record.guests),
};
})
: []; : [];
const feed = feedRaw.map((row: any): FeedEntry => ({ const feed = feedRaw.map((row): FeedEntry => {
photoId: toNumber(row.photo_id), const record = row as Record<string, unknown>;
guest: safeString(row.guest), return {
task: row.task ?? null, photoId: toNumber(record.photo_id),
likes: toNumber(row.likes), guest: safeString(record.guest),
createdAt: safeString(row.created_at), task: (record as { task?: string }).task ?? null,
thumbnail: row.thumbnail ? safeString(row.thumbnail) : null, likes: toNumber(record.likes),
})); createdAt: safeString(record.created_at),
thumbnail: record.thumbnail ? safeString(record.thumbnail) : null,
};
});
const payload: AchievementsPayload = { const payload: AchievementsPayload = {
summary: { summary: {

View File

@@ -40,8 +40,9 @@ async function handleResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null); const data = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
const error = new Error((data && data.error && data.error.message) || 'Request failed'); const errorPayload = data as { error?: { message?: string; code?: unknown } } | null;
(error as any).code = data?.error?.code ?? response.status; const error = new Error(errorPayload?.error?.message ?? 'Request failed') as Error & { code?: unknown };
error.code = errorPayload?.error?.code ?? response.status;
throw error; throw error;
} }
@@ -78,4 +79,3 @@ export async function fetchGalleryPhotos(token: string, cursor?: string | null,
return handleResponse<GalleryPhotosResponse>(response); return handleResponse<GalleryPhotosResponse>(response);
} }

View File

@@ -98,8 +98,8 @@ async function requestJson<T>(url: string): Promise<T> {
}); });
if (!response.ok) { if (!response.ok) {
const error = new Error('Help request failed'); const error = new Error('Help request failed') as Error & { status?: number };
(error as any).status = response.status; error.status = response.status;
throw error; throw error;
} }

View File

@@ -62,10 +62,12 @@ export async function likePhoto(id: number): Promise<number> {
}); });
if (!res.ok) { if (!res.ok) {
let payload: any = null; let payload: unknown = null;
try { try {
payload = await res.clone().json(); payload = await res.clone().json();
} catch {} } catch (error) {
console.warn('Like photo: failed to parse error payload', error);
}
if (res.status === 419) { if (res.status === 419) {
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.'); 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<number> {
} }
const error: UploadError = new Error( 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; error.status = res.status;
if (payload?.error?.meta) { const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
error.meta = payload.error.meta as Record<string, unknown>; if (meta) {
error.meta = meta;
} }
throw error; throw error;
@@ -114,7 +117,7 @@ export async function uploadPhoto(
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`; const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
const headers = getCsrfHeaders(); const headers = getCsrfHeaders();
const attemptUpload = (attempt: number): Promise<any> => const attemptUpload = (): Promise<Record<string, unknown>> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', url, true); xhr.open('POST', url, true);
@@ -139,7 +142,7 @@ export async function uploadPhoto(
xhr.onload = () => { xhr.onload = () => {
const status = xhr.status; const status = xhr.status;
const payload = xhr.response ?? null; const payload = (xhr.response ?? null) as Record<string, unknown> | null;
if (status >= 200 && status < 300) { if (status >= 200 && status < 300) {
resolve(payload); resolve(payload);
@@ -147,12 +150,13 @@ export async function uploadPhoto(
} }
const error: UploadError = new Error( 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; error.status = status;
if (payload?.error?.meta) { const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
error.meta = payload.error.meta as Record<string, unknown>; if (meta) {
error.meta = meta;
} }
reject(error); reject(error);
}; };
@@ -174,8 +178,9 @@ export async function uploadPhoto(
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
const json = await attemptUpload(attempt + 1); const json = await attemptUpload();
return json?.photo_id ?? json?.id ?? json?.data?.id ?? 0; const payload = json as { photo_id?: number; id?: number; data?: { id?: number } };
return payload.photo_id ?? payload.id ?? payload.data?.id ?? 0;
} catch (error) { } catch (error) {
const err = error as UploadError; const err = error as UploadError;
@@ -213,13 +218,16 @@ export async function createPhotoShareLink(eventToken: string, photoId: number):
}); });
if (!res.ok) { if (!res.ok) {
let payload: any = null; let payload: unknown = null;
try { try {
payload = await res.clone().json(); 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'); const errorPayload = payload as { error?: { message?: string; code?: string } } | null;
error.code = payload?.error?.code ?? 'share_failed'; const error: UploadError = new Error(errorPayload?.error?.message ?? 'Share link creation failed');
error.code = errorPayload?.error?.code ?? 'share_failed';
error.status = res.status; error.status = res.status;
throw error; throw error;
} }

View File

@@ -1,6 +1,7 @@
import { usePage } from '@inertiajs/react'; import { usePage } from '@inertiajs/react';
export const useLocale = () => { export const useLocale = () => {
const { locale } = usePage().props as any; const { locale } = usePage<{ locale?: string }>().props;
return locale; return locale;
}; };

View File

@@ -4,9 +4,9 @@ import { useLocale } from './useLocale';
type LocalizedPathInput = string | null | undefined; type LocalizedPathInput = string | null | undefined;
export const useLocalizedRoutes = () => { export const useLocalizedRoutes = () => {
const page = usePage<{ supportedLocales?: string[] }>(); const { props } = usePage<{ supportedLocales?: string[] }>();
const locale = useLocale(); const locale = useLocale();
const supportedLocales = (page.props as any)?.supportedLocales ?? []; const supportedLocales = props.supportedLocales ?? [];
const fallbackLocale = (() => { const fallbackLocale = (() => {
if (locale && supportedLocales.includes(locale)) { if (locale && supportedLocales.includes(locale)) {

View File

@@ -3,13 +3,15 @@ import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend'; import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from 'i18next-browser-languagedetector';
const isDev = typeof import.meta !== 'undefined' && Boolean(import.meta.env?.DEV);
i18n i18n
.use(Backend) .use(Backend)
.use(LanguageDetector) .use(LanguageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
fallbackLng: 'de', fallbackLng: 'de',
debug: process.env.NODE_ENV === 'development', debug: isDev,
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
}, },

View File

@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { usePage } from '@inertiajs/react';
import Header from './Header'; import Header from './Header';
import Footer from './Footer'; import Footer from './Footer';
@@ -10,8 +9,6 @@ interface AppLayoutProps {
} }
const AppLayout: React.FC<AppLayoutProps> = ({ children, header, footer }) => { const AppLayout: React.FC<AppLayoutProps> = ({ children, header, footer }) => {
const { auth } = usePage().props;
return ( return (
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
{header || <Header />} {header || <Header />}
@@ -21,4 +18,4 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children, header, footer }) => {
); );
}; };
export default AppLayout; export default AppLayout;

View File

@@ -26,7 +26,7 @@ import {
} from '@/components/ui/navigation-menu'; } from '@/components/ui/navigation-menu';
const Header: React.FC = () => { 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 { t } = useTranslation('auth');
const { appearance, updateAppearance } = useAppearance(); const { appearance, updateAppearance } = useAppearance();
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
@@ -77,7 +77,6 @@ const Header: React.FC = () => {
const ctaHref = localizedPath('/demo'); const ctaHref = localizedPath('/demo');
const navItems = useMemo(() => { const navItems = useMemo(() => {
const homeHref = localizedPath('/');
const howItWorksHref = localizedPath('/so-funktionierts'); const howItWorksHref = localizedPath('/so-funktionierts');
return [ return [

View File

@@ -7,25 +7,28 @@ import Footer from '@/layouts/app/Footer';
import { useAppearance } from '@/hooks/use-appearance'; import { useAppearance } from '@/hooks/use-appearance';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; 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 { interface MarketingLayoutProps {
children: React.ReactNode; children: React.ReactNode;
title?: string; title?: string;
} }
type PageProps = {
translations?: Record<string, Record<string, string>>;
locale?: string;
analytics?: { matomo?: MatomoConfig };
supportedLocales?: string[];
appUrl?: string;
auth?: { user?: { name?: string; email?: string } };
};
const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) => { const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) => {
const page = usePage<{ const page = usePage<PageProps>();
translations?: Record<string, Record<string, string>>;
locale?: string;
analytics?: { matomo?: MatomoConfig };
supportedLocales?: string[];
appUrl?: string;
}>();
const { url } = page; const { url } = page;
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const i18n = useTranslation(); 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 user = auth?.user ?? null;
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
const { appearance, updateAppearance } = useAppearance(); const { appearance, updateAppearance } = useAppearance();

View File

@@ -5,17 +5,19 @@ function extractErrorMessage(payload: unknown): string {
return 'coupon_error_generic'; return 'coupon_error_generic';
} }
const data = payload as Record<string, any>; const data = payload as Record<string, unknown>;
if (data.message && typeof data.message === 'string') { if (typeof data.message === 'string') {
return data.message; return data.message;
} }
if (data.errors) { if (data.errors && typeof data.errors === 'object') {
const errors = data.errors as Record<string, string[]>; const errors = data.errors as Record<string, unknown>;
const firstKey = Object.keys(errors)[0]; const firstKey = Object.keys(errors)[0];
if (firstKey && Array.isArray(errors[firstKey]) && errors[firstKey][0]) { const firstEntry = firstKey ? errors[firstKey] : undefined;
return errors[firstKey][0];
if (Array.isArray(firstEntry) && typeof firstEntry[0] === 'string') {
return firstEntry[0];
} }
} }

View File

@@ -133,7 +133,7 @@ export default function ProfileIndex() {
try { try {
return dateFormatter.format(new Date(userData.emailVerifiedAt)); return dateFormatter.format(new Date(userData.emailVerifiedAt));
} catch (error) { } catch {
return null; return null;
} }
}, [userData.emailVerifiedAt, dateFormatter]); }, [userData.emailVerifiedAt, dateFormatter]);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useState } from "react";
import { usePage } from "@inertiajs/react"; import { usePage } from "@inertiajs/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -31,8 +31,6 @@ type SharedPageProps = {
type FieldErrors = Record<string, string>; type FieldErrors = Record<string, string>;
const fallbackRoute = (locale: string) => `/${locale}/login`;
const csrfToken = () => (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? ""; const csrfToken = () => (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? "";
export default function LoginForm({ onSuccess, canResetPassword = true, locale }: LoginFormProps) { export default function LoginForm({ onSuccess, canResetPassword = true, locale }: LoginFormProps) {

View File

@@ -6,10 +6,8 @@ import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import type { GoogleProfilePrefill } from '../marketing/checkout/types'; import type { GoogleProfilePrefill } from '../marketing/checkout/types';
declare const route: (name: string, params?: Record<string, unknown>) => string;
export interface RegisterSuccessPayload { export interface RegisterSuccessPayload {
user: any | null; user: unknown | null;
redirect?: string | null; redirect?: string | null;
pending_purchase?: boolean; pending_purchase?: boolean;
} }
@@ -69,7 +67,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
const [serverError, setServerError] = useState<string | null>(null); const [serverError, setServerError] = useState<string | null>(null);
const [serverErrorType, setServerErrorType] = useState<'generic' | 'session-expired'>('generic'); const [serverErrorType, setServerErrorType] = useState<'generic' | 'session-expired'>('generic');
const { t } = useTranslation(['auth', 'common']); const { t } = useTranslation(['auth', 'common']);
const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: any | null } }>(); const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: unknown | null } }>();
const resolvedLocale = locale ?? page.props.locale ?? 'de'; const resolvedLocale = locale ?? page.props.locale ?? 'de';
const { data, setData, errors, clearErrors, reset, setError } = useForm<RegisterFormFields>({ const { data, setData, errors, clearErrors, reset, setError } = useForm<RegisterFormFields>({
@@ -245,7 +243,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
try { try {
const json = await response.clone().json(); const json = await response.clone().json();
message = json?.message ?? null; message = json?.message ?? null;
} catch (error) { } catch {
message = null; message = null;
} }

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useForm } from '@inertiajs/react'; import { useForm } from '@inertiajs/react';
import { useTranslation } from 'react-i18next'; 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'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
interface RegisterProps { interface RegisterProps {

View File

@@ -110,7 +110,10 @@ export default function Dashboard() {
const page = usePage<SharedData & DashboardPageProps>(); const page = usePage<SharedData & DashboardPageProps>();
const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale, onboarding } = page.props; const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale, onboarding } = page.props;
const { auth, supportedLocales } = page.props; const { auth, supportedLocales } = page.props;
const translations = (page.props.translations?.dashboard ?? {}) as Record<string, unknown>; const translations = useMemo(
() => (page.props.translations?.dashboard ?? {}) as Record<string, unknown>,
[page.props.translations?.dashboard],
);
const [verificationSent, setVerificationSent] = useState(false); const [verificationSent, setVerificationSent] = useState(false);
const [sendingVerification, setSendingVerification] = useState(false); const [sendingVerification, setSendingVerification] = useState(false);
@@ -285,7 +288,7 @@ export default function Dashboard() {
try { try {
return currencyFormatter.format(price); return currencyFormatter.format(price);
} catch (error) { } catch {
return `${price.toFixed(2)}`; 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 });

View File

@@ -50,7 +50,10 @@ const MarkdownPreview: React.FC<{ html?: string; fallback?: string; className?:
const Blog: React.FC<Props> = ({ posts }) => { const Blog: React.FC<Props> = ({ posts }) => {
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
const { props } = usePage<{ supportedLocales?: string[] }>(); 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 { t, i18n } = useTranslation('marketing');
const locale = i18n.language || 'de'; const locale = i18n.language || 'de';
const articles = posts?.data ?? []; const articles = posts?.data ?? [];
@@ -116,7 +119,7 @@ const Blog: React.FC<Props> = ({ posts }) => {
return localizedPath(raw); return localizedPath(raw);
} }
}, },
[localizedPath] [localizedPath, supportedLocales]
); );
const renderPagination = () => { const renderPagination = () => {

View File

@@ -11,7 +11,7 @@ const Kontakt: React.FC = () => {
message: '', message: '',
}); });
const { flash } = usePage().props as any; const { flash } = usePage<{ flash?: { success?: string } }>().props;
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();

View File

@@ -1,22 +1,20 @@
import React, { useState, useEffect, useMemo, useRef, useLayoutEffect } from 'react'; 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 { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Sheet, SheetContent } from '@/components/ui/sheet'; import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import MarketingLayout from '@/layouts/mainWebsite'; import MarketingLayout from '@/layouts/mainWebsite';
import { useAnalytics } from '@/hooks/useAnalytics'; import { useAnalytics } from '@/hooks/useAnalytics';
import { useCtaExperiment } from '@/hooks/useCtaExperiment'; import { useCtaExperiment } from '@/hooks/useCtaExperiment';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; 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 { interface Package {
id: number; id: number;
@@ -214,14 +212,11 @@ interface PackagesProps {
const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackages }) => { const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackages }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null); const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
const [currentStep, setCurrentStep] = useState<'overview' | 'testimonials'>('overview');
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const dialogScrollRef = useRef<HTMLDivElement | null>(null); const dialogScrollRef = useRef<HTMLDivElement | null>(null);
const dialogHeadingRef = useRef<HTMLDivElement | null>(null); const dialogHeadingRef = useRef<HTMLDivElement | null>(null);
const mobileEndcustomerRef = useRef<HTMLDivElement | null>(null); const mobileEndcustomerRef = useRef<HTMLDivElement | null>(null);
const mobileResellerRef = useRef<HTMLDivElement | null>(null); const mobileResellerRef = useRef<HTMLDivElement | null>(null);
const { props } = usePage();
const { auth } = props as any;
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { t: tCommon } = useTranslation('common'); const { t: tCommon } = useTranslation('common');
@@ -278,10 +273,18 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
const media = window.matchMedia('(max-width: 768px)'); const media = window.matchMedia('(max-width: 768px)');
const update = () => setIsMobile(media.matches); const update = () => setIsMobile(media.matches);
update(); update();
media.addEventListener ? media.addEventListener('change', update) : media.addListener(update); if (media.addEventListener) {
media.addEventListener('change', update);
} else {
media.addListener(update);
}
return () => { 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, value: pkg.price,
}); });
setSelectedPackage(pkg); setSelectedPackage(pkg);
setCurrentStep('overview');
setOpen(true); setOpen(true);
}; };
@@ -418,20 +420,6 @@ function selectHighlightPackageId(packages: Package[]): number | null {
// nextStep entfernt, da Tabs nun parallel sind // nextStep entfernt, da Tabs nun parallel sind
const getFeatureIcon = (feature: string) => {
switch (feature) {
case 'basic_uploads': return <Image className="w-4 h-4" />;
case 'unlimited_sharing': return <ArrowRight className="w-4 h-4" />;
case 'no_watermark': return <Shield className="w-4 h-4" />;
case 'custom_tasks': return <Check className="w-4 h-4" />;
case 'advanced_analytics': return <Star className="w-4 h-4" />;
case 'priority_support': return <Users className="w-4 h-4" />;
case 'reseller_dashboard': return <ShoppingCart className="w-4 h-4" />;
case 'custom_branding': return <Image className="w-4 h-4" />;
default: return <Check className="w-4 h-4" />;
}
};
const getAccentTheme = (variant: 'endcustomer' | 'reseller') => const getAccentTheme = (variant: 'endcustomer' | 'reseller') =>
variant === 'reseller' variant === 'reseller'
? { ? {

View File

@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { usePage, router } from '@inertiajs/react'; import { usePage, router } from '@inertiajs/react';
import { Head } from '@inertiajs/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/mainWebsite'; import MarketingLayout from '@/layouts/mainWebsite';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
@@ -8,7 +7,7 @@ import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { ADMIN_HOME_PATH } from '@/admin/constants'; import { ADMIN_HOME_PATH } from '@/admin/constants';
const Success: React.FC = () => { 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 { t } = useTranslation('success');
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();

View File

@@ -104,7 +104,10 @@ const WizardBody: React.FC<{
[t] [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(() => { const progress = useMemo(() => {
if (currentIndex < 0) { if (currentIndex < 0) {
return 0; return 0;

View File

@@ -5,7 +5,7 @@ interface CheckoutState {
currentStep: CheckoutStepId; currentStep: CheckoutStepId;
selectedPackage: CheckoutPackage | null; selectedPackage: CheckoutPackage | null;
packageOptions: CheckoutPackage[]; packageOptions: CheckoutPackage[];
authUser: any; authUser: unknown;
isAuthenticated: boolean; isAuthenticated: boolean;
paymentIntent: string | null; paymentIntent: string | null;
loading: boolean; loading: boolean;
@@ -19,7 +19,7 @@ interface CheckoutWizardContextType {
packageOptions: CheckoutPackage[]; packageOptions: CheckoutPackage[];
currentStep: CheckoutStepId; currentStep: CheckoutStepId;
isAuthenticated: boolean; isAuthenticated: boolean;
authUser: any; authUser: unknown;
paddleConfig?: { paddleConfig?: {
environment?: string | null; environment?: string | null;
client_token?: string | null; client_token?: string | null;
@@ -27,7 +27,7 @@ interface CheckoutWizardContextType {
paymentCompleted: boolean; paymentCompleted: boolean;
selectPackage: (pkg: CheckoutPackage) => void; selectPackage: (pkg: CheckoutPackage) => void;
setSelectedPackage: (pkg: CheckoutPackage) => void; setSelectedPackage: (pkg: CheckoutPackage) => void;
setAuthUser: (user: any) => void; setAuthUser: (user: unknown) => void;
nextStep: () => void; nextStep: () => void;
prevStep: () => void; prevStep: () => void;
previousStep: () => void; previousStep: () => void;
@@ -56,7 +56,7 @@ const initialState: CheckoutState = {
type CheckoutAction = type CheckoutAction =
| { type: 'SELECT_PACKAGE'; payload: CheckoutPackage } | { type: 'SELECT_PACKAGE'; payload: CheckoutPackage }
| { type: 'SET_AUTH_USER'; payload: any } | { type: 'SET_AUTH_USER'; payload: unknown }
| { type: 'NEXT_STEP' } | { type: 'NEXT_STEP' }
| { type: 'PREV_STEP' } | { type: 'PREV_STEP' }
| { type: 'GO_TO_STEP'; payload: CheckoutStepId } | { type: 'GO_TO_STEP'; payload: CheckoutStepId }
@@ -72,19 +72,23 @@ function checkoutReducer(state: CheckoutState, action: CheckoutAction): Checkout
case 'SET_AUTH_USER': case 'SET_AUTH_USER':
return { ...state, authUser: action.payload, isAuthenticated: Boolean(action.payload) }; return { ...state, authUser: action.payload, isAuthenticated: Boolean(action.payload) };
case 'NEXT_STEP': case 'NEXT_STEP':
const steps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation']; {
const currentIndex = steps.indexOf(state.currentStep); const steps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation'];
if (currentIndex < steps.length - 1) { const currentIndex = steps.indexOf(state.currentStep);
return { ...state, currentStep: steps[currentIndex + 1] }; if (currentIndex < steps.length - 1) {
return { ...state, currentStep: steps[currentIndex + 1] };
}
return state;
} }
return state;
case 'PREV_STEP': case 'PREV_STEP':
const prevSteps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation']; {
const prevIndex = prevSteps.indexOf(state.currentStep); const prevSteps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation'];
if (prevIndex > 0) { const prevIndex = prevSteps.indexOf(state.currentStep);
return { ...state, currentStep: prevSteps[prevIndex - 1] }; if (prevIndex > 0) {
return { ...state, currentStep: prevSteps[prevIndex - 1] };
}
return state;
} }
return state;
case 'GO_TO_STEP': case 'GO_TO_STEP':
return { ...state, currentStep: action.payload }; return { ...state, currentStep: action.payload };
case 'UPDATE_PAYMENT_INTENT': case 'UPDATE_PAYMENT_INTENT':
@@ -105,7 +109,7 @@ interface CheckoutWizardProviderProps {
initialPackage?: CheckoutPackage; initialPackage?: CheckoutPackage;
packageOptions?: CheckoutPackage[]; packageOptions?: CheckoutPackage[];
initialStep?: CheckoutStepId; initialStep?: CheckoutStepId;
initialAuthUser?: any; initialAuthUser?: unknown;
initialIsAuthenticated?: boolean; initialIsAuthenticated?: boolean;
paddle?: { paddle?: {
environment?: string | null; environment?: string | null;
@@ -173,7 +177,7 @@ export function CheckoutWizardProvider({
dispatch({ type: 'SELECT_PACKAGE', payload: pkg }); dispatch({ type: 'SELECT_PACKAGE', payload: pkg });
}, []); }, []);
const setAuthUser = useCallback((user: any) => { const setAuthUser = useCallback((user: unknown) => {
dispatch({ type: 'SET_AUTH_USER', payload: user }); dispatch({ type: 'SET_AUTH_USER', payload: user });
}, []); }, []);

View File

@@ -39,7 +39,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
const page = usePage<{ locale?: string }>(); const page = usePage<{ locale?: string }>();
const locale = page.props.locale ?? "de"; const locale = page.props.locale ?? "de";
const googleAuth = useMemo<GoogleAuthFlash>(() => { const googleAuth = useMemo<GoogleAuthFlash>(() => {
const props = page.props as Record<string, any>; const props = page.props as { googleAuth?: GoogleAuthFlash };
return props.googleAuth ?? {}; return props.googleAuth ?? {};
}, [page.props]); }, [page.props]);
const { isAuthenticated, authUser, setAuthUser, nextStep, selectedPackage } = useCheckoutWizard(); const { isAuthenticated, authUser, setAuthUser, nextStep, selectedPackage } = useCheckoutWizard();

View File

@@ -8,10 +8,9 @@ import { cn } from "@/lib/utils";
interface ConfirmationStepProps { interface ConfirmationStepProps {
onViewProfile?: () => void; onViewProfile?: () => void;
onGoToAdmin?: () => void;
} }
export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfile, onGoToAdmin }) => { export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfile }) => {
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { selectedPackage } = useCheckoutWizard(); const { selectedPackage } = useCheckoutWizard();
const handleProfile = React.useCallback(() => { const handleProfile = React.useCallback(() => {
@@ -22,14 +21,6 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
window.location.href = '/settings/profile'; window.location.href = '/settings/profile';
}, [onViewProfile]); }, [onViewProfile]);
const handleAdmin = React.useCallback(() => {
if (typeof onGoToAdmin === 'function') {
onGoToAdmin();
return;
}
window.location.href = '/event-admin';
}, [onGoToAdmin]);
const packageName = selectedPackage?.name ?? ''; const packageName = selectedPackage?.name ?? '';
const onboardingItems = [ const onboardingItems = [

View File

@@ -149,17 +149,10 @@ export const PackageStep: React.FC = () => {
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState } = useCheckoutWizard(); const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState } = useCheckoutWizard();
// Early return if no package is selected
if (!selectedPackage) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">{t('checkout.package_step.no_package_selected')}</p>
</div>
);
}
const comparablePackages = useMemo(() => { const comparablePackages = useMemo(() => {
if (!selectedPackage) {
return [];
}
// Filter by type and sort: free packages first, then by price ascending // Filter by type and sort: free packages first, then by price ascending
return packageOptions return packageOptions
.filter((pkg: CheckoutPackage) => pkg.type === selectedPackage.type) .filter((pkg: CheckoutPackage) => pkg.type === selectedPackage.type)
@@ -180,6 +173,14 @@ export const PackageStep: React.FC = () => {
resetPaymentState(); resetPaymentState();
}; };
if (!selectedPackage) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">{t('checkout.package_step.no_package_selected')}</p>
</div>
);
}
return ( return (
<div className="grid gap-8 lg:grid-cols-[2fr_1fr]"> <div className="grid gap-8 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6"> <div className="space-y-6">

View File

@@ -139,7 +139,7 @@ export const PaymentStep: React.FC = () => {
const [couponLoading, setCouponLoading] = useState(false); const [couponLoading, setCouponLoading] = useState(false);
const paddleRef = useRef<typeof window.Paddle | null>(null); const paddleRef = useRef<typeof window.Paddle | null>(null);
const checkoutContainerRef = useRef<HTMLDivElement | null>(null); const checkoutContainerRef = useRef<HTMLDivElement | null>(null);
const eventCallbackRef = useRef<(event: any) => void>(); const eventCallbackRef = useRef<(event: Record<string, unknown>) => void>();
const hasAutoAppliedCoupon = useRef(false); const hasAutoAppliedCoupon = useRef(false);
const checkoutContainerClass = 'paddle-checkout-container'; const checkoutContainerClass = 'paddle-checkout-container';
@@ -149,7 +149,6 @@ export const PaymentStep: React.FC = () => {
}, [i18n.language]); }, [i18n.language]);
const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]); const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
const hasCoupon = Boolean(couponPreview);
const applyCoupon = useCallback(async (code: string) => { const applyCoupon = useCallback(async (code: string) => {
if (!selectedPackage) { if (!selectedPackage) {
@@ -339,7 +338,7 @@ export const PaymentStep: React.FC = () => {
console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody }); console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody });
} }
let data: any = null; let data: { checkout_url?: string; message?: string } | null = null;
try { try {
data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null; data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null;
} catch (parseError) { } catch (parseError) {
@@ -354,7 +353,7 @@ export const PaymentStep: React.FC = () => {
if (/^https?:\/\//i.test(trimmed)) { if (/^https?:\/\//i.test(trimmed)) {
checkoutUrl = trimmed; checkoutUrl = trimmed;
} else if (trimmed.startsWith('<')) { } else if (trimmed.startsWith('<')) {
const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:\/?#\[\]@!$&'()*+,;=%-]+/); const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:/?#@!$&'()*+,;=%-]+/);
if (match) { if (match) {
checkoutUrl = match[0]; checkoutUrl = match[0];
} }
@@ -444,7 +443,7 @@ export const PaymentStep: React.FC = () => {
locale: paddleLocale, locale: paddleLocale,
}, },
}, },
eventCallback: (event: any) => eventCallbackRef.current?.(event), eventCallback: (event: Record<string, unknown>) => eventCallbackRef.current?.(event),
}); });
inlineReady = true; inlineReady = true;

View File

@@ -43,6 +43,23 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
> >
{({ processing, recentlySuccessful, errors }) => ( {({ processing, recentlySuccessful, errors }) => (
<> <>
<div className="grid gap-2">
<Label htmlFor="name">{t('auth.settings.profile.name', 'Name')}</Label>
<Input
id="name"
type="text"
className="mt-1 block w-full"
defaultValue={auth.user.name ?? ''}
name="name"
required
autoComplete="name"
placeholder={t('auth.settings.profile.name_placeholder', 'Dein Name')}
/>
<InputError className="mt-2" message={errors.name} />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email">{t('auth.settings.profile.email', 'Email address')}</Label> <Label htmlFor="email">{t('auth.settings.profile.email', 'Email address')}</Label>
@@ -66,7 +83,7 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
<Input <Input
id="username" id="username"
className="mt-1 block w-full" className="mt-1 block w-full"
defaultValue={(auth.user as any).username ?? ''} defaultValue={auth.user?.username ?? ''}
name="username" name="username"
autoComplete="username" autoComplete="username"
placeholder={t('auth.settings.profile.username_placeholder')} placeholder={t('auth.settings.profile.username_placeholder')}
@@ -81,7 +98,7 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
<select <select
id="preferred_locale" id="preferred_locale"
name="preferred_locale" name="preferred_locale"
defaultValue={(auth.user as any).preferred_locale ?? 'en'} defaultValue={auth.user?.preferred_locale ?? 'en'}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
> >
{(supportedLocales ?? ['de', 'en']).map((l) => ( {(supportedLocales ?? ['de', 'en']).map((l) => (

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/rules-of-hooks */
import 'dotenv/config'; import 'dotenv/config';
import { test as base, expect, Page, APIRequestContext, APIResponse } from '@playwright/test'; 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!'; const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD ?? 'Demo1234!';
export const test = base.extend<TenantAdminFixtures & TestingApiFixtures>({ export const test = base.extend<TenantAdminFixtures & TestingApiFixtures>({
tenantAdminCredentials: async ({}, use) => { tenantAdminCredentials: async (_context, use) => {
if (!tenantAdminEmail || !tenantAdminPassword) { if (!tenantAdminEmail || !tenantAdminPassword) {
await use(null); await use(null);
return; return;

View File

@@ -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.describe('Homepage Links Test', () => {
test('Click all links on homepage and check for errors', async ({ page }) => { 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 // For each link, create temporary listeners
const linkFailedRequests: { url: string; status: number }[] = []; const linkFailedRequests: { url: string; status: number }[] = [];
const linkConsoleErrors: string[] = []; const linkConsoleErrors: string[] = [];
const linkResponseHandler = (response: any) => { const linkResponseHandler = (response: Response) => {
if (response.status() >= 400) { if (response.status() >= 400) {
linkFailedRequests.push({ linkFailedRequests.push({
url: response.url(), url: response.url(),
@@ -55,7 +55,7 @@ test.describe('Homepage Links Test', () => {
}); });
} }
}; };
const linkConsoleHandler = (msg: any) => { const linkConsoleHandler = (msg: ConsoleMessage) => {
if (msg.type() === 'error') { if (msg.type() === 'error') {
linkConsoleErrors.push(msg.text()); linkConsoleErrors.push(msg.text());
} }
@@ -103,4 +103,4 @@ test.describe('Homepage Links Test', () => {
console.log('All links tested successfully.'); console.log('All links tested successfully.');
}); });
}); });

View File

@@ -1,5 +1,4 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { chromium } from 'playwright';
test.describe('Package Flow in Admin PWA', () => { test.describe('Package Flow in Admin PWA', () => {
test('Create event with package and verify limits', async ({ page }) => { test('Create event with package and verify limits', async ({ page }) => {