refactor(guest): retire legacy guest app and move shared modules
This commit is contained in:
69
resources/js/shared/guest/components/LiveShowBackdrop.tsx
Normal file
69
resources/js/shared/guest/components/LiveShowBackdrop.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import type { LiveShowBackgroundMode, LiveShowPhoto } from '../services/liveShowApi';
|
||||
|
||||
function resolvePhotoUrl(photo?: LiveShowPhoto | null): string | null {
|
||||
if (!photo) {
|
||||
return null;
|
||||
}
|
||||
return photo.full_url || photo.thumb_url || null;
|
||||
}
|
||||
|
||||
function resolveBlurAmount(intensity: number): number {
|
||||
const safe = Number.isFinite(intensity) ? intensity : 70;
|
||||
return 28 + Math.min(60, Math.max(0, safe)) * 0.45;
|
||||
}
|
||||
|
||||
export default function LiveShowBackdrop({
|
||||
mode,
|
||||
photo,
|
||||
intensity,
|
||||
}: {
|
||||
mode: LiveShowBackgroundMode;
|
||||
photo?: LiveShowPhoto | null;
|
||||
intensity: number;
|
||||
}) {
|
||||
const photoUrl = resolvePhotoUrl(photo);
|
||||
const blurAmount = resolveBlurAmount(intensity);
|
||||
const fallbackMode = mode === 'blur_last' && !photoUrl ? 'gradient' : mode;
|
||||
|
||||
if (fallbackMode === 'solid') {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0"
|
||||
style={{ backgroundColor: 'rgb(8, 10, 16)' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fallbackMode === 'gradient') {
|
||||
return <div className="pointer-events-none absolute inset-0 z-0 bg-aurora-enhanced" />;
|
||||
}
|
||||
|
||||
if (fallbackMode === 'brand') {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(120% 120% at 15% 15%, var(--guest-primary) 0%, rgba(0,0,0,0.8) 55%), radial-gradient(120% 120% at 85% 20%, var(--guest-secondary) 0%, transparent 60%)',
|
||||
backgroundColor: 'rgb(5, 8, 16)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-0">
|
||||
<div
|
||||
className="absolute inset-0 scale-110"
|
||||
style={{
|
||||
backgroundImage: photoUrl ? `url(${photoUrl})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
filter: `blur(${blurAmount}px) saturate(1.15)`,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
resources/js/shared/guest/components/LiveShowStage.tsx
Normal file
80
resources/js/shared/guest/components/LiveShowStage.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import type { LiveShowLayoutMode, LiveShowPhoto } from '../services/liveShowApi';
|
||||
|
||||
const BASE_TILE =
|
||||
'relative overflow-hidden rounded-[28px] bg-black/70 shadow-[0_24px_70px_rgba(0,0,0,0.55)]';
|
||||
|
||||
function PhotoTile({
|
||||
photo,
|
||||
fit,
|
||||
label,
|
||||
className = '',
|
||||
}: {
|
||||
photo: LiveShowPhoto;
|
||||
fit: 'cover' | 'contain';
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const src = photo.full_url || photo.thumb_url || '';
|
||||
return (
|
||||
<div className={`${BASE_TILE} ${className}`.trim()}>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={label}
|
||||
className={`h-full w-full object-${fit} object-center`}
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-white/60">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LiveShowStage({
|
||||
layout,
|
||||
photos,
|
||||
title,
|
||||
}: {
|
||||
layout: LiveShowLayoutMode;
|
||||
photos: LiveShowPhoto[];
|
||||
title: string;
|
||||
}) {
|
||||
if (photos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (layout === 'single') {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center px-6 py-12">
|
||||
<PhotoTile
|
||||
photo={photos[0]}
|
||||
fit="contain"
|
||||
label={title}
|
||||
className="h-[62vh] w-full max-w-[1200px] sm:h-[72vh]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (layout === 'split') {
|
||||
return (
|
||||
<div className="grid h-[72vh] w-full grid-cols-1 gap-6 px-6 py-12 lg:grid-cols-2">
|
||||
{photos.slice(0, 2).map((photo) => (
|
||||
<PhotoTile key={photo.id} photo={photo} fit="cover" label={title} className="h-full w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[72vh] w-full grid-cols-2 grid-rows-2 gap-5 px-6 py-12">
|
||||
{photos.slice(0, 4).map((photo) => (
|
||||
<PhotoTile key={photo.id} photo={photo} fit="cover" label={title} className="h-full w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
resources/js/shared/guest/components/PullToRefresh.tsx
Normal file
160
resources/js/shared/guest/components/PullToRefresh.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import { ArrowDown, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { triggerHaptic } from '../lib/haptics';
|
||||
|
||||
const MAX_PULL = 96;
|
||||
const TRIGGER_PULL = 72;
|
||||
const DAMPING = 0.55;
|
||||
|
||||
type PullToRefreshProps = {
|
||||
onRefresh: () => Promise<void> | void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
pullLabel?: string;
|
||||
releaseLabel?: string;
|
||||
refreshingLabel?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function PullToRefresh({
|
||||
onRefresh,
|
||||
disabled = false,
|
||||
className,
|
||||
pullLabel = 'Pull to refresh',
|
||||
releaseLabel = 'Release to refresh',
|
||||
refreshingLabel = 'Refreshing…',
|
||||
children,
|
||||
}: PullToRefreshProps) {
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const startYRef = React.useRef<number | null>(null);
|
||||
const pullDistanceRef = React.useRef(0);
|
||||
const readyRef = React.useRef(false);
|
||||
const [pullDistance, setPullDistance] = React.useState(0);
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
|
||||
const updatePull = React.useCallback((value: number) => {
|
||||
pullDistanceRef.current = value;
|
||||
setPullDistance(value);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleStart = (event: TouchEvent) => {
|
||||
if (refreshing || window.scrollY > 0) {
|
||||
return;
|
||||
}
|
||||
startYRef.current = event.touches[0]?.clientY ?? null;
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handleMove = (event: TouchEvent) => {
|
||||
if (refreshing || startYRef.current === null) {
|
||||
return;
|
||||
}
|
||||
if (window.scrollY > 0) {
|
||||
startYRef.current = null;
|
||||
updatePull(0);
|
||||
setDragging(false);
|
||||
return;
|
||||
}
|
||||
const currentY = event.touches[0]?.clientY ?? 0;
|
||||
const delta = currentY - startYRef.current;
|
||||
if (delta <= 0) {
|
||||
updatePull(0);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const next = Math.min(MAX_PULL, delta * DAMPING);
|
||||
updatePull(next);
|
||||
const isReady = next >= TRIGGER_PULL;
|
||||
if (isReady && !readyRef.current) {
|
||||
readyRef.current = true;
|
||||
triggerHaptic('selection');
|
||||
} else if (!isReady && readyRef.current) {
|
||||
readyRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnd = async () => {
|
||||
if (startYRef.current === null) {
|
||||
return;
|
||||
}
|
||||
startYRef.current = null;
|
||||
setDragging(false);
|
||||
readyRef.current = false;
|
||||
|
||||
if (pullDistanceRef.current >= TRIGGER_PULL) {
|
||||
triggerHaptic('medium');
|
||||
setRefreshing(true);
|
||||
updatePull(TRIGGER_PULL);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
updatePull(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updatePull(0);
|
||||
};
|
||||
|
||||
container.addEventListener('touchstart', handleStart, { passive: true });
|
||||
container.addEventListener('touchmove', handleMove, { passive: false });
|
||||
container.addEventListener('touchend', handleEnd);
|
||||
container.addEventListener('touchcancel', handleEnd);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('touchstart', handleStart);
|
||||
container.removeEventListener('touchmove', handleMove);
|
||||
container.removeEventListener('touchend', handleEnd);
|
||||
container.removeEventListener('touchcancel', handleEnd);
|
||||
};
|
||||
}, [disabled, onRefresh, refreshing, updatePull]);
|
||||
|
||||
const progress = Math.min(pullDistance / TRIGGER_PULL, 1);
|
||||
const ready = pullDistance >= TRIGGER_PULL;
|
||||
const indicatorLabel = refreshing
|
||||
? refreshingLabel
|
||||
: ready
|
||||
? releaseLabel
|
||||
: pullLabel;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('relative', className)}>
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 right-0 top-2 flex h-10 items-center justify-center"
|
||||
style={{
|
||||
opacity: progress,
|
||||
transform: `translateY(${Math.min(pullDistance, TRIGGER_PULL) - 48}px)`,
|
||||
transition: dragging ? 'none' : 'transform 200ms ease-out, opacity 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/30 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-slate-900/80 dark:text-slate-100">
|
||||
{refreshing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<ArrowDown
|
||||
className={cn('h-4 w-4 transition-transform duration-200', ready && 'rotate-180')}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span>{indicatorLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn('will-change-transform', !dragging && 'transition-transform duration-200 ease-out')}
|
||||
style={{ transform: `translateY(${pullDistance}px)` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
resources/js/shared/guest/components/ToastHost.tsx
Normal file
79
resources/js/shared/guest/components/ToastHost.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
|
||||
type ToastAction = { label: string; onClick: () => void };
|
||||
type Toast = {
|
||||
id: number;
|
||||
text: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
action?: ToastAction;
|
||||
durationMs?: number;
|
||||
};
|
||||
const Ctx = React.createContext<{ push: (t: Omit<Toast,'id'>) => void } | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [list, setList] = React.useState<Toast[]>([]);
|
||||
const push = React.useCallback((t: Omit<Toast,'id'>) => {
|
||||
const id = Date.now() + Math.random();
|
||||
const durationMs = t.durationMs ?? 3000;
|
||||
setList((arr) => [...arr, { id, ...t, durationMs }]);
|
||||
if (durationMs > 0) {
|
||||
setTimeout(() => setList((arr) => arr.filter((x) => x.id !== id)), durationMs);
|
||||
}
|
||||
}, []);
|
||||
const dismiss = React.useCallback((id: number) => {
|
||||
setList((arr) => arr.filter((x) => x.id !== id));
|
||||
}, []);
|
||||
const contextValue = React.useMemo(() => ({ push }), [push]);
|
||||
React.useEffect(() => {
|
||||
const onEvt = (e: CustomEvent<Omit<Toast, 'id'>>) => push(e.detail);
|
||||
window.addEventListener('guest-toast', onEvt);
|
||||
return () => window.removeEventListener('guest-toast', onEvt);
|
||||
}, [push]);
|
||||
return (
|
||||
<Ctx.Provider value={contextValue}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
|
||||
<div className="flex w-full max-w-sm flex-col gap-2">
|
||||
{list.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`pointer-events-auto rounded-md border p-3 shadow-sm ${
|
||||
t.type === 'error'
|
||||
? 'border-red-300 bg-red-50 text-red-700'
|
||||
: t.type === 'info'
|
||||
? 'border-blue-300 bg-blue-50 text-blue-700'
|
||||
: 'border-green-300 bg-green-50 text-green-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-sm">{t.text}</span>
|
||||
{t.action ? (
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto rounded-full border border-current/30 px-3 py-1 text-xs font-semibold uppercase tracking-wide transition hover:border-current"
|
||||
onClick={() => {
|
||||
try {
|
||||
t.action?.onClick();
|
||||
} finally {
|
||||
dismiss(t.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = React.useContext(Ctx);
|
||||
if (!ctx) throw new Error('ToastProvider missing');
|
||||
return ctx;
|
||||
}
|
||||
31
resources/js/shared/guest/components/legal-markdown.tsx
Normal file
31
resources/js/shared/guest/components/legal-markdown.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
markdown?: string;
|
||||
html?: string;
|
||||
};
|
||||
|
||||
export function LegalMarkdown({ markdown = '', html }: Props) {
|
||||
const derived = React.useMemo(() => {
|
||||
if (html && html.trim().length > 0) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const escaped = markdown
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
return escaped
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => `<p>${block.replace(/\n/g, '<br/>')}</p>`)
|
||||
.join('\n');
|
||||
}, [markdown, html]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: derived }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
349
resources/js/shared/guest/context/EventBrandingContext.tsx
Normal file
349
resources/js/shared/guest/context/EventBrandingContext.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo } from 'react';
|
||||
import type { EventBranding } from '../types/event-branding';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
|
||||
|
||||
type EventBrandingContextValue = {
|
||||
branding: EventBranding;
|
||||
isCustom: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_EVENT_BRANDING: EventBranding = {
|
||||
primaryColor: '#E94B5A',
|
||||
secondaryColor: '#F7C7CF',
|
||||
backgroundColor: '#FFF6F2',
|
||||
fontFamily: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
logoUrl: null,
|
||||
welcomeMessage: null,
|
||||
palette: {
|
||||
primary: '#E94B5A',
|
||||
secondary: '#F7C7CF',
|
||||
background: '#FFF6F2',
|
||||
surface: '#FFFFFF',
|
||||
},
|
||||
typography: {
|
||||
heading: 'Playfair Display, "Times New Roman", serif',
|
||||
body: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
sizePreset: 'm',
|
||||
},
|
||||
logo: {
|
||||
mode: 'emoticon',
|
||||
value: null,
|
||||
position: 'left',
|
||||
size: 'm',
|
||||
},
|
||||
buttons: {
|
||||
style: 'filled',
|
||||
radius: 12,
|
||||
},
|
||||
mode: 'auto',
|
||||
};
|
||||
const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase();
|
||||
const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.toLowerCase();
|
||||
const DEFAULT_BACKGROUND = DEFAULT_EVENT_BRANDING.backgroundColor.toLowerCase();
|
||||
const LIGHT_LUMINANCE_THRESHOLD = 0.65;
|
||||
const DARK_LUMINANCE_THRESHOLD = 0.35;
|
||||
const DARK_FALLBACK_SURFACE = '#0f172a';
|
||||
const LIGHT_FALLBACK_SURFACE = '#ffffff';
|
||||
const FONT_SCALE_MAP: Record<'s' | 'm' | 'l', number> = {
|
||||
s: 0.94,
|
||||
m: 1,
|
||||
l: 1.08,
|
||||
};
|
||||
|
||||
const EventBrandingContext = createContext<EventBrandingContextValue | undefined>(undefined);
|
||||
|
||||
function normaliseHexColor(value: string | null | undefined, fallback: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed) ? trimmed : fallback;
|
||||
}
|
||||
|
||||
function resolveBranding(input?: EventBranding | null): EventBranding {
|
||||
if (!input) {
|
||||
return DEFAULT_EVENT_BRANDING;
|
||||
}
|
||||
|
||||
const palettePrimary = input.palette?.primary ?? input.primaryColor;
|
||||
const paletteSecondary = input.palette?.secondary ?? input.secondaryColor;
|
||||
const paletteBackground = input.palette?.background ?? input.backgroundColor;
|
||||
const paletteSurface = input.palette?.surface ?? input.backgroundColor;
|
||||
|
||||
const headingFont = input.typography?.heading ?? input.fontFamily ?? null;
|
||||
const bodyFont = input.typography?.body ?? input.fontFamily ?? null;
|
||||
const rawSize = input.typography?.sizePreset ?? 'm';
|
||||
const sizePreset = rawSize === 's' || rawSize === 'm' || rawSize === 'l' ? rawSize : 'm';
|
||||
|
||||
const logoMode = input.logo?.mode ?? (input.logoUrl ? 'upload' : 'emoticon');
|
||||
const logoValue = input.logo?.value ?? input.logoUrl ?? null;
|
||||
|
||||
return {
|
||||
primaryColor: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
|
||||
secondaryColor: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||
backgroundColor: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor),
|
||||
fontFamily: bodyFont?.trim() || DEFAULT_EVENT_BRANDING.fontFamily,
|
||||
logoUrl: logoMode === 'upload' ? (logoValue?.trim() || null) : null,
|
||||
palette: {
|
||||
primary: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
|
||||
secondary: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||
background: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor),
|
||||
surface: normaliseHexColor(paletteSurface, paletteBackground ?? DEFAULT_EVENT_BRANDING.backgroundColor),
|
||||
},
|
||||
typography: {
|
||||
heading: headingFont?.trim() || DEFAULT_EVENT_BRANDING.typography?.heading || null,
|
||||
body: bodyFont?.trim() || DEFAULT_EVENT_BRANDING.typography?.body || DEFAULT_EVENT_BRANDING.fontFamily,
|
||||
sizePreset,
|
||||
},
|
||||
logo: {
|
||||
mode: logoMode,
|
||||
value: logoMode === 'upload' ? (logoValue?.trim() || null) : (logoValue ?? null),
|
||||
position: input.logo?.position ?? 'left',
|
||||
size: input.logo?.size ?? 'm',
|
||||
},
|
||||
buttons: {
|
||||
style: input.buttons?.style ?? 'filled',
|
||||
radius: typeof input.buttons?.radius === 'number' ? input.buttons.radius : 12,
|
||||
primary: input.buttons?.primary ?? normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
|
||||
secondary: input.buttons?.secondary ?? normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||
linkColor: input.buttons?.linkColor ?? normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||
},
|
||||
mode: input.mode ?? 'auto',
|
||||
useDefaultBranding: input.useDefaultBranding ?? undefined,
|
||||
welcomeMessage: input.welcomeMessage ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
type ThemeVariant = 'light' | 'dark';
|
||||
|
||||
function resolveThemeVariant(
|
||||
mode: EventBranding['mode'],
|
||||
backgroundColor: string,
|
||||
appearanceOverride: 'light' | 'dark' | null,
|
||||
): ThemeVariant {
|
||||
const prefersDark = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false;
|
||||
const backgroundLuminance = relativeLuminance(backgroundColor || DEFAULT_EVENT_BRANDING.backgroundColor);
|
||||
const backgroundPrefers = backgroundLuminance >= LIGHT_LUMINANCE_THRESHOLD
|
||||
? 'light'
|
||||
: backgroundLuminance <= DARK_LUMINANCE_THRESHOLD
|
||||
? 'dark'
|
||||
: null;
|
||||
|
||||
if (appearanceOverride) {
|
||||
return appearanceOverride;
|
||||
}
|
||||
|
||||
if (mode === 'dark') {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
if (mode === 'light') {
|
||||
return 'light';
|
||||
}
|
||||
|
||||
if (backgroundPrefers) {
|
||||
return backgroundPrefers;
|
||||
}
|
||||
|
||||
return prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function clampToTheme(color: string, theme: ThemeVariant): string {
|
||||
const luminance = relativeLuminance(color);
|
||||
if (theme === 'dark' && luminance >= LIGHT_LUMINANCE_THRESHOLD) {
|
||||
return DARK_FALLBACK_SURFACE;
|
||||
}
|
||||
if (theme === 'light' && luminance <= DARK_LUMINANCE_THRESHOLD) {
|
||||
return LIGHT_FALLBACK_SURFACE;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
function applyCssVariables(branding: EventBranding, theme: ThemeVariant) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const background = clampToTheme(branding.backgroundColor, theme);
|
||||
const surfaceCandidate = clampToTheme(branding.palette?.surface ?? background, theme);
|
||||
const backgroundLuminance = relativeLuminance(background);
|
||||
const surfaceLuminance = relativeLuminance(surfaceCandidate);
|
||||
const surface = Math.abs(surfaceLuminance - backgroundLuminance) < 0.06
|
||||
? theme === 'light'
|
||||
? LIGHT_FALLBACK_SURFACE
|
||||
: DARK_FALLBACK_SURFACE
|
||||
: surfaceCandidate;
|
||||
const isLight = theme === 'light';
|
||||
const foreground = isLight ? '#1f2937' : '#f8fafc';
|
||||
const mutedForeground = isLight ? '#6b7280' : '#cbd5e1';
|
||||
const muted = isLight ? '#f6efec' : '#1f2937';
|
||||
const border = isLight ? '#e6d9d6' : '#334155';
|
||||
const input = isLight ? '#eadfda' : '#273247';
|
||||
const primaryForeground = getContrastingTextColor(branding.primaryColor, '#ffffff', '#0f172a');
|
||||
const secondaryForeground = getContrastingTextColor(branding.secondaryColor, '#ffffff', '#0f172a');
|
||||
root.style.setProperty('--guest-primary', branding.primaryColor);
|
||||
root.style.setProperty('--guest-secondary', branding.secondaryColor);
|
||||
root.style.setProperty('--guest-background', background);
|
||||
root.style.setProperty('--guest-surface', surface);
|
||||
root.style.setProperty('--guest-button-radius', `${branding.buttons?.radius ?? 12}px`);
|
||||
root.style.setProperty('--guest-radius', `${branding.buttons?.radius ?? 12}px`);
|
||||
root.style.setProperty('--guest-link', branding.buttons?.linkColor ?? branding.secondaryColor);
|
||||
root.style.setProperty('--guest-button-style', branding.buttons?.style ?? 'filled');
|
||||
root.style.setProperty('--guest-font-scale', String(FONT_SCALE_MAP[branding.typography?.sizePreset ?? 'm'] ?? 1));
|
||||
root.style.setProperty('--foreground', foreground);
|
||||
root.style.setProperty('--card-foreground', foreground);
|
||||
root.style.setProperty('--popover-foreground', foreground);
|
||||
root.style.setProperty('--muted', muted);
|
||||
root.style.setProperty('--muted-foreground', mutedForeground);
|
||||
root.style.setProperty('--border', border);
|
||||
root.style.setProperty('--input', input);
|
||||
root.style.setProperty('--primary', branding.primaryColor);
|
||||
root.style.setProperty('--primary-foreground', primaryForeground);
|
||||
root.style.setProperty('--secondary', branding.secondaryColor);
|
||||
root.style.setProperty('--secondary-foreground', secondaryForeground);
|
||||
root.style.setProperty('--accent', branding.secondaryColor);
|
||||
root.style.setProperty('--accent-foreground', secondaryForeground);
|
||||
root.style.setProperty('--ring', branding.primaryColor);
|
||||
|
||||
const headingFont = branding.typography?.heading ?? branding.fontFamily;
|
||||
const bodyFont = branding.typography?.body ?? branding.fontFamily;
|
||||
|
||||
if (bodyFont) {
|
||||
root.style.setProperty('--guest-font-family', bodyFont);
|
||||
root.style.setProperty('--guest-body-font', bodyFont);
|
||||
} else {
|
||||
root.style.removeProperty('--guest-font-family');
|
||||
root.style.removeProperty('--guest-body-font');
|
||||
}
|
||||
|
||||
if (headingFont) {
|
||||
root.style.setProperty('--guest-heading-font', headingFont);
|
||||
} else {
|
||||
root.style.removeProperty('--guest-heading-font');
|
||||
}
|
||||
}
|
||||
|
||||
function resetCssVariables() {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
root.style.removeProperty('--guest-primary');
|
||||
root.style.removeProperty('--guest-secondary');
|
||||
root.style.removeProperty('--guest-background');
|
||||
root.style.removeProperty('--guest-surface');
|
||||
root.style.removeProperty('--guest-button-radius');
|
||||
root.style.removeProperty('--guest-radius');
|
||||
root.style.removeProperty('--guest-link');
|
||||
root.style.removeProperty('--guest-button-style');
|
||||
root.style.removeProperty('--guest-font-scale');
|
||||
root.style.removeProperty('--guest-font-family');
|
||||
root.style.removeProperty('--guest-body-font');
|
||||
root.style.removeProperty('--guest-heading-font');
|
||||
root.style.removeProperty('--foreground');
|
||||
root.style.removeProperty('--card-foreground');
|
||||
root.style.removeProperty('--popover-foreground');
|
||||
root.style.removeProperty('--muted');
|
||||
root.style.removeProperty('--muted-foreground');
|
||||
root.style.removeProperty('--border');
|
||||
root.style.removeProperty('--input');
|
||||
root.style.removeProperty('--primary');
|
||||
root.style.removeProperty('--primary-foreground');
|
||||
root.style.removeProperty('--secondary');
|
||||
root.style.removeProperty('--secondary-foreground');
|
||||
root.style.removeProperty('--accent');
|
||||
root.style.removeProperty('--accent-foreground');
|
||||
root.style.removeProperty('--ring');
|
||||
}
|
||||
|
||||
function applyThemeMode(
|
||||
mode: EventBranding['mode'],
|
||||
backgroundColor: string,
|
||||
appearanceOverride: 'light' | 'dark' | null,
|
||||
): ThemeVariant {
|
||||
if (typeof document === 'undefined') {
|
||||
return 'light';
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const theme = resolveThemeVariant(mode, backgroundColor, appearanceOverride);
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
root.style.colorScheme = 'dark';
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
root.classList.remove('dark');
|
||||
root.style.colorScheme = 'light';
|
||||
return 'light';
|
||||
}
|
||||
|
||||
export function EventBrandingProvider({
|
||||
branding,
|
||||
children,
|
||||
}: {
|
||||
branding?: EventBranding | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const resolved = useMemo(() => resolveBranding(branding), [branding]);
|
||||
const { appearance } = useAppearance();
|
||||
const appearanceOverride = appearance === 'light' || appearance === 'dark' ? appearance : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.add('guest-theme');
|
||||
}
|
||||
const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false;
|
||||
const theme = applyThemeMode(resolved.mode ?? 'auto', resolved.backgroundColor, appearanceOverride);
|
||||
applyCssVariables(resolved, theme);
|
||||
|
||||
return () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
if (previousDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
document.documentElement.classList.remove('guest-theme');
|
||||
}
|
||||
resetCssVariables();
|
||||
const fallbackTheme = applyThemeMode(
|
||||
DEFAULT_EVENT_BRANDING.mode ?? 'auto',
|
||||
DEFAULT_EVENT_BRANDING.backgroundColor,
|
||||
appearanceOverride,
|
||||
);
|
||||
applyCssVariables(DEFAULT_EVENT_BRANDING, fallbackTheme);
|
||||
};
|
||||
}, [appearanceOverride, resolved]);
|
||||
|
||||
const value = useMemo<EventBrandingContextValue>(() => ({
|
||||
branding: resolved,
|
||||
isCustom:
|
||||
resolved.primaryColor.toLowerCase() !== DEFAULT_PRIMARY
|
||||
|| resolved.secondaryColor.toLowerCase() !== DEFAULT_SECONDARY
|
||||
|| resolved.backgroundColor.toLowerCase() !== DEFAULT_BACKGROUND,
|
||||
// legacy surface check omitted by intent
|
||||
}), [resolved]);
|
||||
|
||||
return <EventBrandingContext.Provider value={value}>{children}</EventBrandingContext.Provider>;
|
||||
}
|
||||
|
||||
export function useEventBranding(): EventBrandingContextValue {
|
||||
const context = useContext(EventBrandingContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useEventBranding must be used within an EventBrandingProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useOptionalEventBranding(): EventBrandingContextValue | undefined {
|
||||
return useContext(EventBrandingContext);
|
||||
}
|
||||
304
resources/js/shared/guest/context/NotificationCenterContext.tsx
Normal file
304
resources/js/shared/guest/context/NotificationCenterContext.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import React from 'react';
|
||||
import { useUploadQueue } from '../queue/hooks';
|
||||
import type { QueueItem } from '../queue/queue';
|
||||
import {
|
||||
dismissGuestNotification,
|
||||
fetchGuestNotifications,
|
||||
markGuestNotificationRead,
|
||||
type GuestNotificationItem,
|
||||
} from '../services/notificationApi';
|
||||
import { fetchPendingUploadsSummary } from '../services/pendingUploadsApi';
|
||||
import { updateAppBadge } from '../lib/badges';
|
||||
|
||||
export type NotificationCenterValue = {
|
||||
notifications: GuestNotificationItem[];
|
||||
unreadCount: number;
|
||||
queueItems: QueueItem[];
|
||||
queueCount: number;
|
||||
pendingCount: number;
|
||||
loading: boolean;
|
||||
pendingLoading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
setFilters: (filters: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => void;
|
||||
markAsRead: (id: number) => Promise<void>;
|
||||
dismiss: (id: number) => Promise<void>;
|
||||
eventToken: string;
|
||||
lastFetchedAt: Date | null;
|
||||
isOffline: boolean;
|
||||
};
|
||||
|
||||
const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null);
|
||||
|
||||
export function NotificationCenterProvider({ eventToken, children }: { eventToken: string; children: React.ReactNode }) {
|
||||
const { items, loading: queueLoading, refresh: refreshQueue } = useUploadQueue();
|
||||
const [notifications, setNotifications] = React.useState<GuestNotificationItem[]>([]);
|
||||
const [unreadCount, setUnreadCount] = React.useState(0);
|
||||
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
|
||||
const [pendingCount, setPendingCount] = React.useState(0);
|
||||
const [pendingLoading, setPendingLoading] = React.useState(true);
|
||||
const [filters, setFiltersState] = React.useState<{ status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }>({
|
||||
status: 'new',
|
||||
scope: 'all',
|
||||
});
|
||||
const etagRef = React.useRef<string | null>(null);
|
||||
const fetchLockRef = React.useRef(false);
|
||||
const [lastFetchedAt, setLastFetchedAt] = React.useState<Date | null>(null);
|
||||
const [isOffline, setIsOffline] = React.useState<boolean>(typeof navigator !== 'undefined' ? !navigator.onLine : false);
|
||||
|
||||
const queueCount = React.useMemo(
|
||||
() => items.filter((item) => item.status !== 'done').length,
|
||||
[items]
|
||||
);
|
||||
|
||||
const loadNotifications = React.useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
if (!eventToken) {
|
||||
if (!options.silent) {
|
||||
setLoadingNotifications(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetchLockRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchLockRef.current = true;
|
||||
if (!options.silent) {
|
||||
setLoadingNotifications(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const statusFilter = filters.status && filters.status !== 'all' ? (filters.status === 'new' ? 'unread' : filters.status) : undefined;
|
||||
const result = await fetchGuestNotifications(eventToken, etagRef.current, {
|
||||
status: statusFilter as any,
|
||||
scope: filters.scope,
|
||||
});
|
||||
if (!result.notModified) {
|
||||
setNotifications(result.notifications);
|
||||
setUnreadCount(result.unreadCount);
|
||||
setLastFetchedAt(new Date());
|
||||
}
|
||||
etagRef.current = result.etag;
|
||||
setIsOffline(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load guest notifications', error);
|
||||
if (!options.silent) {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
setIsOffline(true);
|
||||
}
|
||||
} finally {
|
||||
fetchLockRef.current = false;
|
||||
if (!options.silent) {
|
||||
setLoadingNotifications(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[eventToken]
|
||||
);
|
||||
|
||||
const loadPendingUploads = React.useCallback(async () => {
|
||||
if (!eventToken) {
|
||||
setPendingLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPendingLoading(true);
|
||||
const result = await fetchPendingUploadsSummary(eventToken, 1);
|
||||
setPendingCount(result.totalCount);
|
||||
} catch (error) {
|
||||
console.error('Failed to load pending uploads', error);
|
||||
setPendingCount(0);
|
||||
} finally {
|
||||
setPendingLoading(false);
|
||||
}
|
||||
}, [eventToken]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
etagRef.current = null;
|
||||
setPendingCount(0);
|
||||
|
||||
if (!eventToken) {
|
||||
setLoadingNotifications(false);
|
||||
setPendingLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingNotifications(true);
|
||||
void loadNotifications();
|
||||
void loadPendingUploads();
|
||||
}, [eventToken, loadNotifications, loadPendingUploads]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void loadNotifications({ silent: true });
|
||||
void loadPendingUploads();
|
||||
}, 90000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [eventToken, loadNotifications, loadPendingUploads]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'guest-notification-refresh') {
|
||||
void loadNotifications({ silent: true });
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker?.addEventListener('message', handler);
|
||||
|
||||
return () => {
|
||||
navigator.serviceWorker?.removeEventListener('message', handler);
|
||||
};
|
||||
}, [loadNotifications]);
|
||||
|
||||
const markAsRead = React.useCallback(
|
||||
async (id: number) => {
|
||||
if (!eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
let decremented = false;
|
||||
setNotifications((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== id) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (item.status === 'new') {
|
||||
decremented = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
status: 'read',
|
||||
readAt: new Date().toISOString(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (decremented) {
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
}
|
||||
|
||||
try {
|
||||
await markGuestNotificationRead(eventToken, id);
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notification as read', error);
|
||||
void loadNotifications({ silent: true });
|
||||
}
|
||||
},
|
||||
[eventToken, loadNotifications]
|
||||
);
|
||||
|
||||
const dismiss = React.useCallback(
|
||||
async (id: number) => {
|
||||
if (!eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
let decremented = false;
|
||||
setNotifications((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== id) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (item.status === 'new') {
|
||||
decremented = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
status: 'dismissed',
|
||||
dismissedAt: new Date().toISOString(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (decremented) {
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
}
|
||||
|
||||
try {
|
||||
await dismissGuestNotification(eventToken, id);
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss notification', error);
|
||||
void loadNotifications({ silent: true });
|
||||
}
|
||||
},
|
||||
[eventToken, loadNotifications]
|
||||
);
|
||||
|
||||
const setFilters = React.useCallback((next: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => {
|
||||
setFiltersState((prev) => ({ ...prev, ...next }));
|
||||
void loadNotifications({ silent: true });
|
||||
}, [loadNotifications]);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
await Promise.all([loadNotifications(), refreshQueue(), loadPendingUploads()]);
|
||||
}, [loadNotifications, refreshQueue, loadPendingUploads]);
|
||||
|
||||
const loading = loadingNotifications || queueLoading || pendingLoading;
|
||||
React.useEffect(() => {
|
||||
void updateAppBadge(unreadCount);
|
||||
}, [unreadCount]);
|
||||
|
||||
const value: NotificationCenterValue = {
|
||||
notifications,
|
||||
unreadCount,
|
||||
queueItems: items,
|
||||
queueCount,
|
||||
pendingCount,
|
||||
loading,
|
||||
pendingLoading,
|
||||
refresh,
|
||||
setFilters,
|
||||
markAsRead,
|
||||
dismiss,
|
||||
eventToken,
|
||||
lastFetchedAt,
|
||||
isOffline,
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationCenterContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationCenterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotificationCenter(): NotificationCenterValue {
|
||||
const ctx = React.useContext(NotificationCenterContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useNotificationCenter must be used within NotificationCenterProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalNotificationCenter(): NotificationCenterValue | null {
|
||||
return React.useContext(NotificationCenterContext);
|
||||
}
|
||||
93
resources/js/shared/guest/hooks/useGuestTaskProgress.ts
Normal file
93
resources/js/shared/guest/hooks/useGuestTaskProgress.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
|
||||
export const TASK_BADGE_TARGET = 5;
|
||||
|
||||
function storageKey(eventKey: string) {
|
||||
return `guestTasks_${eventKey}`;
|
||||
}
|
||||
|
||||
function parseStored(value: string | null) {
|
||||
if (!value) {
|
||||
return [] as number[];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((item) => Number.isInteger(item)) as number[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse task progress from storage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function useGuestTaskProgress(eventKey: string | undefined) {
|
||||
const [completed, setCompleted] = React.useState<number[]>([]);
|
||||
const [hydrated, setHydrated] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!eventKey) {
|
||||
setCompleted([]);
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey(eventKey));
|
||||
setCompleted(parseStored(stored));
|
||||
} catch (error) {
|
||||
console.warn('Failed to read task progress', error);
|
||||
setCompleted([]);
|
||||
} finally {
|
||||
setHydrated(true);
|
||||
}
|
||||
}, [eventKey]);
|
||||
|
||||
const markCompleted = React.useCallback(
|
||||
(taskId: number) => {
|
||||
if (!eventKey || !Number.isInteger(taskId)) {
|
||||
return;
|
||||
}
|
||||
setCompleted((prev) => {
|
||||
if (prev.includes(taskId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev, taskId];
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(eventKey), JSON.stringify(next));
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist task progress', error);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[eventKey]
|
||||
);
|
||||
|
||||
const clearProgress = React.useCallback(() => {
|
||||
if (!eventKey) return;
|
||||
setCompleted([]);
|
||||
try {
|
||||
window.localStorage.removeItem(storageKey(eventKey));
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear task progress', error);
|
||||
}
|
||||
}, [eventKey]);
|
||||
|
||||
const isCompleted = React.useCallback(
|
||||
(taskId: number | null | undefined) => {
|
||||
if (!Number.isInteger(taskId)) return false;
|
||||
return completed.includes(taskId as number);
|
||||
},
|
||||
[completed]
|
||||
);
|
||||
|
||||
return {
|
||||
hydrated,
|
||||
completed,
|
||||
completedCount: completed.length,
|
||||
markCompleted,
|
||||
clearProgress,
|
||||
isCompleted,
|
||||
};
|
||||
}
|
||||
19
resources/js/shared/guest/hooks/useHapticsPreference.ts
Normal file
19
resources/js/shared/guest/hooks/useHapticsPreference.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { getHapticsPreference, setHapticsPreference, supportsHaptics } from '../lib/haptics';
|
||||
|
||||
export function useHapticsPreference() {
|
||||
const [enabled, setEnabledState] = React.useState(() => getHapticsPreference());
|
||||
const [supported, setSupported] = React.useState(() => supportsHaptics());
|
||||
|
||||
React.useEffect(() => {
|
||||
setEnabledState(getHapticsPreference());
|
||||
setSupported(supportsHaptics());
|
||||
}, []);
|
||||
|
||||
const setEnabled = React.useCallback((value: boolean) => {
|
||||
setHapticsPreference(value);
|
||||
setEnabledState(value);
|
||||
}, []);
|
||||
|
||||
return { enabled, setEnabled, supported };
|
||||
}
|
||||
229
resources/js/shared/guest/hooks/useLiveShowPlayback.ts
Normal file
229
resources/js/shared/guest/hooks/useLiveShowPlayback.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { LiveShowLayoutMode, LiveShowPhoto, LiveShowSettings } from '../services/liveShowApi';
|
||||
|
||||
const MIN_FIXED_SECONDS = 3;
|
||||
const MAX_FIXED_SECONDS = 20;
|
||||
|
||||
function resolveApprovedAt(photo: LiveShowPhoto): number {
|
||||
if (!photo.approved_at) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = Date.parse(photo.approved_at);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function resolvePriority(photo: LiveShowPhoto): number {
|
||||
return Number.isFinite(photo.live_priority) ? photo.live_priority : 0;
|
||||
}
|
||||
|
||||
export function resolveItemsPerFrame(layout: LiveShowLayoutMode): number {
|
||||
switch (layout) {
|
||||
case 'split':
|
||||
return 2;
|
||||
case 'grid_burst':
|
||||
return 4;
|
||||
case 'single':
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveIntervalMs(settings: LiveShowSettings, totalCount: number): number {
|
||||
if (settings.pace_mode === 'fixed') {
|
||||
const safeSeconds = Math.min(MAX_FIXED_SECONDS, Math.max(MIN_FIXED_SECONDS, settings.fixed_interval_seconds));
|
||||
return safeSeconds * 1000;
|
||||
}
|
||||
|
||||
if (totalCount >= 60) return 4500;
|
||||
if (totalCount >= 30) return 5500;
|
||||
if (totalCount >= 15) return 6500;
|
||||
if (totalCount >= 6) return 7500;
|
||||
return 9000;
|
||||
}
|
||||
|
||||
export function resolvePlaybackQueue(photos: LiveShowPhoto[], settings: LiveShowSettings): LiveShowPhoto[] {
|
||||
if (photos.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const newestFirst = [...photos].sort((a, b) => {
|
||||
const timeDiff = resolveApprovedAt(b) - resolveApprovedAt(a);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return b.id - a.id;
|
||||
});
|
||||
|
||||
if (settings.playback_mode === 'newest_first') {
|
||||
return newestFirst;
|
||||
}
|
||||
|
||||
if (settings.playback_mode === 'curated') {
|
||||
const curated = photos.filter((photo) => photo.is_featured || resolvePriority(photo) > 0);
|
||||
const base = curated.length > 0 ? curated : photos;
|
||||
return [...base].sort((a, b) => {
|
||||
const priorityDiff = resolvePriority(b) - resolvePriority(a);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
const timeDiff = resolveApprovedAt(b) - resolveApprovedAt(a);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return b.id - a.id;
|
||||
});
|
||||
}
|
||||
|
||||
const oldestFirst = [...photos].sort((a, b) => {
|
||||
const timeDiff = resolveApprovedAt(a) - resolveApprovedAt(b);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
const balanced: LiveShowPhoto[] = [];
|
||||
const seen = new Set<number>();
|
||||
let newestIndex = 0;
|
||||
let oldestIndex = 0;
|
||||
let newestStreak = 0;
|
||||
|
||||
while (balanced.length < photos.length) {
|
||||
let added = false;
|
||||
|
||||
if (newestIndex < newestFirst.length && newestStreak < 2) {
|
||||
const candidate = newestFirst[newestIndex++];
|
||||
if (!seen.has(candidate.id)) {
|
||||
balanced.push(candidate);
|
||||
seen.add(candidate.id);
|
||||
newestStreak += 1;
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!added) {
|
||||
while (oldestIndex < oldestFirst.length && seen.has(oldestFirst[oldestIndex].id)) {
|
||||
oldestIndex += 1;
|
||||
}
|
||||
if (oldestIndex < oldestFirst.length) {
|
||||
const candidate = oldestFirst[oldestIndex++];
|
||||
balanced.push(candidate);
|
||||
seen.add(candidate.id);
|
||||
newestStreak = 0;
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!added) {
|
||||
while (newestIndex < newestFirst.length && seen.has(newestFirst[newestIndex].id)) {
|
||||
newestIndex += 1;
|
||||
}
|
||||
if (newestIndex < newestFirst.length) {
|
||||
const candidate = newestFirst[newestIndex++];
|
||||
balanced.push(candidate);
|
||||
seen.add(candidate.id);
|
||||
newestStreak += 1;
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!added) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return balanced;
|
||||
}
|
||||
|
||||
export function buildFramePhotos(
|
||||
queue: LiveShowPhoto[],
|
||||
startIndex: number,
|
||||
itemsPerFrame: number
|
||||
): LiveShowPhoto[] {
|
||||
if (queue.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const safeCount = Math.min(itemsPerFrame, queue.length);
|
||||
const result: LiveShowPhoto[] = [];
|
||||
for (let offset = 0; offset < safeCount; offset += 1) {
|
||||
const idx = (startIndex + offset) % queue.length;
|
||||
result.push(queue[idx]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export type LiveShowPlaybackState = {
|
||||
frame: LiveShowPhoto[];
|
||||
layout: LiveShowLayoutMode;
|
||||
intervalMs: number;
|
||||
frameKey: string;
|
||||
nextFrame: LiveShowPhoto[];
|
||||
};
|
||||
|
||||
export function useLiveShowPlayback(
|
||||
photos: LiveShowPhoto[],
|
||||
settings: LiveShowSettings,
|
||||
options: { paused?: boolean } = {}
|
||||
): LiveShowPlaybackState {
|
||||
const queue = useMemo(() => resolvePlaybackQueue(photos, settings), [photos, settings]);
|
||||
const layout = settings.layout_mode;
|
||||
const itemsPerFrame = resolveItemsPerFrame(layout);
|
||||
const [index, setIndex] = useState(0);
|
||||
const currentIdRef = useRef<number | null>(null);
|
||||
const paused = Boolean(options.paused);
|
||||
|
||||
useEffect(() => {
|
||||
if (queue.length === 0) {
|
||||
setIndex(0);
|
||||
currentIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentIdRef.current !== null) {
|
||||
const existingIndex = queue.findIndex((photo) => photo.id === currentIdRef.current);
|
||||
if (existingIndex >= 0) {
|
||||
setIndex(existingIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIndex((prev) => prev % queue.length);
|
||||
}, [queue]);
|
||||
|
||||
const frame = useMemo(() => {
|
||||
const framePhotos = buildFramePhotos(queue, index, itemsPerFrame);
|
||||
currentIdRef.current = framePhotos[0]?.id ?? null;
|
||||
return framePhotos;
|
||||
}, [queue, index, itemsPerFrame]);
|
||||
|
||||
const frameKey = useMemo(() => {
|
||||
if (frame.length === 0) {
|
||||
return `empty-${layout}`;
|
||||
}
|
||||
|
||||
return frame.map((photo) => photo.id).join('-');
|
||||
}, [frame, layout]);
|
||||
|
||||
const nextFrame = useMemo(() => {
|
||||
if (queue.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return buildFramePhotos(queue, index + itemsPerFrame, itemsPerFrame);
|
||||
}, [index, itemsPerFrame, queue]);
|
||||
|
||||
const intervalMs = resolveIntervalMs(settings, queue.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (queue.length === 0 || paused) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setIndex((prev) => (prev + itemsPerFrame) % queue.length);
|
||||
}, intervalMs);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [intervalMs, itemsPerFrame, queue.length]);
|
||||
|
||||
return {
|
||||
frame,
|
||||
layout,
|
||||
intervalMs,
|
||||
frameKey,
|
||||
nextFrame,
|
||||
};
|
||||
}
|
||||
317
resources/js/shared/guest/hooks/useLiveShowState.ts
Normal file
317
resources/js/shared/guest/hooks/useLiveShowState.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
buildLiveShowStreamUrl,
|
||||
DEFAULT_LIVE_SHOW_SETTINGS,
|
||||
fetchLiveShowState,
|
||||
fetchLiveShowUpdates,
|
||||
LiveShowCursor,
|
||||
LiveShowEvent,
|
||||
LiveShowPhoto,
|
||||
LiveShowSettings,
|
||||
LiveShowState,
|
||||
LiveShowError,
|
||||
} from '../services/liveShowApi';
|
||||
|
||||
export type LiveShowStatus = 'loading' | 'ready' | 'error';
|
||||
export type LiveShowConnection = 'idle' | 'sse' | 'polling';
|
||||
|
||||
const MAX_PHOTOS = 200;
|
||||
const POLL_INTERVAL_MS = 12_000;
|
||||
const POLL_HIDDEN_INTERVAL_MS = 30_000;
|
||||
|
||||
function mergePhotos(existing: LiveShowPhoto[], incoming: LiveShowPhoto[]): LiveShowPhoto[] {
|
||||
if (incoming.length === 0) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const byId = new Map<number, LiveShowPhoto>();
|
||||
existing.forEach((photo) => byId.set(photo.id, photo));
|
||||
incoming.forEach((photo) => {
|
||||
if (!byId.has(photo.id)) {
|
||||
byId.set(photo.id, photo);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(byId.values()).slice(-MAX_PHOTOS);
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown): string {
|
||||
if (error instanceof LiveShowError) {
|
||||
return error.message || 'Live Show konnte nicht geladen werden.';
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message || 'Live Show konnte nicht geladen werden.';
|
||||
}
|
||||
|
||||
return 'Live Show konnte nicht geladen werden.';
|
||||
}
|
||||
|
||||
function safeParseJson<T>(value: string): T | null {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
console.warn('Live show event payload parse failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type LiveShowStateResult = {
|
||||
status: LiveShowStatus;
|
||||
connection: LiveShowConnection;
|
||||
error: string | null;
|
||||
event: LiveShowEvent | null;
|
||||
settings: LiveShowSettings;
|
||||
settingsVersion: string;
|
||||
photos: LiveShowPhoto[];
|
||||
cursor: LiveShowCursor | null;
|
||||
};
|
||||
|
||||
export function useLiveShowState(token: string | null, limit = 50): LiveShowStateResult {
|
||||
const [status, setStatus] = useState<LiveShowStatus>('loading');
|
||||
const [connection, setConnection] = useState<LiveShowConnection>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [event, setEvent] = useState<LiveShowEvent | null>(null);
|
||||
const [settings, setSettings] = useState<LiveShowSettings>(DEFAULT_LIVE_SHOW_SETTINGS);
|
||||
const [settingsVersion, setSettingsVersion] = useState('');
|
||||
const [photos, setPhotos] = useState<LiveShowPhoto[]>([]);
|
||||
const [cursor, setCursor] = useState<LiveShowCursor | null>(null);
|
||||
const [visible, setVisible] = useState(
|
||||
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
|
||||
);
|
||||
const cursorRef = useRef<LiveShowCursor | null>(null);
|
||||
const settingsVersionRef = useRef<string>('');
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const pollingTimerRef = useRef<number | null>(null);
|
||||
const pollInFlight = useRef(false);
|
||||
|
||||
const updateCursor = useCallback((next: LiveShowCursor | null) => {
|
||||
cursorRef.current = next;
|
||||
setCursor(next);
|
||||
}, []);
|
||||
|
||||
const applySettings = useCallback((nextSettings: LiveShowSettings, nextVersion: string) => {
|
||||
setSettings(nextSettings);
|
||||
setSettingsVersion(nextVersion);
|
||||
settingsVersionRef.current = nextVersion;
|
||||
}, []);
|
||||
|
||||
const applyPhotos = useCallback(
|
||||
(incoming: LiveShowPhoto[], nextCursor?: LiveShowCursor | null) => {
|
||||
if (incoming.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPhotos((existing) => mergePhotos(existing, incoming));
|
||||
if (nextCursor) {
|
||||
updateCursor(nextCursor);
|
||||
}
|
||||
},
|
||||
[updateCursor]
|
||||
);
|
||||
|
||||
const closeEventSource = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearPolling = useCallback(() => {
|
||||
if (pollingTimerRef.current !== null) {
|
||||
window.clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pollUpdates = useCallback(async () => {
|
||||
if (!token || pollInFlight.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
pollInFlight.current = true;
|
||||
try {
|
||||
const update = await fetchLiveShowUpdates(token, {
|
||||
cursor: cursorRef.current,
|
||||
settingsVersion: settingsVersionRef.current,
|
||||
limit,
|
||||
});
|
||||
|
||||
if (update.settings) {
|
||||
applySettings(update.settings, update.settings_version);
|
||||
} else if (update.settings_version && update.settings_version !== settingsVersionRef.current) {
|
||||
settingsVersionRef.current = update.settings_version;
|
||||
setSettingsVersion(update.settings_version);
|
||||
}
|
||||
|
||||
if (update.photos.length > 0) {
|
||||
applyPhotos(update.photos, update.cursor ?? cursorRef.current);
|
||||
} else if (update.cursor) {
|
||||
updateCursor(update.cursor);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Live show polling error:', err);
|
||||
} finally {
|
||||
pollInFlight.current = false;
|
||||
}
|
||||
}, [applyPhotos, applySettings, limit, token, updateCursor]);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
clearPolling();
|
||||
setConnection('polling');
|
||||
void pollUpdates();
|
||||
const interval = visible ? POLL_INTERVAL_MS : POLL_HIDDEN_INTERVAL_MS;
|
||||
pollingTimerRef.current = window.setInterval(() => {
|
||||
void pollUpdates();
|
||||
}, interval);
|
||||
}, [clearPolling, pollUpdates, visible]);
|
||||
|
||||
const startSse = useCallback(() => {
|
||||
if (!token || typeof EventSource === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
closeEventSource();
|
||||
|
||||
const url = buildLiveShowStreamUrl(token, {
|
||||
cursor: cursorRef.current,
|
||||
settingsVersion: settingsVersionRef.current,
|
||||
limit,
|
||||
});
|
||||
|
||||
try {
|
||||
const stream = new EventSource(url);
|
||||
eventSourceRef.current = stream;
|
||||
setConnection('sse');
|
||||
|
||||
stream.addEventListener('settings.updated', (event) => {
|
||||
const payload = safeParseJson<{
|
||||
settings?: LiveShowSettings;
|
||||
settings_version?: string;
|
||||
}>((event as MessageEvent<string>).data);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
if (payload.settings && payload.settings_version) {
|
||||
applySettings(payload.settings, payload.settings_version);
|
||||
}
|
||||
});
|
||||
|
||||
stream.addEventListener('photo.approved', (event) => {
|
||||
const payload = safeParseJson<{
|
||||
photo?: LiveShowPhoto;
|
||||
cursor?: LiveShowCursor | null;
|
||||
}>((event as MessageEvent<string>).data);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
if (payload.photo) {
|
||||
applyPhotos([payload.photo], payload.cursor ?? null);
|
||||
} else if (payload.cursor) {
|
||||
updateCursor(payload.cursor);
|
||||
}
|
||||
});
|
||||
|
||||
stream.addEventListener('error', () => {
|
||||
closeEventSource();
|
||||
startPolling();
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('Live show SSE failed:', err);
|
||||
closeEventSource();
|
||||
return false;
|
||||
}
|
||||
}, [applyPhotos, applySettings, closeEventSource, limit, startPolling, token, updateCursor]);
|
||||
|
||||
const startStreaming = useCallback(() => {
|
||||
clearPolling();
|
||||
|
||||
if (!startSse()) {
|
||||
startPolling();
|
||||
}
|
||||
}, [clearPolling, startPolling, startSse]);
|
||||
|
||||
useEffect(() => {
|
||||
const onVisibility = () => setVisible(document.visibilityState === 'visible');
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
return () => document.removeEventListener('visibilitychange', onVisibility);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection !== 'polling') {
|
||||
return;
|
||||
}
|
||||
|
||||
startPolling();
|
||||
}, [connection, startPolling, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('error');
|
||||
setError('Live Show konnte nicht geladen werden.');
|
||||
setEvent(null);
|
||||
setPhotos([]);
|
||||
updateCursor(null);
|
||||
setSettings(DEFAULT_LIVE_SHOW_SETTINGS);
|
||||
setSettingsVersion('');
|
||||
setConnection('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
setConnection('idle');
|
||||
|
||||
try {
|
||||
const data: LiveShowState = await fetchLiveShowState(token, limit);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvent(data.event);
|
||||
setPhotos(data.photos);
|
||||
updateCursor(data.cursor);
|
||||
applySettings(data.settings, data.settings_version);
|
||||
setStatus('ready');
|
||||
startStreaming();
|
||||
} catch (err) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
setError(resolveErrorMessage(err));
|
||||
setConnection('idle');
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
closeEventSource();
|
||||
clearPolling();
|
||||
setConnection('idle');
|
||||
};
|
||||
}, [applySettings, clearPolling, closeEventSource, limit, startStreaming, token, updateCursor]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
status,
|
||||
connection,
|
||||
error,
|
||||
event,
|
||||
settings,
|
||||
settingsVersion,
|
||||
photos,
|
||||
cursor,
|
||||
}),
|
||||
[status, connection, error, event, settings, settingsVersion, photos, cursor]
|
||||
);
|
||||
}
|
||||
124
resources/js/shared/guest/i18n/LocaleContext.tsx
Normal file
124
resources/js/shared/guest/i18n/LocaleContext.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type LocaleCode, isLocaleCode } from './messages';
|
||||
|
||||
export interface LocaleContextValue {
|
||||
locale: LocaleCode;
|
||||
setLocale: (next: LocaleCode) => void;
|
||||
resetLocale: () => void;
|
||||
hydrated: boolean;
|
||||
defaultLocale: LocaleCode;
|
||||
storageKey: string;
|
||||
availableLocales: typeof SUPPORTED_LOCALES;
|
||||
}
|
||||
|
||||
const LocaleContext = React.createContext<LocaleContextValue | undefined>(undefined);
|
||||
|
||||
function sanitizeLocale(value: string | null | undefined, fallback: LocaleCode = DEFAULT_LOCALE): LocaleCode {
|
||||
if (value && isLocaleCode(value)) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface LocaleProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultLocale?: LocaleCode;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function LocaleProvider({
|
||||
children,
|
||||
defaultLocale = DEFAULT_LOCALE,
|
||||
storageKey = 'guestLocale_global',
|
||||
}: LocaleProviderProps) {
|
||||
const resolvedDefault = sanitizeLocale(defaultLocale, DEFAULT_LOCALE);
|
||||
const [locale, setLocaleState] = React.useState<LocaleCode>(resolvedDefault);
|
||||
const [userLocale, setUserLocale] = React.useState<LocaleCode | null>(null);
|
||||
const [hydrated, setHydrated] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHydrated(false);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
setLocaleState(resolvedDefault);
|
||||
setUserLocale(null);
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = window.localStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
console.warn('Failed to read stored locale', error);
|
||||
}
|
||||
|
||||
const nextLocale = sanitizeLocale(stored, resolvedDefault);
|
||||
setLocaleState(nextLocale);
|
||||
setUserLocale(isLocaleCode(stored) ? stored : null);
|
||||
setHydrated(true);
|
||||
}, [storageKey, resolvedDefault]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hydrated || userLocale !== null) {
|
||||
return;
|
||||
}
|
||||
setLocaleState(resolvedDefault);
|
||||
}, [hydrated, userLocale, resolvedDefault]);
|
||||
|
||||
const setLocale = React.useCallback(
|
||||
(next: LocaleCode) => {
|
||||
const safeLocale = sanitizeLocale(next, resolvedDefault);
|
||||
setLocaleState(safeLocale);
|
||||
setUserLocale(safeLocale);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, safeLocale);
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist locale', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[storageKey, resolvedDefault],
|
||||
);
|
||||
|
||||
const resetLocale = React.useCallback(() => {
|
||||
setUserLocale(null);
|
||||
setLocaleState(resolvedDefault);
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear stored locale', error);
|
||||
}
|
||||
}
|
||||
}, [resolvedDefault, storageKey]);
|
||||
|
||||
const value = React.useMemo<LocaleContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale,
|
||||
resetLocale,
|
||||
hydrated,
|
||||
defaultLocale: resolvedDefault,
|
||||
storageKey,
|
||||
availableLocales: SUPPORTED_LOCALES,
|
||||
}),
|
||||
[locale, setLocale, resetLocale, hydrated, resolvedDefault, storageKey],
|
||||
);
|
||||
|
||||
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
|
||||
}
|
||||
|
||||
export function useLocale(): LocaleContextValue {
|
||||
const ctx = React.useContext(LocaleContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useLocale must be used within a LocaleProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalLocale(): LocaleContextValue | undefined {
|
||||
return React.useContext(LocaleContext);
|
||||
}
|
||||
1885
resources/js/shared/guest/i18n/messages.ts
Normal file
1885
resources/js/shared/guest/i18n/messages.ts
Normal file
File diff suppressed because it is too large
Load Diff
49
resources/js/shared/guest/i18n/useTranslation.ts
Normal file
49
resources/js/shared/guest/i18n/useTranslation.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { translate, DEFAULT_LOCALE, type LocaleCode } from './messages';
|
||||
import { useLocale } from './LocaleContext';
|
||||
|
||||
type ReplacementValues = Record<string, string | number>;
|
||||
|
||||
export type TranslateFn = {
|
||||
(key: string): string;
|
||||
(key: string, fallback: string): string;
|
||||
(key: string, replacements: ReplacementValues): string;
|
||||
(key: string, replacements: ReplacementValues, fallback: string): string;
|
||||
};
|
||||
|
||||
function resolveTranslation(locale: LocaleCode, key: string, fallback?: string): string {
|
||||
return translate(locale, key) ?? translate(DEFAULT_LOCALE, key) ?? fallback ?? key;
|
||||
}
|
||||
|
||||
function applyReplacements(value: string, replacements?: ReplacementValues): string {
|
||||
if (!replacements) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.entries(replacements).reduce((acc, [token, replacement]) => {
|
||||
const pattern = new RegExp(`\\{${token}\\}`, 'g');
|
||||
return acc.replace(pattern, String(replacement));
|
||||
}, value);
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const { locale } = useLocale();
|
||||
|
||||
const t = React.useCallback<TranslateFn>((key: string, arg2?: ReplacementValues | string, arg3?: string) => {
|
||||
let replacements: ReplacementValues | undefined;
|
||||
let fallback: string | undefined;
|
||||
|
||||
if (typeof arg2 === 'string' || arg2 === undefined) {
|
||||
fallback = arg2 ?? arg3;
|
||||
} else {
|
||||
replacements = arg2;
|
||||
fallback = arg3;
|
||||
}
|
||||
|
||||
const raw = resolveTranslation(locale, key, fallback);
|
||||
|
||||
return applyReplacements(raw, replacements);
|
||||
}, [locale]);
|
||||
|
||||
return React.useMemo(() => ({ t, locale }), [t, locale]);
|
||||
}
|
||||
34
resources/js/shared/guest/lib/analyticsConsent.ts
Normal file
34
resources/js/shared/guest/lib/analyticsConsent.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type AnalyticsNudgeState = {
|
||||
decisionMade: boolean;
|
||||
analyticsConsent: boolean;
|
||||
snoozedUntil: number | null;
|
||||
now: number;
|
||||
activeSeconds: number;
|
||||
routeCount: number;
|
||||
thresholdSeconds: number;
|
||||
thresholdRoutes: number;
|
||||
isUpload: boolean;
|
||||
};
|
||||
|
||||
export function isUploadPath(pathname: string): boolean {
|
||||
return /\/upload(?:\/|$)/.test(pathname);
|
||||
}
|
||||
|
||||
export function shouldShowAnalyticsNudge(state: AnalyticsNudgeState): boolean {
|
||||
if (state.decisionMade || state.analyticsConsent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.isUpload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.snoozedUntil && state.snoozedUntil > state.now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
state.activeSeconds >= state.thresholdSeconds &&
|
||||
state.routeCount >= state.thresholdRoutes
|
||||
);
|
||||
}
|
||||
46
resources/js/shared/guest/lib/badges.ts
Normal file
46
resources/js/shared/guest/lib/badges.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
type BadgingNavigator = Navigator & {
|
||||
setAppBadge?: (contents?: number) => Promise<void> | void;
|
||||
clearAppBadge?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
function getNavigator(): BadgingNavigator | null {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return navigator as BadgingNavigator;
|
||||
}
|
||||
|
||||
export function supportsBadging(): boolean {
|
||||
const nav = getNavigator();
|
||||
return Boolean(nav && (typeof nav.setAppBadge === 'function' || typeof nav.clearAppBadge === 'function'));
|
||||
}
|
||||
|
||||
export async function updateAppBadge(count: number): Promise<void> {
|
||||
const nav = getNavigator();
|
||||
if (!nav) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeCount = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0;
|
||||
if (!supportsBadging()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (safeCount > 0 && nav.setAppBadge) {
|
||||
await nav.setAppBadge(safeCount);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nav.clearAppBadge) {
|
||||
await nav.clearAppBadge();
|
||||
return;
|
||||
}
|
||||
|
||||
if (nav.setAppBadge) {
|
||||
await nav.setAppBadge(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Updating app badge failed', error);
|
||||
}
|
||||
}
|
||||
50
resources/js/shared/guest/lib/color.ts
Normal file
50
resources/js/shared/guest/lib/color.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const normalized = hex.trim();
|
||||
if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let r: number;
|
||||
let g: number;
|
||||
let b: number;
|
||||
|
||||
if (normalized.length === 4) {
|
||||
r = parseInt(normalized[1] + normalized[1], 16);
|
||||
g = parseInt(normalized[2] + normalized[2], 16);
|
||||
b = parseInt(normalized[3] + normalized[3], 16);
|
||||
} else {
|
||||
r = parseInt(normalized.slice(1, 3), 16);
|
||||
g = parseInt(normalized.slice(3, 5), 16);
|
||||
b = parseInt(normalized.slice(5, 7), 16);
|
||||
}
|
||||
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
export function relativeLuminance(hex: string): number {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalize = (channel: number) => {
|
||||
const c = channel / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
const r = normalize(rgb.r);
|
||||
const g = normalize(rgb.g);
|
||||
const b = normalize(rgb.b);
|
||||
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
export function getContrastingTextColor(
|
||||
backgroundHex: string,
|
||||
lightColor = '#ffffff',
|
||||
darkColor = '#0f172a',
|
||||
): string {
|
||||
const luminance = relativeLuminance(backgroundHex);
|
||||
|
||||
return luminance > 0.5 ? darkColor : lightColor;
|
||||
}
|
||||
49
resources/js/shared/guest/lib/csrf.ts
Normal file
49
resources/js/shared/guest/lib/csrf.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getDeviceId } from './device';
|
||||
|
||||
function getCsrfToken(): string | null {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metaToken = document.querySelector('meta[name="csrf-token"]');
|
||||
if (metaToken instanceof HTMLMetaElement) {
|
||||
return metaToken.getAttribute('content') || null;
|
||||
}
|
||||
|
||||
const name = 'XSRF-TOKEN=';
|
||||
const decodedCookie = decodeURIComponent(document.cookie ?? '');
|
||||
const parts = decodedCookie.split(';');
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trimStart();
|
||||
if (!trimmed.startsWith(name)) {
|
||||
continue;
|
||||
}
|
||||
const token = trimmed.substring(name.length);
|
||||
try {
|
||||
return decodeURIComponent(atob(token));
|
||||
} catch {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCsrfHeaders(deviceId?: string): Record<string, string> {
|
||||
const token = getCsrfToken();
|
||||
const resolvedDeviceId = deviceId ?? (typeof window !== 'undefined' ? getDeviceId() : undefined);
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
if (resolvedDeviceId) {
|
||||
headers['X-Device-Id'] = resolvedDeviceId;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['X-CSRF-TOKEN'] = token;
|
||||
headers['X-XSRF-TOKEN'] = token;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
19
resources/js/shared/guest/lib/device.ts
Normal file
19
resources/js/shared/guest/lib/device.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function getDeviceId(): string {
|
||||
const KEY = 'device-id';
|
||||
let id = localStorage.getItem(KEY);
|
||||
if (!id) {
|
||||
id = genId();
|
||||
localStorage.setItem(KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function genId() {
|
||||
// Simple UUID v4-ish generator
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf) >> 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
128
resources/js/shared/guest/lib/emotionTheme.ts
Normal file
128
resources/js/shared/guest/lib/emotionTheme.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export type EmotionTheme = {
|
||||
gradientClass: string;
|
||||
gradientBackground: string;
|
||||
suggestionGradient: string;
|
||||
suggestionBorder: string;
|
||||
};
|
||||
|
||||
export type EmotionIdentity = {
|
||||
slug?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
const themeFreude: EmotionTheme = {
|
||||
gradientClass: 'from-amber-300 via-orange-400 to-rose-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #fde68a, #fb923c, #fb7185)',
|
||||
suggestionGradient: 'from-amber-50 via-white to-orange-50 dark:from-amber-500/20 dark:via-gray-900 dark:to-orange-500/10',
|
||||
suggestionBorder: 'border-amber-200/60 dark:border-amber-400/30',
|
||||
};
|
||||
|
||||
const themeLiebe: EmotionTheme = {
|
||||
gradientClass: 'from-rose-400 via-fuchsia-500 to-purple-600',
|
||||
gradientBackground: 'linear-gradient(135deg, #fb7185, #ec4899, #9333ea)',
|
||||
suggestionGradient: 'from-rose-50 via-white to-fuchsia-50 dark:from-rose-500/20 dark:via-gray-900 dark:to-fuchsia-500/10',
|
||||
suggestionBorder: 'border-rose-200/60 dark:border-rose-400/30',
|
||||
};
|
||||
|
||||
const themeEkstase: EmotionTheme = {
|
||||
gradientClass: 'from-fuchsia-400 via-purple-500 to-indigo-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #f472b6, #a855f7, #6366f1)',
|
||||
suggestionGradient: 'from-fuchsia-50 via-white to-indigo-50 dark:from-fuchsia-500/20 dark:via-gray-900 dark:to-indigo-500/10',
|
||||
suggestionBorder: 'border-fuchsia-200/60 dark:border-fuchsia-400/30',
|
||||
};
|
||||
|
||||
const themeEntspannt: EmotionTheme = {
|
||||
gradientClass: 'from-teal-300 via-emerald-400 to-cyan-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #5eead4, #34d399, #22d3ee)',
|
||||
suggestionGradient: 'from-teal-50 via-white to-emerald-50 dark:from-teal-500/20 dark:via-gray-900 dark:to-emerald-500/10',
|
||||
suggestionBorder: 'border-emerald-200/60 dark:border-emerald-400/30',
|
||||
};
|
||||
|
||||
const themeBesinnlich: EmotionTheme = {
|
||||
gradientClass: 'from-slate-500 via-blue-500 to-indigo-600',
|
||||
gradientBackground: 'linear-gradient(135deg, #64748b, #3b82f6, #4f46e5)',
|
||||
suggestionGradient: 'from-slate-50 via-white to-blue-50 dark:from-slate-600/20 dark:via-gray-900 dark:to-blue-500/10',
|
||||
suggestionBorder: 'border-slate-200/60 dark:border-slate-500/30',
|
||||
};
|
||||
|
||||
const themeUeberraschung: EmotionTheme = {
|
||||
gradientClass: 'from-indigo-300 via-violet-500 to-rose-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #a5b4fc, #a855f7, #fb7185)',
|
||||
suggestionGradient: 'from-indigo-50 via-white to-violet-50 dark:from-indigo-500/20 dark:via-gray-900 dark:to-violet-500/10',
|
||||
suggestionBorder: 'border-indigo-200/60 dark:border-indigo-400/30',
|
||||
};
|
||||
|
||||
const themeDefault: EmotionTheme = {
|
||||
gradientClass: 'from-pink-500 via-purple-500 to-indigo-600',
|
||||
gradientBackground: 'linear-gradient(135deg, #ec4899, #a855f7, #4f46e5)',
|
||||
suggestionGradient: 'from-pink-50 via-white to-indigo-50 dark:from-pink-500/20 dark:via-gray-900 dark:to-indigo-500/10',
|
||||
suggestionBorder: 'border-pink-200/60 dark:border-pink-400/30',
|
||||
};
|
||||
|
||||
const EMOTION_THEMES: Record<string, EmotionTheme> = {
|
||||
freude: themeFreude,
|
||||
happy: themeFreude,
|
||||
liebe: themeLiebe,
|
||||
romance: themeLiebe,
|
||||
romantik: themeLiebe,
|
||||
nostalgie: themeEntspannt,
|
||||
relaxed: themeEntspannt,
|
||||
ruehrung: themeBesinnlich,
|
||||
traurigkeit: themeBesinnlich,
|
||||
teamgeist: themeFreude,
|
||||
gemeinschaft: themeFreude,
|
||||
ueberraschung: themeUeberraschung,
|
||||
surprise: themeUeberraschung,
|
||||
ekstase: themeEkstase,
|
||||
excited: themeEkstase,
|
||||
besinnlichkeit: themeBesinnlich,
|
||||
sad: themeBesinnlich,
|
||||
default: themeDefault,
|
||||
};
|
||||
|
||||
const EMOTION_ICONS: Record<string, string> = {
|
||||
freude: '😊',
|
||||
happy: '😊',
|
||||
liebe: '❤️',
|
||||
romantik: '💞',
|
||||
nostalgie: '📼',
|
||||
ruehrung: '🥲',
|
||||
teamgeist: '🤝',
|
||||
ueberraschung: '😲',
|
||||
surprise: '😲',
|
||||
ekstase: '🤩',
|
||||
besinnlichkeit: '🕯️',
|
||||
};
|
||||
|
||||
function sluggify(value?: string | null): string {
|
||||
return (value ?? '')
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveEmotionKey(identity?: EmotionIdentity | null): string {
|
||||
if (!identity) return 'default';
|
||||
const nameKey = sluggify(identity.name);
|
||||
if (nameKey && EMOTION_THEMES[nameKey]) {
|
||||
return nameKey;
|
||||
}
|
||||
const slugKey = sluggify(identity.slug);
|
||||
if (slugKey && EMOTION_THEMES[slugKey]) {
|
||||
return slugKey;
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
export function getEmotionTheme(identity?: EmotionIdentity | null): EmotionTheme {
|
||||
const key = resolveEmotionKey(identity);
|
||||
return EMOTION_THEMES[key] ?? themeDefault;
|
||||
}
|
||||
|
||||
export function getEmotionIcon(identity?: EmotionIdentity | null): string {
|
||||
const key = resolveEmotionKey(identity);
|
||||
return EMOTION_ICONS[key] ?? '✨';
|
||||
}
|
||||
8
resources/js/shared/guest/lib/engagement.ts
Normal file
8
resources/js/shared/guest/lib/engagement.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { EventData } from '../services/eventApi';
|
||||
|
||||
export function isTaskModeEnabled(event?: EventData | null): boolean {
|
||||
if (!event) return true;
|
||||
const mode = event.engagement_mode;
|
||||
if (!mode) return true;
|
||||
return mode === 'tasks';
|
||||
}
|
||||
62
resources/js/shared/guest/lib/haptics.ts
Normal file
62
resources/js/shared/guest/lib/haptics.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { prefersReducedMotion } from './motion';
|
||||
|
||||
export type HapticPattern = 'selection' | 'light' | 'medium' | 'success' | 'error';
|
||||
|
||||
const PATTERNS: Record<HapticPattern, number | number[]> = {
|
||||
selection: 10,
|
||||
light: 15,
|
||||
medium: 30,
|
||||
success: [10, 30, 10],
|
||||
error: [20, 30, 20],
|
||||
};
|
||||
|
||||
export const HAPTICS_STORAGE_KEY = 'guestHapticsEnabled';
|
||||
|
||||
export function supportsHaptics(): boolean {
|
||||
return typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
|
||||
}
|
||||
|
||||
export function getHapticsPreference(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(HAPTICS_STORAGE_KEY);
|
||||
if (raw === null) {
|
||||
return true;
|
||||
}
|
||||
return raw !== '0';
|
||||
} catch (error) {
|
||||
console.warn('Failed to read haptics preference', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function setHapticsPreference(enabled: boolean): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(HAPTICS_STORAGE_KEY, enabled ? '1' : '0');
|
||||
} catch (error) {
|
||||
console.warn('Failed to store haptics preference', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function isHapticsEnabled(): boolean {
|
||||
return getHapticsPreference() && supportsHaptics() && !prefersReducedMotion();
|
||||
}
|
||||
|
||||
export function triggerHaptic(pattern: HapticPattern): void {
|
||||
if (!isHapticsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
navigator.vibrate(PATTERNS[pattern]);
|
||||
} catch (error) {
|
||||
console.warn('Haptic feedback failed', error);
|
||||
}
|
||||
}
|
||||
97
resources/js/shared/guest/lib/image.ts
Normal file
97
resources/js/shared/guest/lib/image.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// @ts-nocheck
|
||||
export async function compressPhoto(
|
||||
file: File,
|
||||
opts: { targetBytes?: number; maxEdge?: number; qualityStart?: number } = {}
|
||||
): Promise<File> {
|
||||
const targetBytes = opts.targetBytes ?? 1_500_000; // 1.5 MB
|
||||
const maxEdge = opts.maxEdge ?? 2560;
|
||||
const qualityStart = opts.qualityStart ?? 0.85;
|
||||
|
||||
// If already small and jpeg, return as-is
|
||||
if (file.size <= targetBytes && file.type === 'image/jpeg') return file;
|
||||
|
||||
const img = await loadImageBitmap(file);
|
||||
const { width, height } = fitWithin(img.width, img.height, maxEdge);
|
||||
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas unsupported');
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Iteratively lower quality to fit target size
|
||||
let quality = qualityStart;
|
||||
let blob: Blob | null = await toBlob(canvas, 'image/jpeg', quality);
|
||||
if (!blob) throw new Error('Failed to encode image');
|
||||
|
||||
while (blob.size > targetBytes && quality > 0.5) {
|
||||
quality -= 0.05;
|
||||
const attempt = await toBlob(canvas, 'image/jpeg', quality);
|
||||
if (attempt) blob = attempt;
|
||||
else break;
|
||||
}
|
||||
|
||||
// If still too large, downscale further by 0.9 until it fits or edge < 800
|
||||
let currentWidth = width;
|
||||
let currentHeight = height;
|
||||
while (blob.size > targetBytes && Math.max(currentWidth, currentHeight) > 800) {
|
||||
currentWidth = Math.round(currentWidth * 0.9);
|
||||
currentHeight = Math.round(currentHeight * 0.9);
|
||||
const c2 = createCanvas(currentWidth, currentHeight);
|
||||
const c2ctx = c2.getContext('2d');
|
||||
if (!c2ctx) break;
|
||||
c2ctx.drawImage(canvas, 0, 0, currentWidth, currentHeight);
|
||||
const attempt = await toBlob(c2, 'image/jpeg', quality);
|
||||
if (attempt) blob = attempt;
|
||||
}
|
||||
|
||||
const outName = ensureJpegExtension(file.name);
|
||||
return new File([blob], outName, { type: 'image/jpeg', lastModified: Date.now() });
|
||||
}
|
||||
|
||||
function fitWithin(w: number, h: number, maxEdge: number) {
|
||||
const scale = Math.min(1, maxEdge / Math.max(w, h));
|
||||
return { width: Math.round(w * scale), height: Math.round(h * scale) };
|
||||
}
|
||||
|
||||
function createCanvas(w: number, h: number): HTMLCanvasElement {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h; return c;
|
||||
}
|
||||
|
||||
function toBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
|
||||
return new Promise(resolve => canvas.toBlob(resolve, type, quality));
|
||||
}
|
||||
|
||||
async function loadImageBitmap(file: File): Promise<CanvasImageSource> {
|
||||
const canBitmap = 'createImageBitmap' in window;
|
||||
|
||||
if (canBitmap) {
|
||||
try {
|
||||
return await createImageBitmap(file);
|
||||
} catch (error) {
|
||||
console.warn('Falling back to HTML image decode', error);
|
||||
}
|
||||
}
|
||||
|
||||
return await loadHtmlImage(file);
|
||||
}
|
||||
|
||||
function loadHtmlImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
|
||||
img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function ensureJpegExtension(name: string) {
|
||||
return name.replace(/\.(heic|heif|png|webp|jpg|jpeg)$/i, '') + '.jpg';
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
107
resources/js/shared/guest/lib/liveShowEffects.ts
Normal file
107
resources/js/shared/guest/lib/liveShowEffects.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { MotionProps, Transition } from 'framer-motion';
|
||||
import { IOS_EASE, IOS_EASE_SOFT } from './motion';
|
||||
import type { LiveShowEffectPreset } from '../services/liveShowApi';
|
||||
|
||||
export type LiveShowEffectSpec = {
|
||||
frame: MotionProps;
|
||||
flash?: MotionProps;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function resolveIntensity(intensity: number): number {
|
||||
const safe = Number.isFinite(intensity) ? intensity : 70;
|
||||
return clamp(safe / 100, 0, 1);
|
||||
}
|
||||
|
||||
function buildTransition(duration: number, ease: Transition['ease']): Transition {
|
||||
return {
|
||||
duration,
|
||||
ease,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLiveShowEffect(
|
||||
preset: LiveShowEffectPreset,
|
||||
intensity: number,
|
||||
reducedMotion: boolean
|
||||
): LiveShowEffectSpec {
|
||||
const strength = reducedMotion ? 0 : resolveIntensity(intensity);
|
||||
const baseDuration = reducedMotion ? 0.2 : clamp(0.9 - strength * 0.35, 0.45, 1);
|
||||
const exitDuration = reducedMotion ? 0.15 : clamp(baseDuration * 0.6, 0.25, 0.6);
|
||||
|
||||
if (reducedMotion) {
|
||||
return {
|
||||
frame: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
|
||||
transition: buildTransition(baseDuration, IOS_EASE_SOFT),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
switch (preset) {
|
||||
case 'shutter_flash': {
|
||||
const scale = 1 + strength * 0.05;
|
||||
return {
|
||||
frame: {
|
||||
initial: { opacity: 0, scale, y: 12 * strength },
|
||||
animate: { opacity: 1, scale: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.98, transition: buildTransition(exitDuration, IOS_EASE) },
|
||||
transition: buildTransition(baseDuration, IOS_EASE),
|
||||
},
|
||||
flash: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: [0, 0.85, 0], transition: { duration: 0.5, times: [0, 0.2, 1] } },
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'polaroid_toss': {
|
||||
const rotation = 3 + strength * 5;
|
||||
return {
|
||||
frame: {
|
||||
initial: { opacity: 0, rotate: -rotation, scale: 0.9 },
|
||||
animate: { opacity: 1, rotate: 0, scale: 1 },
|
||||
exit: { opacity: 0, rotate: rotation * 0.5, scale: 0.98, transition: buildTransition(exitDuration, IOS_EASE) },
|
||||
transition: buildTransition(baseDuration, IOS_EASE),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'parallax_glide': {
|
||||
const scale = 1 + strength * 0.06;
|
||||
return {
|
||||
frame: {
|
||||
initial: { opacity: 0, scale, y: 24 * strength },
|
||||
animate: { opacity: 1, scale: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 1.02, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
|
||||
transition: buildTransition(baseDuration + 0.2, IOS_EASE_SOFT),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'light_effects': {
|
||||
return {
|
||||
frame: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
|
||||
transition: buildTransition(baseDuration * 0.8, IOS_EASE_SOFT),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'film_cut':
|
||||
default: {
|
||||
const scale = 1 + strength * 0.03;
|
||||
return {
|
||||
frame: {
|
||||
initial: { opacity: 0, scale },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.99, transition: buildTransition(exitDuration, IOS_EASE) },
|
||||
transition: buildTransition(baseDuration, IOS_EASE),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
51
resources/js/shared/guest/lib/localizeTaskLabel.ts
Normal file
51
resources/js/shared/guest/lib/localizeTaskLabel.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { LocaleCode } from '../i18n/messages';
|
||||
|
||||
type LocalizedRecord = Record<string, string | null | undefined>;
|
||||
|
||||
function pickLocalizedValue(record: LocalizedRecord, locale: LocaleCode): string | null {
|
||||
if (typeof record[locale] === 'string' && record[locale]) {
|
||||
return record[locale] as string;
|
||||
}
|
||||
|
||||
if (typeof record.de === 'string' && record.de) {
|
||||
return record.de as string;
|
||||
}
|
||||
|
||||
if (typeof record.en === 'string' && record.en) {
|
||||
return record.en as string;
|
||||
}
|
||||
|
||||
const firstValue = Object.values(record).find((value) => typeof value === 'string' && value);
|
||||
return (firstValue as string | undefined) ?? null;
|
||||
}
|
||||
|
||||
export function localizeTaskLabel(
|
||||
raw: string | LocalizedRecord | null | undefined,
|
||||
locale: LocaleCode,
|
||||
): string | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof raw === 'object') {
|
||||
return pickLocalizedValue(raw, locale);
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return pickLocalizedValue(parsed as LocalizedRecord, locale) ?? trimmed;
|
||||
}
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
86
resources/js/shared/guest/lib/motion.ts
Normal file
86
resources/js/shared/guest/lib/motion.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Variants } from 'framer-motion';
|
||||
|
||||
export const IOS_EASE = [0.22, 0.61, 0.36, 1] as const;
|
||||
export const IOS_EASE_SOFT = [0.25, 0.8, 0.25, 1] as const;
|
||||
|
||||
export const STAGGER_FAST: Variants = {
|
||||
hidden: {},
|
||||
show: {
|
||||
transition: {
|
||||
staggerChildren: 0.06,
|
||||
delayChildren: 0.04,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FADE_UP: Variants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.24,
|
||||
ease: IOS_EASE,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FADE_SCALE: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.98 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.22,
|
||||
ease: IOS_EASE,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches);
|
||||
}
|
||||
|
||||
export function getMotionContainerProps(enabled: boolean, variants: Variants) {
|
||||
if (!enabled) {
|
||||
return { initial: false } as const;
|
||||
}
|
||||
|
||||
return { variants, initial: 'hidden', animate: 'show' } as const;
|
||||
}
|
||||
|
||||
export function getMotionItemProps(enabled: boolean, variants: Variants) {
|
||||
return enabled ? { variants } : {};
|
||||
}
|
||||
|
||||
export function getMotionContainerPropsForNavigation(
|
||||
enabled: boolean,
|
||||
variants: Variants,
|
||||
navigationType: 'POP' | 'PUSH' | 'REPLACE'
|
||||
) {
|
||||
if (!enabled) {
|
||||
return { initial: false } as const;
|
||||
}
|
||||
|
||||
const initial = navigationType === 'POP' ? 'hidden' : false;
|
||||
|
||||
return { variants, initial, animate: 'show' } as const;
|
||||
}
|
||||
|
||||
export function getMotionItemPropsForNavigation(
|
||||
enabled: boolean,
|
||||
variants: Variants,
|
||||
navigationType: 'POP' | 'PUSH' | 'REPLACE'
|
||||
) {
|
||||
if (!enabled) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const initial = navigationType === 'POP' ? 'hidden' : false;
|
||||
|
||||
return { variants, initial, animate: 'show' } as const;
|
||||
}
|
||||
94
resources/js/shared/guest/lib/uploadErrorDialog.ts
Normal file
94
resources/js/shared/guest/lib/uploadErrorDialog.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { TranslateFn } from '../i18n/useTranslation';
|
||||
|
||||
export type UploadErrorDialog = {
|
||||
code: string;
|
||||
title: string;
|
||||
description: string;
|
||||
hint?: string;
|
||||
tone: 'danger' | 'warning' | 'info';
|
||||
};
|
||||
|
||||
function formatWithNumbers(template: string, values: Record<string, number | string | undefined>): string {
|
||||
return Object.entries(values).reduce((acc, [key, value]) => {
|
||||
if (value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
return acc.replaceAll(`{${key}}`, String(value));
|
||||
}, template);
|
||||
}
|
||||
|
||||
export function resolveUploadErrorDialog(
|
||||
code: string | undefined,
|
||||
meta: Record<string, unknown> | undefined,
|
||||
t: TranslateFn
|
||||
): UploadErrorDialog {
|
||||
const normalized = (code ?? 'unknown').toLowerCase();
|
||||
const getNumber = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined);
|
||||
|
||||
switch (normalized) {
|
||||
case 'photo_limit_exceeded': {
|
||||
const used = getNumber(meta?.used);
|
||||
const limit = getNumber(meta?.limit);
|
||||
const remaining = getNumber(meta?.remaining);
|
||||
|
||||
return {
|
||||
code: normalized,
|
||||
tone: 'danger',
|
||||
title: t('upload.dialogs.photoLimit.title'),
|
||||
description: formatWithNumbers(t('upload.dialogs.photoLimit.description'), {
|
||||
used,
|
||||
limit,
|
||||
remaining,
|
||||
}),
|
||||
hint: t('upload.dialogs.photoLimit.hint'),
|
||||
};
|
||||
}
|
||||
|
||||
case 'upload_device_limit':
|
||||
return {
|
||||
code: normalized,
|
||||
tone: 'warning',
|
||||
title: t('upload.dialogs.deviceLimit.title'),
|
||||
description: t('upload.dialogs.deviceLimit.description'),
|
||||
hint: t('upload.dialogs.deviceLimit.hint'),
|
||||
};
|
||||
|
||||
case 'event_package_missing':
|
||||
case 'event_not_found':
|
||||
return {
|
||||
code: normalized,
|
||||
tone: 'info',
|
||||
title: t('upload.dialogs.packageMissing.title'),
|
||||
description: t('upload.dialogs.packageMissing.description'),
|
||||
hint: t('upload.dialogs.packageMissing.hint'),
|
||||
};
|
||||
|
||||
case 'gallery_expired':
|
||||
return {
|
||||
code: normalized,
|
||||
tone: 'danger',
|
||||
title: t('upload.dialogs.galleryExpired.title'),
|
||||
description: t('upload.dialogs.galleryExpired.description'),
|
||||
hint: t('upload.dialogs.galleryExpired.hint'),
|
||||
};
|
||||
|
||||
case 'csrf_mismatch':
|
||||
return {
|
||||
code: normalized,
|
||||
tone: 'warning',
|
||||
title: t('upload.dialogs.csrf.title'),
|
||||
description: t('upload.dialogs.csrf.description'),
|
||||
hint: t('upload.dialogs.csrf.hint'),
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
code: normalized,
|
||||
tone: 'info',
|
||||
title: t('upload.dialogs.generic.title'),
|
||||
description: t('upload.dialogs.generic.description'),
|
||||
hint: t('upload.dialogs.generic.hint'),
|
||||
};
|
||||
}
|
||||
}
|
||||
47
resources/js/shared/guest/queue/hooks.ts
Normal file
47
resources/js/shared/guest/queue/hooks.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { enqueue, list, processQueue, clearDone, remove, type QueueItem } from './queue';
|
||||
|
||||
export function useUploadQueue() {
|
||||
const [items, setItems] = React.useState<QueueItem[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
const all = await list();
|
||||
setItems(all);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const add = React.useCallback(async (it: Parameters<typeof enqueue>[0]) => {
|
||||
await enqueue(it);
|
||||
await refresh();
|
||||
await processQueue();
|
||||
}, [refresh]);
|
||||
|
||||
const retryAll = React.useCallback(async () => {
|
||||
await processQueue();
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const clearFinished = React.useCallback(async () => {
|
||||
await clearDone();
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const removeItem = React.useCallback(
|
||||
async (id: number) => {
|
||||
await remove(id);
|
||||
await refresh();
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
refresh();
|
||||
const online = () => processQueue().then(refresh);
|
||||
window.addEventListener('online', online);
|
||||
return () => window.removeEventListener('online', online);
|
||||
}, [refresh]);
|
||||
|
||||
return { items, loading, refresh, add, retryAll, clearFinished, remove: removeItem } as const;
|
||||
}
|
||||
34
resources/js/shared/guest/queue/idb.ts
Normal file
34
resources/js/shared/guest/queue/idb.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// @ts-nocheck
|
||||
export type TxMode = 'readonly' | 'readwrite';
|
||||
|
||||
export function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('guest-upload-queue', 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains('items')) {
|
||||
const store = db.createObjectStore('items', { keyPath: 'id', autoIncrement: true });
|
||||
store.createIndex('status', 'status', { unique: false });
|
||||
store.createIndex('nextAttemptAt', 'nextAttemptAt', { unique: false });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function withStore<T>(mode: TxMode, fn: (store: IDBObjectStore) => void | Promise<T>): Promise<T> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('items', mode);
|
||||
const store = tx.objectStore('items');
|
||||
let result: unknown;
|
||||
const wrap = async () => {
|
||||
try { result = await fn(store); } catch (e) { reject(e); }
|
||||
};
|
||||
wrap();
|
||||
tx.oncomplete = () => resolve(result);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
16
resources/js/shared/guest/queue/notify.ts
Normal file
16
resources/js/shared/guest/queue/notify.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function notify(text: string, type: 'success'|'error') {
|
||||
// Lazy import to avoid cycle
|
||||
import('../components/ToastHost')
|
||||
.then(() => {
|
||||
try {
|
||||
// This only works inside React tree; for SW-triggered, we fallback
|
||||
const evt = new CustomEvent('guest-toast', { detail: { text, type } });
|
||||
window.dispatchEvent(evt);
|
||||
} catch (error) {
|
||||
console.warn('Dispatching toast event failed', error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Toast module failed to load', error);
|
||||
});
|
||||
}
|
||||
134
resources/js/shared/guest/queue/queue.ts
Normal file
134
resources/js/shared/guest/queue/queue.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// @ts-nocheck
|
||||
import { withStore } from './idb';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
import { createUpload } from './xhr';
|
||||
import { notify } from './notify';
|
||||
type SyncManager = { register(tag: string): Promise<void>; };
|
||||
|
||||
export const buildQueueUploadUrl = (eventToken: string) =>
|
||||
`/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
|
||||
|
||||
export type QueueItem = {
|
||||
id?: number;
|
||||
eventToken: string;
|
||||
fileName: string;
|
||||
blob: Blob;
|
||||
emotion_id?: number | null;
|
||||
task_id?: number | null;
|
||||
live_show_opt_in?: boolean | null;
|
||||
status: 'pending' | 'uploading' | 'done' | 'error';
|
||||
retries: number;
|
||||
nextAttemptAt?: number | null;
|
||||
createdAt: number;
|
||||
photoId?: number;
|
||||
};
|
||||
|
||||
let processing = false;
|
||||
|
||||
export async function enqueue(item: Omit<QueueItem, 'id' | 'status' | 'retries' | 'createdAt'>) {
|
||||
const now = Date.now();
|
||||
await withStore('readwrite', (store) => {
|
||||
store.add({ ...item, status: 'pending', retries: 0, createdAt: now });
|
||||
});
|
||||
// Register background sync if available
|
||||
if ('serviceWorker' in navigator && 'SyncManager' in window) {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
(reg as ServiceWorkerRegistration & { sync?: SyncManager }).sync?.register('upload-queue');
|
||||
} catch (error) {
|
||||
console.warn('Background sync registration failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function list(): Promise<QueueItem[]> {
|
||||
return withStore('readonly', (store) => new Promise((resolve) => {
|
||||
const req = store.getAll();
|
||||
req.onsuccess = () => resolve(req.result as QueueItem[]);
|
||||
}));
|
||||
}
|
||||
|
||||
export async function clearDone() {
|
||||
const items = await list();
|
||||
await withStore('readwrite', (store) => {
|
||||
for (const it of items) {
|
||||
if (it.status === 'done') store.delete(it.id!);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function remove(id: number) {
|
||||
await withStore('readwrite', (store) => {
|
||||
store.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function processQueue() {
|
||||
if (processing) return; processing = true;
|
||||
try {
|
||||
const now = Date.now();
|
||||
const items = await list();
|
||||
for (const it of items) {
|
||||
if (it.status === 'done') continue;
|
||||
if (it.nextAttemptAt && it.nextAttemptAt > now) continue;
|
||||
await markStatus(it.id!, 'uploading');
|
||||
const ok = await attemptUpload(it);
|
||||
if (ok) {
|
||||
await markStatus(it.id!, 'done');
|
||||
} else {
|
||||
const retries = (it.retries ?? 0) + 1;
|
||||
const backoffSec = Math.min(60, Math.pow(2, Math.min(retries, 5))); // 2,4,8,16,32,60
|
||||
await update(it.id!, { status: 'error', retries, nextAttemptAt: Date.now() + backoffSec * 1000 });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function attemptUpload(it: QueueItem): Promise<boolean> {
|
||||
if (!navigator.onLine) return false;
|
||||
try {
|
||||
const json = await createUpload(
|
||||
buildQueueUploadUrl(it.eventToken),
|
||||
it,
|
||||
getDeviceId(),
|
||||
(pct) => {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('queue-progress', { detail: { id: it.id, progress: pct } }));
|
||||
} catch (error) {
|
||||
console.warn('Queue progress dispatch failed', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
// mark my-photo-ids for "Meine"
|
||||
try {
|
||||
const raw = localStorage.getItem('my-photo-ids');
|
||||
const arr: number[] = raw ? JSON.parse(raw) : [];
|
||||
if (json.id && !arr.includes(json.id)) localStorage.setItem('my-photo-ids', JSON.stringify([json.id, ...arr]));
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist my-photo-ids', error);
|
||||
}
|
||||
notify('Upload erfolgreich', 'success');
|
||||
return true;
|
||||
} catch {
|
||||
notify('Upload fehlgeschlagen', 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markStatus(id: number, status: QueueItem['status']) {
|
||||
await update(id, { status });
|
||||
}
|
||||
|
||||
async function update(id: number, patch: Partial<QueueItem>) {
|
||||
await withStore('readwrite', (store) => new Promise<void>((resolve, reject) => {
|
||||
const getReq = store.get(id);
|
||||
getReq.onsuccess = () => {
|
||||
const val = getReq.result as QueueItem;
|
||||
store.put({ ...val, ...patch, id });
|
||||
resolve();
|
||||
};
|
||||
getReq.onerror = () => reject(getReq.error);
|
||||
}));
|
||||
}
|
||||
45
resources/js/shared/guest/queue/xhr.ts
Normal file
45
resources/js/shared/guest/queue/xhr.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { QueueItem } from './queue';
|
||||
import { buildCsrfHeaders } from '../lib/csrf';
|
||||
|
||||
export async function createUpload(
|
||||
url: string,
|
||||
it: QueueItem,
|
||||
deviceId: string,
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url, true);
|
||||
const headers = buildCsrfHeaders(deviceId);
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
const form = new FormData();
|
||||
form.append('photo', it.blob, it.fileName);
|
||||
if (it.emotion_id) form.append('emotion_id', String(it.emotion_id));
|
||||
if (it.task_id) form.append('task_id', String(it.task_id));
|
||||
if (typeof it.live_show_opt_in === 'boolean') {
|
||||
form.append('live_show_opt_in', it.live_show_opt_in ? '1' : '0');
|
||||
}
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
if (onProgress && ev.lengthComputable) {
|
||||
const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100));
|
||||
onProgress(pct);
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} catch (error) {
|
||||
console.warn('Upload response parse failed', error);
|
||||
resolve({});
|
||||
}
|
||||
} else {
|
||||
reject(new Error('upload failed'));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('network error'));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
271
resources/js/shared/guest/services/achievementApi.ts
Normal file
271
resources/js/shared/guest/services/achievementApi.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// @ts-nocheck
|
||||
import { getDeviceId } from '../lib/device';
|
||||
import { DEFAULT_LOCALE } from '../i18n/messages';
|
||||
|
||||
export interface AchievementBadge {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
earned: boolean;
|
||||
progress: number;
|
||||
target: number;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
guest: string;
|
||||
photos: number;
|
||||
likes: number;
|
||||
}
|
||||
|
||||
export interface TopPhotoHighlight {
|
||||
photoId: number;
|
||||
guest: string;
|
||||
likes: number;
|
||||
task?: string | null;
|
||||
createdAt: string;
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
export interface TrendingEmotionHighlight {
|
||||
emotionId: number;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TimelinePoint {
|
||||
date: string;
|
||||
photos: number;
|
||||
guests: number;
|
||||
}
|
||||
|
||||
export interface FeedEntry {
|
||||
photoId: number;
|
||||
guest: string;
|
||||
task?: string | null;
|
||||
likes: number;
|
||||
createdAt: string;
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
export interface AchievementsPayload {
|
||||
summary: {
|
||||
totalPhotos: number;
|
||||
uniqueGuests: number;
|
||||
tasksSolved: number;
|
||||
likesTotal: number;
|
||||
};
|
||||
personal: {
|
||||
guestName: string;
|
||||
photos: number;
|
||||
tasks: number;
|
||||
likes: number;
|
||||
badges: AchievementBadge[];
|
||||
} | null;
|
||||
leaderboards: {
|
||||
uploads: LeaderboardEntry[];
|
||||
likes: LeaderboardEntry[];
|
||||
};
|
||||
highlights: {
|
||||
topPhoto: TopPhotoHighlight | null;
|
||||
trendingEmotion: TrendingEmotionHighlight | null;
|
||||
timeline: TimelinePoint[];
|
||||
};
|
||||
feed: FeedEntry[];
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function safeString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
type FetchAchievementsOptions = {
|
||||
guestName?: string;
|
||||
locale?: string;
|
||||
signal?: AbortSignal;
|
||||
forceRefresh?: boolean;
|
||||
};
|
||||
|
||||
type AchievementsCacheEntry = {
|
||||
data: AchievementsPayload;
|
||||
etag: string | null;
|
||||
};
|
||||
|
||||
const achievementsCache = new Map<string, AchievementsCacheEntry>();
|
||||
|
||||
export async function fetchAchievements(
|
||||
eventToken: string,
|
||||
options: FetchAchievementsOptions = {}
|
||||
): Promise<AchievementsPayload> {
|
||||
const { guestName, signal, forceRefresh } = options;
|
||||
const locale = options.locale ?? DEFAULT_LOCALE;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (guestName && guestName.trim().length > 0) {
|
||||
params.set('guest_name', guestName.trim());
|
||||
}
|
||||
if (locale) {
|
||||
params.set('locale', locale);
|
||||
}
|
||||
|
||||
const deviceId = getDeviceId();
|
||||
const cacheKey = [eventToken, locale, guestName?.trim() ?? '', deviceId].join(':');
|
||||
const cached = forceRefresh ? null : achievementsCache.get(cacheKey);
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'X-Device-Id': deviceId,
|
||||
'Cache-Control': 'no-store',
|
||||
Accept: 'application/json',
|
||||
'X-Locale': locale,
|
||||
};
|
||||
|
||||
if (cached?.etag) {
|
||||
headers['If-None-Match'] = cached.etag;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/achievements?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (response.status === 304 && cached) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || 'Achievements request failed');
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const summary = json.summary ?? {};
|
||||
const personalRaw = json.personal ?? null;
|
||||
const leaderboards = json.leaderboards ?? {};
|
||||
const highlights = json.highlights ?? {};
|
||||
const feedRaw = Array.isArray(json.feed) ? json.feed : [];
|
||||
|
||||
const personal = personalRaw
|
||||
? {
|
||||
guestName: safeString(personalRaw.guest_name),
|
||||
photos: toNumber(personalRaw.photos),
|
||||
tasks: toNumber(personalRaw.tasks),
|
||||
likes: toNumber(personalRaw.likes),
|
||||
badges: Array.isArray(personalRaw.badges)
|
||||
? personalRaw.badges.map((badge): AchievementBadge => {
|
||||
const record = badge as Record<string, unknown>;
|
||||
return {
|
||||
id: safeString(record.id),
|
||||
title: safeString(record.title),
|
||||
description: safeString(record.description),
|
||||
earned: Boolean(record.earned),
|
||||
progress: toNumber(record.progress),
|
||||
target: toNumber(record.target, 1),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
}
|
||||
: null;
|
||||
|
||||
const uploadsBoard = Array.isArray(leaderboards.uploads)
|
||||
? leaderboards.uploads.map((row): LeaderboardEntry => {
|
||||
const record = row as Record<string, unknown>;
|
||||
return {
|
||||
guest: safeString(record.guest),
|
||||
photos: toNumber(record.photos),
|
||||
likes: toNumber(record.likes),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const likesBoard = Array.isArray(leaderboards.likes)
|
||||
? leaderboards.likes.map((row): LeaderboardEntry => {
|
||||
const record = row as Record<string, unknown>;
|
||||
return {
|
||||
guest: safeString(record.guest),
|
||||
photos: toNumber(record.photos),
|
||||
likes: toNumber(record.likes),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const topPhotoRaw = highlights.top_photo ?? null;
|
||||
const topPhoto = topPhotoRaw
|
||||
? {
|
||||
photoId: toNumber(topPhotoRaw.photo_id),
|
||||
guest: safeString(topPhotoRaw.guest),
|
||||
likes: toNumber(topPhotoRaw.likes),
|
||||
task: topPhotoRaw.task ?? null,
|
||||
createdAt: safeString(topPhotoRaw.created_at),
|
||||
thumbnail: topPhotoRaw.thumbnail ? safeString(topPhotoRaw.thumbnail) : null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const trendingRaw = highlights.trending_emotion ?? null;
|
||||
const trendingEmotion = trendingRaw
|
||||
? {
|
||||
emotionId: toNumber(trendingRaw.emotion_id),
|
||||
name: safeString(trendingRaw.name),
|
||||
count: toNumber(trendingRaw.count),
|
||||
}
|
||||
: null;
|
||||
|
||||
const timeline = Array.isArray(highlights.timeline)
|
||||
? highlights.timeline.map((row): TimelinePoint => {
|
||||
const record = row as Record<string, unknown>;
|
||||
return {
|
||||
date: safeString(record.date),
|
||||
photos: toNumber(record.photos),
|
||||
guests: toNumber(record.guests),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const feed = feedRaw.map((row): FeedEntry => {
|
||||
const record = row as Record<string, unknown>;
|
||||
return {
|
||||
photoId: toNumber(record.photo_id),
|
||||
guest: safeString(record.guest),
|
||||
task: (record as { task?: string }).task ?? null,
|
||||
likes: toNumber(record.likes),
|
||||
createdAt: safeString(record.created_at),
|
||||
thumbnail: record.thumbnail ? safeString(record.thumbnail) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const payload: AchievementsPayload = {
|
||||
summary: {
|
||||
totalPhotos: toNumber(summary.total_photos),
|
||||
uniqueGuests: toNumber(summary.unique_guests),
|
||||
tasksSolved: toNumber(summary.tasks_solved),
|
||||
likesTotal: toNumber(summary.likes_total),
|
||||
},
|
||||
personal,
|
||||
leaderboards: {
|
||||
uploads: uploadsBoard,
|
||||
likes: likesBoard,
|
||||
},
|
||||
highlights: {
|
||||
topPhoto,
|
||||
trendingEmotion,
|
||||
timeline,
|
||||
},
|
||||
feed,
|
||||
};
|
||||
|
||||
achievementsCache.set(cacheKey, {
|
||||
data: payload,
|
||||
etag: response.headers.get('ETag'),
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
362
resources/js/shared/guest/services/eventApi.ts
Normal file
362
resources/js/shared/guest/services/eventApi.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { getDeviceId } from '../lib/device';
|
||||
import { DEFAULT_LOCALE } from '../i18n/messages';
|
||||
|
||||
export interface EventBrandingPayload {
|
||||
primary_color?: string | null;
|
||||
secondary_color?: string | null;
|
||||
background_color?: string | null;
|
||||
font_family?: string | null;
|
||||
logo_url?: string | null;
|
||||
surface_color?: string | null;
|
||||
heading_font?: string | null;
|
||||
body_font?: string | null;
|
||||
font_size?: 's' | 'm' | 'l' | null;
|
||||
welcome_message?: string | null;
|
||||
icon?: string | null;
|
||||
logo_mode?: 'emoticon' | 'upload' | null;
|
||||
logo_value?: string | null;
|
||||
logo_position?: 'left' | 'right' | 'center' | null;
|
||||
logo_size?: 's' | 'm' | 'l' | null;
|
||||
button_style?: 'filled' | 'outline' | null;
|
||||
button_radius?: number | null;
|
||||
button_primary_color?: string | null;
|
||||
button_secondary_color?: string | null;
|
||||
link_color?: string | null;
|
||||
mode?: 'light' | 'dark' | 'auto' | null;
|
||||
use_default_branding?: boolean | null;
|
||||
palette?: {
|
||||
primary?: string | null;
|
||||
secondary?: string | null;
|
||||
background?: string | null;
|
||||
surface?: string | null;
|
||||
} | null;
|
||||
typography?: {
|
||||
heading?: string | null;
|
||||
body?: string | null;
|
||||
size?: 's' | 'm' | 'l' | null;
|
||||
} | null;
|
||||
logo?: {
|
||||
mode?: 'emoticon' | 'upload';
|
||||
value?: string | null;
|
||||
position?: 'left' | 'right' | 'center';
|
||||
size?: 's' | 'm' | 'l';
|
||||
} | null;
|
||||
buttons?: {
|
||||
style?: 'filled' | 'outline';
|
||||
radius?: number | null;
|
||||
primary?: string | null;
|
||||
secondary?: string | null;
|
||||
link_color?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface EventData {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
default_locale: string;
|
||||
engagement_mode?: 'tasks' | 'photo_only' | 'no_tasks';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
join_token?: string | null;
|
||||
demo_read_only?: boolean;
|
||||
photobooth_enabled?: boolean | null;
|
||||
type?: {
|
||||
slug: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
};
|
||||
branding?: EventBrandingPayload | null;
|
||||
guest_upload_visibility?: 'immediate' | 'review';
|
||||
live_show?: {
|
||||
moderation_mode?: 'off' | 'manual' | 'trusted_only';
|
||||
};
|
||||
}
|
||||
|
||||
export interface PackageData {
|
||||
id: number;
|
||||
name: string;
|
||||
max_photos: number;
|
||||
max_guests?: number | null;
|
||||
gallery_days?: number | null;
|
||||
}
|
||||
|
||||
export interface LimitUsageSummary {
|
||||
limit: number | null;
|
||||
used: number;
|
||||
remaining: number | null;
|
||||
percentage: number | null;
|
||||
state: 'ok' | 'warning' | 'limit_reached' | 'unlimited';
|
||||
threshold_reached: number | null;
|
||||
next_threshold: number | null;
|
||||
thresholds: number[];
|
||||
}
|
||||
|
||||
export interface GallerySummary {
|
||||
state: 'ok' | 'warning' | 'expired' | 'unlimited';
|
||||
expires_at: string | null;
|
||||
days_remaining: number | null;
|
||||
warning_thresholds: number[];
|
||||
warning_triggered: number | null;
|
||||
warning_sent_at: string | null;
|
||||
expired_notified_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventPackageLimits {
|
||||
photos: LimitUsageSummary | null;
|
||||
guests: LimitUsageSummary | null;
|
||||
gallery: GallerySummary | null;
|
||||
can_upload_photos: boolean;
|
||||
can_add_guests: boolean;
|
||||
}
|
||||
|
||||
export interface EventPackage {
|
||||
id: number;
|
||||
event_id?: number;
|
||||
package_id?: number;
|
||||
used_photos: number;
|
||||
used_guests?: number;
|
||||
expires_at: string | null;
|
||||
package: PackageData | null;
|
||||
limits: EventPackageLimits | null;
|
||||
}
|
||||
|
||||
export interface EventStats {
|
||||
onlineGuests: number;
|
||||
tasksSolved: number;
|
||||
guestCount: number;
|
||||
likesCount: number;
|
||||
latestPhotoAt: string | null;
|
||||
}
|
||||
|
||||
export type FetchEventErrorCode =
|
||||
| 'invalid_token'
|
||||
| 'token_expired'
|
||||
| 'token_revoked'
|
||||
| 'token_rate_limited'
|
||||
| 'access_rate_limited'
|
||||
| 'guest_limit_exceeded'
|
||||
| 'gallery_expired'
|
||||
| 'event_not_public'
|
||||
| 'network_error'
|
||||
| 'server_error'
|
||||
| 'unknown';
|
||||
|
||||
interface FetchEventErrorOptions {
|
||||
code: FetchEventErrorCode;
|
||||
message: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export class FetchEventError extends Error {
|
||||
readonly code: FetchEventErrorCode;
|
||||
readonly status?: number;
|
||||
|
||||
constructor({ code, message, status }: FetchEventErrorOptions) {
|
||||
super(message);
|
||||
this.name = 'FetchEventError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function coerceLocalized(value: unknown, fallback: string): string {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const preferredKeys = ['de', 'en'];
|
||||
|
||||
for (const key of preferredKeys) {
|
||||
const candidate = obj[key];
|
||||
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== '');
|
||||
if (typeof firstString === 'string') {
|
||||
return firstString;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const API_ERROR_CODES: FetchEventErrorCode[] = [
|
||||
'invalid_token',
|
||||
'token_expired',
|
||||
'token_revoked',
|
||||
'token_rate_limited',
|
||||
'access_rate_limited',
|
||||
'guest_limit_exceeded',
|
||||
'gallery_expired',
|
||||
'event_not_public',
|
||||
];
|
||||
|
||||
function resolveErrorCode(rawCode: unknown, status: number): FetchEventErrorCode {
|
||||
if (typeof rawCode === 'string') {
|
||||
const normalized = rawCode.toLowerCase() as FetchEventErrorCode;
|
||||
if ((API_ERROR_CODES as string[]).includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 429) return rawCode === 'access_rate_limited' ? 'access_rate_limited' : 'token_rate_limited';
|
||||
if (status === 404) return 'event_not_public';
|
||||
if (status === 410) return rawCode === 'gallery_expired' ? 'gallery_expired' : 'token_expired';
|
||||
if (status === 401) return 'invalid_token';
|
||||
if (status === 403) return 'token_revoked';
|
||||
if (status >= 500) return 'server_error';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function defaultMessageForCode(code: FetchEventErrorCode): string {
|
||||
switch (code) {
|
||||
case 'invalid_token':
|
||||
return 'Der eingegebene Zugriffscode ist ungültig.';
|
||||
case 'token_revoked':
|
||||
return 'Dieser Zugriffscode wurde deaktiviert. Bitte fordere einen neuen Code an.';
|
||||
case 'token_expired':
|
||||
return 'Dieser Zugriffscode ist abgelaufen.';
|
||||
case 'token_rate_limited':
|
||||
return 'Zu viele Versuche in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
|
||||
case 'access_rate_limited':
|
||||
return 'Zu viele Aufrufe in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
|
||||
case 'guest_limit_exceeded':
|
||||
return 'Dieses Event hat sein Gäste-Limit erreicht. Bitte kontaktiere die Veranstalter:innen.';
|
||||
case 'gallery_expired':
|
||||
return 'Die Galerie ist nicht mehr verfügbar.';
|
||||
case 'event_not_public':
|
||||
return 'Dieses Event ist nicht öffentlich verfügbar.';
|
||||
case 'network_error':
|
||||
return 'Keine Verbindung zum Server. Prüfe deine Internetverbindung und versuche es erneut.';
|
||||
case 'server_error':
|
||||
return 'Der Server ist gerade nicht erreichbar. Bitte versuche es später erneut.';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'Event konnte nicht geladen werden.';
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEvent(eventKey: string): Promise<EventData> {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`, {
|
||||
headers: {
|
||||
'X-Device-Id': getDeviceId(),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
let apiMessage: string | null = null;
|
||||
let rawCode: unknown;
|
||||
|
||||
try {
|
||||
const data = await res.json();
|
||||
rawCode = data?.error?.code ?? data?.code;
|
||||
const message = data?.error?.message ?? data?.message;
|
||||
if (typeof message === 'string' && message.trim() !== '') {
|
||||
apiMessage = message.trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors and fall back to defaults
|
||||
}
|
||||
|
||||
const code = resolveErrorCode(rawCode, res.status);
|
||||
const message = apiMessage ?? defaultMessageForCode(code);
|
||||
|
||||
throw new FetchEventError({
|
||||
code,
|
||||
message,
|
||||
status: res.status,
|
||||
});
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
const moderationMode = json?.live_show?.moderation_mode;
|
||||
const normalized: EventData = {
|
||||
...json,
|
||||
name: coerceLocalized(json?.name, 'Fotospiel Event'),
|
||||
default_locale: typeof json?.default_locale === 'string' && json.default_locale.trim() !== ''
|
||||
? json.default_locale
|
||||
: DEFAULT_LOCALE,
|
||||
engagement_mode: (json?.engagement_mode as 'tasks' | 'photo_only' | 'no_tasks' | undefined) ?? 'tasks',
|
||||
guest_upload_visibility:
|
||||
json?.guest_upload_visibility === 'immediate' ? 'immediate' : 'review',
|
||||
live_show: {
|
||||
moderation_mode: moderationMode === 'off' || moderationMode === 'manual' || moderationMode === 'trusted_only'
|
||||
? moderationMode
|
||||
: 'manual',
|
||||
},
|
||||
demo_read_only: Boolean(json?.demo_read_only),
|
||||
};
|
||||
|
||||
if (json?.type) {
|
||||
normalized.type = {
|
||||
...json.type,
|
||||
name: coerceLocalized(json.type?.name, 'Event'),
|
||||
};
|
||||
}
|
||||
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
if (error instanceof FetchEventError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
throw new FetchEventError({
|
||||
code: 'network_error',
|
||||
message: defaultMessageForCode('network_error'),
|
||||
status: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new FetchEventError({
|
||||
code: 'unknown',
|
||||
message: error.message || defaultMessageForCode('unknown'),
|
||||
status: 0,
|
||||
});
|
||||
}
|
||||
|
||||
throw new FetchEventError({
|
||||
code: 'unknown',
|
||||
message: defaultMessageForCode('unknown'),
|
||||
status: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchStats(eventKey: string): Promise<EventStats> {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/stats`, {
|
||||
headers: {
|
||||
'X-Device-Id': getDeviceId(),
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error('Stats fetch failed');
|
||||
const json = await res.json();
|
||||
return {
|
||||
onlineGuests: json.online_guests ?? json.onlineGuests ?? 0,
|
||||
tasksSolved: json.tasks_solved ?? json.tasksSolved ?? 0,
|
||||
guestCount: json.guest_count ?? json.guestCount ?? 0,
|
||||
likesCount: json.likes_count ?? json.likesCount ?? 0,
|
||||
latestPhotoAt: json.latest_photo_at ?? json.latestPhotoAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEventPackage(eventToken: string): Promise<EventPackage | null> {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/package`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return null;
|
||||
throw new Error('Failed to load event package');
|
||||
}
|
||||
const payload = await res.json();
|
||||
return {
|
||||
...payload,
|
||||
limits: payload?.limits ?? null,
|
||||
} as EventPackage;
|
||||
}
|
||||
113
resources/js/shared/guest/services/galleryApi.ts
Normal file
113
resources/js/shared/guest/services/galleryApi.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { LocaleCode } from '../i18n/messages';
|
||||
import type { EventBrandingPayload } from './eventApi';
|
||||
|
||||
export type GalleryBranding = EventBrandingPayload;
|
||||
|
||||
export interface GalleryMetaResponse {
|
||||
event: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
gallery_expires_at?: string | null;
|
||||
guest_downloads_enabled?: boolean;
|
||||
guest_sharing_enabled?: boolean;
|
||||
};
|
||||
branding: GalleryBranding;
|
||||
}
|
||||
|
||||
export interface GalleryPhotoResource {
|
||||
id: number;
|
||||
thumbnail_url: string | null;
|
||||
full_url: string | null;
|
||||
download_url: string;
|
||||
likes_count: number;
|
||||
guest_name?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export interface GalleryPhotosResponse {
|
||||
data: GalleryPhotoResource[];
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = data as { error?: { message?: string; code?: unknown } } | null;
|
||||
const error = new Error(errorPayload?.error?.message ?? 'Request failed') as Error & { code?: unknown };
|
||||
error.code = errorPayload?.error?.code ?? response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function coerceLocalized(value: unknown, fallback: string): string {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const preferred = ['de', 'en'];
|
||||
for (const key of preferred) {
|
||||
const candidate = obj[key];
|
||||
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== '');
|
||||
if (typeof firstString === 'string') {
|
||||
return firstString;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function fetchGalleryMeta(token: string, locale?: LocaleCode): Promise<GalleryMetaResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (locale) params.set('locale', locale);
|
||||
|
||||
const response = await fetch(`/api/v1/gallery/${encodeURIComponent(token)}${params.toString() ? `?${params.toString()}` : ''}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'omit',
|
||||
});
|
||||
|
||||
const data = await handleResponse<GalleryMetaResponse>(response);
|
||||
|
||||
if (data?.event) {
|
||||
data.event = {
|
||||
...data.event,
|
||||
name: coerceLocalized((data.event as any).name, 'Fotospiel Event'),
|
||||
description: coerceLocalized((data.event as any).description, ''),
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchGalleryPhotos(token: string, cursor?: string | null, limit = 30): Promise<GalleryPhotosResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
if (cursor) {
|
||||
params.set('cursor', cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/gallery/${encodeURIComponent(token)}/photos?${params.toString()}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'omit',
|
||||
});
|
||||
|
||||
return handleResponse<GalleryPhotosResponse>(response);
|
||||
}
|
||||
162
resources/js/shared/guest/services/helpApi.ts
Normal file
162
resources/js/shared/guest/services/helpApi.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { LocaleCode } from '../i18n/messages';
|
||||
|
||||
export type HelpArticleSummary = {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
version_introduced?: string;
|
||||
requires_app_version?: string | null;
|
||||
status?: string;
|
||||
translation_state?: string;
|
||||
last_reviewed_at?: string;
|
||||
owner?: string;
|
||||
updated_at?: string;
|
||||
related?: Array<{ slug: string; title?: string }>;
|
||||
};
|
||||
|
||||
export type HelpArticleDetail = HelpArticleSummary & {
|
||||
body_markdown?: string;
|
||||
body_html?: string;
|
||||
source_path?: string;
|
||||
};
|
||||
|
||||
export interface HelpListResult {
|
||||
articles: HelpArticleSummary[];
|
||||
servedFromCache: boolean;
|
||||
}
|
||||
|
||||
export interface HelpArticleResult {
|
||||
article: HelpArticleDetail;
|
||||
servedFromCache: boolean;
|
||||
}
|
||||
|
||||
const AUDIENCE = 'guest';
|
||||
const LIST_CACHE_TTL = 1000 * 60 * 60 * 6; // 6 hours
|
||||
const DETAIL_CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
|
||||
|
||||
interface CacheRecord<T> {
|
||||
storedAt: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
function listCacheKey(locale: LocaleCode): string {
|
||||
return `help:list:${AUDIENCE}:${locale}`;
|
||||
}
|
||||
|
||||
function detailCacheKey(locale: LocaleCode, slug: string): string {
|
||||
return `help:article:${AUDIENCE}:${locale}:${slug}`;
|
||||
}
|
||||
|
||||
function readCache<T>(key: string, ttl: number): CacheRecord<T> | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as CacheRecord<T>;
|
||||
if (!parsed?.data || !parsed?.storedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() - parsed.storedAt > ttl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.warn('[HelpApi] Failed to read cache', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache<T>(key: string, data: T): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload: CacheRecord<T> = {
|
||||
storedAt: Date.now(),
|
||||
data,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
console.warn('[HelpApi] Failed to write cache', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error('Help request failed') as Error & { status?: number };
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export async function getHelpArticles(locale: LocaleCode, options?: { forceRefresh?: boolean }): Promise<HelpListResult> {
|
||||
const cacheKey = listCacheKey(locale);
|
||||
const cached = readCache<HelpArticleSummary[]>(cacheKey, LIST_CACHE_TTL);
|
||||
|
||||
if (cached && !options?.forceRefresh) {
|
||||
return { articles: cached.data, servedFromCache: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
audience: AUDIENCE,
|
||||
locale,
|
||||
});
|
||||
const data = await requestJson<{ data?: HelpArticleSummary[] }>(`/api/v1/help?${params.toString()}`);
|
||||
const articles = Array.isArray(data?.data) ? data.data : [];
|
||||
writeCache(cacheKey, articles);
|
||||
return { articles, servedFromCache: false };
|
||||
} catch (error) {
|
||||
if (cached) {
|
||||
return { articles: cached.data, servedFromCache: true };
|
||||
}
|
||||
console.error('[HelpApi] Failed to fetch help articles', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHelpArticle(slug: string, locale: LocaleCode): Promise<HelpArticleResult> {
|
||||
const cacheKey = detailCacheKey(locale, slug);
|
||||
const cached = readCache<HelpArticleDetail>(cacheKey, DETAIL_CACHE_TTL);
|
||||
|
||||
if (cached) {
|
||||
return { article: cached.data, servedFromCache: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
audience: AUDIENCE,
|
||||
locale,
|
||||
});
|
||||
const data = await requestJson<{ data?: HelpArticleDetail }>(`/api/v1/help/${encodeURIComponent(slug)}?${params.toString()}`);
|
||||
const article: HelpArticleDetail | undefined = data?.data;
|
||||
const safeArticle: HelpArticleDetail = article ?? { slug, title: slug, summary: '' };
|
||||
writeCache(cacheKey, safeArticle);
|
||||
return { article: safeArticle, servedFromCache: false };
|
||||
} catch (error) {
|
||||
const cachedArticle: HelpArticleDetail | undefined = (cached as { data?: HelpArticleDetail } | null | undefined)?.data;
|
||||
if (cachedArticle) {
|
||||
return { article: cachedArticle, servedFromCache: true };
|
||||
}
|
||||
console.error('[HelpApi] Failed to fetch help article', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
302
resources/js/shared/guest/services/liveShowApi.ts
Normal file
302
resources/js/shared/guest/services/liveShowApi.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
export type LiveShowModerationMode = 'off' | 'manual' | 'trusted_only';
|
||||
export type LiveShowPlaybackMode = 'newest_first' | 'balanced' | 'curated';
|
||||
export type LiveShowPaceMode = 'auto' | 'fixed';
|
||||
export type LiveShowLayoutMode = 'single' | 'split' | 'grid_burst';
|
||||
export type LiveShowEffectPreset =
|
||||
| 'film_cut'
|
||||
| 'shutter_flash'
|
||||
| 'polaroid_toss'
|
||||
| 'parallax_glide'
|
||||
| 'light_effects';
|
||||
export type LiveShowBackgroundMode = 'blur_last' | 'gradient' | 'solid' | 'brand';
|
||||
|
||||
export type LiveShowSettings = {
|
||||
retention_window_hours: number;
|
||||
moderation_mode: LiveShowModerationMode;
|
||||
playback_mode: LiveShowPlaybackMode;
|
||||
pace_mode: LiveShowPaceMode;
|
||||
fixed_interval_seconds: number;
|
||||
layout_mode: LiveShowLayoutMode;
|
||||
effect_preset: LiveShowEffectPreset;
|
||||
effect_intensity: number;
|
||||
background_mode: LiveShowBackgroundMode;
|
||||
};
|
||||
|
||||
export type LiveShowCursor = {
|
||||
approved_at: string | null;
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type LiveShowEvent = {
|
||||
id: number;
|
||||
slug?: string | null;
|
||||
name: string;
|
||||
default_locale?: string | null;
|
||||
};
|
||||
|
||||
export type LiveShowPhoto = {
|
||||
id: number;
|
||||
full_url: string;
|
||||
thumb_url: string | null;
|
||||
approved_at: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
is_featured: boolean;
|
||||
live_priority: number;
|
||||
};
|
||||
|
||||
export type LiveShowState = {
|
||||
event: LiveShowEvent;
|
||||
settings: LiveShowSettings;
|
||||
settings_version: string;
|
||||
photos: LiveShowPhoto[];
|
||||
cursor: LiveShowCursor | null;
|
||||
};
|
||||
|
||||
export type LiveShowUpdates = {
|
||||
settings: LiveShowSettings | null;
|
||||
settings_version: string;
|
||||
photos: LiveShowPhoto[];
|
||||
cursor: LiveShowCursor | null;
|
||||
};
|
||||
|
||||
export type LiveShowErrorCode = 'not_found' | 'invalid_cursor' | 'rate_limited' | 'unknown';
|
||||
|
||||
export class LiveShowError extends Error {
|
||||
readonly code: LiveShowErrorCode;
|
||||
readonly status?: number;
|
||||
|
||||
constructor(code: LiveShowErrorCode, message: string, status?: number) {
|
||||
super(message);
|
||||
this.name = 'LiveShowError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_LIVE_SHOW_SETTINGS: LiveShowSettings = {
|
||||
retention_window_hours: 12,
|
||||
moderation_mode: 'manual',
|
||||
playback_mode: 'newest_first',
|
||||
pace_mode: 'auto',
|
||||
fixed_interval_seconds: 8,
|
||||
layout_mode: 'single',
|
||||
effect_preset: 'film_cut',
|
||||
effect_intensity: 70,
|
||||
background_mode: 'blur_last',
|
||||
};
|
||||
|
||||
const DEFAULT_EVENT_NAME = 'Fotospiel Live Show';
|
||||
|
||||
function coerceLocalized(value: unknown, fallback: string): string {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const preferredKeys = ['de', 'en'];
|
||||
|
||||
for (const key of preferredKeys) {
|
||||
const candidate = obj[key];
|
||||
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== '');
|
||||
if (typeof firstString === 'string') {
|
||||
return firstString;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function normalizeLiveShowSettings(raw?: Partial<LiveShowSettings> | null): LiveShowSettings {
|
||||
const merged = {
|
||||
...DEFAULT_LIVE_SHOW_SETTINGS,
|
||||
...(raw ?? {}),
|
||||
};
|
||||
|
||||
return {
|
||||
retention_window_hours: toNumber(merged.retention_window_hours, DEFAULT_LIVE_SHOW_SETTINGS.retention_window_hours),
|
||||
moderation_mode: merged.moderation_mode,
|
||||
playback_mode: merged.playback_mode,
|
||||
pace_mode: merged.pace_mode,
|
||||
fixed_interval_seconds: toNumber(merged.fixed_interval_seconds, DEFAULT_LIVE_SHOW_SETTINGS.fixed_interval_seconds),
|
||||
layout_mode: merged.layout_mode,
|
||||
effect_preset: merged.effect_preset,
|
||||
effect_intensity: toNumber(merged.effect_intensity, DEFAULT_LIVE_SHOW_SETTINGS.effect_intensity),
|
||||
background_mode: merged.background_mode,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLiveShowEvent(raw: Record<string, unknown>): LiveShowEvent {
|
||||
return {
|
||||
id: Number(raw.id ?? 0),
|
||||
slug: typeof raw.slug === 'string' ? raw.slug : null,
|
||||
name: coerceLocalized(raw.name, DEFAULT_EVENT_NAME),
|
||||
default_locale: typeof raw.default_locale === 'string' ? raw.default_locale : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLiveShowPhoto(raw: Record<string, unknown>): LiveShowPhoto {
|
||||
return {
|
||||
id: Number(raw.id ?? 0),
|
||||
full_url: String(raw.full_url ?? ''),
|
||||
thumb_url: typeof raw.thumb_url === 'string' ? raw.thumb_url : null,
|
||||
approved_at: typeof raw.approved_at === 'string' ? raw.approved_at : null,
|
||||
width: typeof raw.width === 'number' ? raw.width : null,
|
||||
height: typeof raw.height === 'number' ? raw.height : null,
|
||||
is_featured: Boolean(raw.is_featured),
|
||||
live_priority: Number(raw.live_priority ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCursor(raw: Record<string, unknown> | null): LiveShowCursor | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
approved_at: typeof raw.approved_at === 'string' ? raw.approved_at : null,
|
||||
id: Number(raw.id ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveErrorCode(status: number, payload: Record<string, unknown> | null): LiveShowErrorCode {
|
||||
const error = typeof payload?.error === 'string' ? payload.error : null;
|
||||
|
||||
if (error === 'live_show_not_found' || status === 404) {
|
||||
return 'not_found';
|
||||
}
|
||||
|
||||
if (error === 'invalid_cursor' || status === 422) {
|
||||
return 'invalid_cursor';
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return 'rate_limited';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = data as Record<string, unknown> | null;
|
||||
const code = resolveErrorCode(response.status, payload);
|
||||
const message =
|
||||
typeof payload?.message === 'string'
|
||||
? payload.message
|
||||
: typeof payload?.error === 'string'
|
||||
? payload.error
|
||||
: 'Live show request failed';
|
||||
throw new LiveShowError(code, message, response.status);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function buildParams(options: {
|
||||
cursor?: LiveShowCursor | null;
|
||||
settingsVersion?: string;
|
||||
limit?: number;
|
||||
} = {}): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
if (options.limit) {
|
||||
params.set('limit', String(options.limit));
|
||||
}
|
||||
if (options.settingsVersion) {
|
||||
params.set('settings_version', options.settingsVersion);
|
||||
}
|
||||
if (options.cursor?.approved_at) {
|
||||
params.set('after_approved_at', options.cursor.approved_at);
|
||||
params.set('after_id', String(options.cursor.id));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export function buildLiveShowStreamUrl(
|
||||
token: string,
|
||||
options: {
|
||||
cursor?: LiveShowCursor | null;
|
||||
settingsVersion?: string;
|
||||
limit?: number;
|
||||
} = {}
|
||||
): string {
|
||||
const params = buildParams(options);
|
||||
const base = `/api/v1/live-show/${encodeURIComponent(token)}/stream`;
|
||||
const query = params.toString();
|
||||
return query ? `${base}?${query}` : base;
|
||||
}
|
||||
|
||||
export async function fetchLiveShowState(token: string, limit = 50): Promise<LiveShowState> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit) {
|
||||
params.set('limit', String(limit));
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/live-show/${encodeURIComponent(token)}${params.toString() ? `?${params.toString()}` : ''}`,
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'omit',
|
||||
}
|
||||
);
|
||||
|
||||
const data = await handleResponse<Record<string, unknown>>(response);
|
||||
const rawEvent = (data.event as Record<string, unknown>) ?? {};
|
||||
|
||||
return {
|
||||
event: normalizeLiveShowEvent(rawEvent),
|
||||
settings: normalizeLiveShowSettings(data.settings as Partial<LiveShowSettings> | null),
|
||||
settings_version: String(data.settings_version ?? ''),
|
||||
photos: Array.isArray(data.photos)
|
||||
? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record<string, unknown>))
|
||||
: [],
|
||||
cursor: normalizeCursor((data.cursor as Record<string, unknown>) ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLiveShowUpdates(
|
||||
token: string,
|
||||
options: {
|
||||
cursor?: LiveShowCursor | null;
|
||||
settingsVersion?: string;
|
||||
limit?: number;
|
||||
} = {}
|
||||
): Promise<LiveShowUpdates> {
|
||||
const params = buildParams(options);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/live-show/${encodeURIComponent(token)}/updates${params.toString() ? `?${params.toString()}` : ''}`,
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'omit',
|
||||
}
|
||||
);
|
||||
|
||||
const data = await handleResponse<Record<string, unknown>>(response);
|
||||
|
||||
return {
|
||||
settings: data.settings ? normalizeLiveShowSettings(data.settings as Partial<LiveShowSettings>) : null,
|
||||
settings_version: String(data.settings_version ?? ''),
|
||||
photos: Array.isArray(data.photos)
|
||||
? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record<string, unknown>))
|
||||
: [],
|
||||
cursor: normalizeCursor((data.cursor as Record<string, unknown>) ?? null),
|
||||
};
|
||||
}
|
||||
154
resources/js/shared/guest/services/notificationApi.ts
Normal file
154
resources/js/shared/guest/services/notificationApi.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
export type GuestNotificationCta = {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type GuestNotificationItem = {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
status: 'new' | 'read' | 'dismissed';
|
||||
createdAt: string;
|
||||
readAt?: string | null;
|
||||
dismissedAt?: string | null;
|
||||
cta?: GuestNotificationCta | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type GuestNotificationFetchResult = {
|
||||
notifications: GuestNotificationItem[];
|
||||
unreadCount: number;
|
||||
etag: string | null;
|
||||
notModified: boolean;
|
||||
};
|
||||
|
||||
type GuestNotificationResponse = {
|
||||
data?: Array<{
|
||||
id?: number | string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
body?: string | null;
|
||||
status?: 'new' | 'read' | 'dismissed';
|
||||
created_at?: string;
|
||||
read_at?: string | null;
|
||||
dismissed_at?: string | null;
|
||||
cta?: GuestNotificationCta | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
}>;
|
||||
meta?: {
|
||||
unread_count?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type GuestNotificationRow = NonNullable<GuestNotificationResponse['data']>[number];
|
||||
|
||||
function buildHeaders(etag?: string | null): HeadersInit {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Device-Id': getDeviceId(),
|
||||
};
|
||||
|
||||
if (etag) {
|
||||
headers['If-None-Match'] = etag;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function mapNotification(payload: GuestNotificationRow): GuestNotificationItem {
|
||||
return {
|
||||
id: Number(payload.id ?? 0),
|
||||
type: payload.type ?? 'broadcast',
|
||||
title: payload.title ?? '',
|
||||
body: payload.body ?? null,
|
||||
status: payload.status === 'read' || payload.status === 'dismissed' ? payload.status : 'new',
|
||||
createdAt: payload.created_at ?? new Date().toISOString(),
|
||||
readAt: payload.read_at ?? null,
|
||||
dismissedAt: payload.dismissed_at ?? null,
|
||||
cta: payload.cta ?? null,
|
||||
payload: payload.payload ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGuestNotifications(
|
||||
eventToken: string,
|
||||
etag?: string | null,
|
||||
options?: { status?: 'unread' | 'read' | 'dismissed'; scope?: 'all' | 'uploads' | 'tips' | 'general' }
|
||||
): Promise<GuestNotificationFetchResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.status) params.set('status', options.status);
|
||||
if (options?.scope && options.scope !== 'all') params.set('scope', options.scope);
|
||||
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications${params.toString() ? `?${params.toString()}` : ''}`, {
|
||||
method: 'GET',
|
||||
headers: buildHeaders(etag),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.status === 304 && etag) {
|
||||
return {
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
etag,
|
||||
notModified: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const reason = await safeParseError(response);
|
||||
throw new Error(reason ?? 'Benachrichtigungen konnten nicht geladen werden.');
|
||||
}
|
||||
|
||||
const body = (await response.json()) as GuestNotificationResponse;
|
||||
const rows = Array.isArray(body.data) ? body.data : [];
|
||||
const notifications = rows.map(mapNotification);
|
||||
const unreadCount = typeof body.meta?.unread_count === 'number'
|
||||
? body.meta.unread_count
|
||||
: notifications.filter((item) => item.status === 'new').length;
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount,
|
||||
etag: response.headers.get('ETag'),
|
||||
notModified: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function markGuestNotificationRead(eventToken: string, notificationId: number): Promise<void> {
|
||||
await postNotificationAction(eventToken, notificationId, 'read');
|
||||
}
|
||||
|
||||
export async function dismissGuestNotification(eventToken: string, notificationId: number): Promise<void> {
|
||||
await postNotificationAction(eventToken, notificationId, 'dismiss');
|
||||
}
|
||||
|
||||
async function postNotificationAction(eventToken: string, notificationId: number, action: 'read' | 'dismiss'): Promise<void> {
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications/${notificationId}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const reason = await safeParseError(response);
|
||||
throw new Error(reason ?? 'Aktion konnte nicht ausgeführt werden.');
|
||||
}
|
||||
}
|
||||
|
||||
async function safeParseError(response: Response): Promise<string | null> {
|
||||
try {
|
||||
const payload = await response.clone().json();
|
||||
const message = payload?.error?.message ?? payload?.message;
|
||||
if (typeof message === 'string' && message.trim() !== '') {
|
||||
return message.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse notification API error', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
52
resources/js/shared/guest/services/pendingUploadsApi.ts
Normal file
52
resources/js/shared/guest/services/pendingUploadsApi.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
export type PendingUpload = {
|
||||
id: number;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
created_at?: string | null;
|
||||
thumbnail_url?: string | null;
|
||||
full_url?: string | null;
|
||||
};
|
||||
|
||||
type PendingUploadsResponse = {
|
||||
data: PendingUpload[];
|
||||
meta?: {
|
||||
total_count?: number;
|
||||
};
|
||||
};
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = data as { error?: { message?: string; code?: unknown } } | null;
|
||||
const error = new Error(errorPayload?.error?.message ?? 'Request failed') as Error & { code?: unknown };
|
||||
error.code = errorPayload?.error?.code ?? response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function fetchPendingUploadsSummary(
|
||||
token: string,
|
||||
limit = 12
|
||||
): Promise<{ items: PendingUpload[]; totalCount: number }> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(token)}/pending-photos?${params.toString()}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Device-Id': getDeviceId(),
|
||||
},
|
||||
credentials: 'omit',
|
||||
});
|
||||
|
||||
const payload = await handleResponse<PendingUploadsResponse>(response);
|
||||
|
||||
return {
|
||||
items: payload.data ?? [],
|
||||
totalCount: payload.meta?.total_count ?? (payload.data?.length ?? 0),
|
||||
};
|
||||
}
|
||||
333
resources/js/shared/guest/services/photosApi.ts
Normal file
333
resources/js/shared/guest/services/photosApi.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
// @ts-nocheck
|
||||
import { getDeviceId } from '../lib/device';
|
||||
import { buildCsrfHeaders } from '../lib/csrf';
|
||||
|
||||
export type UploadError = Error & {
|
||||
code?: string;
|
||||
status?: number;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export async function likePhoto(id: number): Promise<number> {
|
||||
const headers = buildCsrfHeaders();
|
||||
|
||||
const res = await fetch(`/api/v1/photos/${id}/like`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await res.clone().json();
|
||||
} catch (error) {
|
||||
console.warn('Like photo: failed to parse error payload', error);
|
||||
}
|
||||
|
||||
if (res.status === 419) {
|
||||
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
|
||||
error.code = 'csrf_mismatch';
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const error: UploadError = new Error(
|
||||
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Like failed: ${res.status}`
|
||||
);
|
||||
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'like_failed';
|
||||
error.status = res.status;
|
||||
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
|
||||
if (meta) {
|
||||
error.meta = meta;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.likes_count ?? json.data?.likes_count ?? 0;
|
||||
}
|
||||
|
||||
export async function unlikePhoto(id: number): Promise<number> {
|
||||
const headers = buildCsrfHeaders();
|
||||
|
||||
const res = await fetch(`/api/v1/photos/${id}/like`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await res.clone().json();
|
||||
} catch (error) {
|
||||
console.warn('Unlike photo: failed to parse error payload', error);
|
||||
}
|
||||
|
||||
if (res.status === 419) {
|
||||
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
|
||||
error.code = 'csrf_mismatch';
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const error: UploadError = new Error(
|
||||
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Unlike failed: ${res.status}`
|
||||
);
|
||||
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'unlike_failed';
|
||||
error.status = res.status;
|
||||
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
|
||||
if (meta) {
|
||||
error.meta = meta;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.likes_count ?? json.data?.likes_count ?? 0;
|
||||
}
|
||||
|
||||
export async function deletePhoto(eventToken: string, id: number): Promise<void> {
|
||||
const headers = buildCsrfHeaders();
|
||||
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/photos/${id}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await res.clone().json();
|
||||
} catch (error) {
|
||||
console.warn('Delete photo: failed to parse error payload', error);
|
||||
}
|
||||
|
||||
if (res.status === 419) {
|
||||
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
|
||||
error.code = 'csrf_mismatch';
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const error: UploadError = new Error(
|
||||
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Delete failed: ${res.status}`
|
||||
);
|
||||
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'delete_failed';
|
||||
error.status = res.status;
|
||||
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
|
||||
if (meta) {
|
||||
error.meta = meta;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
type UploadOptions = {
|
||||
guestName?: string;
|
||||
onProgress?: (percent: number) => void;
|
||||
signal?: AbortSignal;
|
||||
maxRetries?: number;
|
||||
onRetry?: (attempt: number) => void;
|
||||
liveShowOptIn?: boolean;
|
||||
};
|
||||
|
||||
export async function uploadPhoto(
|
||||
eventToken: string,
|
||||
file: File,
|
||||
taskId?: number,
|
||||
emotionSlug?: string,
|
||||
options: UploadOptions = {}
|
||||
): Promise<number> {
|
||||
const formData = new FormData();
|
||||
formData.append('photo', file, file.name || `photo-${Date.now()}.jpg`);
|
||||
if (taskId) formData.append('task_id', taskId.toString());
|
||||
if (emotionSlug) formData.append('emotion_slug', emotionSlug);
|
||||
if (options.guestName) formData.append('guest_name', options.guestName);
|
||||
if (typeof options.liveShowOptIn === 'boolean') {
|
||||
formData.append('live_show_opt_in', options.liveShowOptIn ? '1' : '0');
|
||||
}
|
||||
formData.append('device_id', getDeviceId());
|
||||
|
||||
const maxRetries = options.maxRetries ?? 2;
|
||||
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
|
||||
const headers = buildCsrfHeaders();
|
||||
|
||||
const attemptUpload = (): Promise<Record<string, unknown>> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url, true);
|
||||
xhr.withCredentials = true;
|
||||
xhr.responseType = 'json';
|
||||
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
|
||||
if (options.signal) {
|
||||
const onAbort = () => xhr.abort();
|
||||
options.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && options.onProgress) {
|
||||
const percent = Math.min(99, Math.round((event.loaded / event.total) * 100));
|
||||
options.onProgress(percent);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
const status = xhr.status;
|
||||
const payload = (xhr.response ?? null) as Record<string, unknown> | null;
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
resolve(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const error: UploadError = new Error(
|
||||
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Upload failed: ${status}`
|
||||
);
|
||||
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? (status === 0 ? 'network_error' : 'upload_failed');
|
||||
error.status = status;
|
||||
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
|
||||
if (meta) {
|
||||
error.meta = meta;
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
const error: UploadError = new Error('Network error during upload');
|
||||
error.code = 'network_error';
|
||||
reject(error);
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
const error: UploadError = new Error('Upload timed out');
|
||||
error.code = 'timeout';
|
||||
reject(error);
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const json = await attemptUpload();
|
||||
const payload = json as { photo_id?: number; id?: number; data?: { id?: number } };
|
||||
return payload.photo_id ?? payload.id ?? payload.data?.id ?? 0;
|
||||
} catch (error) {
|
||||
const err = error as UploadError;
|
||||
|
||||
if (attempt < maxRetries && (err.code === 'network_error' || (err.status ?? 0) >= 500)) {
|
||||
options.onRetry?.(attempt + 1);
|
||||
const delay = 300 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map CSRF mismatch specifically for caller handling
|
||||
if ((err.status ?? 0) === 419) {
|
||||
err.code = 'csrf_mismatch';
|
||||
}
|
||||
|
||||
// Flag common validation failure for file size/validation
|
||||
if ((err.status ?? 0) === 422 && !err.code) {
|
||||
err.code = 'validation_error';
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Upload failed after retries');
|
||||
}
|
||||
|
||||
export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> {
|
||||
const headers = buildCsrfHeaders();
|
||||
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/share`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await res.clone().json();
|
||||
} catch (error) {
|
||||
console.warn('Share link error payload parse failed', error);
|
||||
}
|
||||
|
||||
const errorPayload = payload as { error?: { message?: string; code?: string } } | null;
|
||||
const error: UploadError = new Error(errorPayload?.error?.message ?? 'Share link creation failed');
|
||||
error.code = errorPayload?.error?.code ?? 'share_failed';
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchPhotoShare(slug: string) {
|
||||
const res = await fetch(`/api/v1/photo-shares/${encodeURIComponent(slug)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => null);
|
||||
const error: UploadError = new Error(payload?.error?.message ?? 'Share link unavailable');
|
||||
error.code = payload?.error?.code ?? 'share_unavailable';
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
|
||||
const normalize = (value: unknown, fallback: string): string => {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return value;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const preferred = ['de', 'en'];
|
||||
for (const key of preferred) {
|
||||
const candidate = obj[key];
|
||||
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== '');
|
||||
if (typeof firstString === 'string') {
|
||||
return firstString;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
if (payload?.event) {
|
||||
payload.event = {
|
||||
...payload.event,
|
||||
name: normalize(payload.event?.name, 'Fotospiel Event'),
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
71
resources/js/shared/guest/services/pushApi.ts
Normal file
71
resources/js/shared/guest/services/pushApi.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
type PushSubscriptionPayload = {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
expirationTime?: number | null;
|
||||
contentEncoding?: string | null;
|
||||
};
|
||||
|
||||
function buildHeaders(): HeadersInit {
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Device-Id': getDeviceId(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerPushSubscription(eventToken: string, subscription: PushSubscription): Promise<void> {
|
||||
const json = subscription.toJSON() as PushSubscriptionPayload;
|
||||
|
||||
const body = {
|
||||
endpoint: json.endpoint,
|
||||
keys: json.keys,
|
||||
expiration_time: json.expirationTime ?? null,
|
||||
content_encoding: json.contentEncoding ?? null,
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/push-subscriptions`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await parseError(response);
|
||||
throw new Error(message ?? 'Push-Registrierung fehlgeschlagen.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterPushSubscription(eventToken: string, endpoint: string): Promise<void> {
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/push-subscriptions`, {
|
||||
method: 'DELETE',
|
||||
headers: buildHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ endpoint }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await parseError(response);
|
||||
throw new Error(message ?? 'Push konnte nicht deaktiviert werden.');
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<string | null> {
|
||||
try {
|
||||
const payload = await response.clone().json();
|
||||
const errorMessage = payload?.error?.message ?? payload?.message;
|
||||
if (typeof errorMessage === 'string' && errorMessage.trim() !== '') {
|
||||
return errorMessage;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse push API error', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
37
resources/js/shared/guest/types/event-branding.ts
Normal file
37
resources/js/shared/guest/types/event-branding.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface EventBranding {
|
||||
// Legacy/compat fields used across components
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
backgroundColor: string;
|
||||
fontFamily: string | null;
|
||||
logoUrl: string | null;
|
||||
welcomeMessage?: string | null;
|
||||
|
||||
// Extended branding shape
|
||||
useDefaultBranding?: boolean;
|
||||
palette?: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
background: string;
|
||||
surface?: string;
|
||||
};
|
||||
typography?: {
|
||||
heading: string | null;
|
||||
body: string | null;
|
||||
sizePreset?: 's' | 'm' | 'l';
|
||||
};
|
||||
logo?: {
|
||||
mode: 'emoticon' | 'upload';
|
||||
value: string | null;
|
||||
position?: 'left' | 'right' | 'center';
|
||||
size?: 's' | 'm' | 'l';
|
||||
};
|
||||
buttons?: {
|
||||
style?: 'filled' | 'outline';
|
||||
radius?: number; // px
|
||||
primary?: string;
|
||||
secondary?: string;
|
||||
linkColor?: string;
|
||||
};
|
||||
mode?: 'light' | 'dark' | 'auto';
|
||||
}
|
||||
Reference in New Issue
Block a user