die tenant admin oauth authentifizierung wurde implementiert und funktioniert jetzt. Zudem wurde das marketing frontend dashboard implementiert.
This commit is contained in:
@@ -111,6 +111,13 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--guest-primary: #f43f5e;
|
||||
--guest-secondary: #fb7185;
|
||||
--guest-background: #ffffff;
|
||||
--guest-font-family: inherit;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Great Vibes';
|
||||
src: url('/fonts/GreatVibes-Regular.ttf') format('truetype');
|
||||
|
||||
@@ -3,25 +3,36 @@ import { NavLink, useParams, useLocation } from 'react-router-dom';
|
||||
import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
|
||||
function TabLink({
|
||||
to,
|
||||
children,
|
||||
isActive,
|
||||
accentColor,
|
||||
}: {
|
||||
to: string;
|
||||
children: React.ReactNode;
|
||||
isActive: boolean;
|
||||
accentColor: string;
|
||||
}) {
|
||||
const activeStyle = isActive
|
||||
? {
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}cc)`,
|
||||
color: '#ffffff',
|
||||
boxShadow: `0 12px 30px ${accentColor}33`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={`
|
||||
flex flex-col items-center gap-1 h-14 p-2 transition-all duration-200 rounded-lg backdrop-blur-md
|
||||
${isActive
|
||||
? 'bg-gradient-to-t from-pink-500/90 to-pink-400/90 text-white shadow-lg scale-105 border border-white/30'
|
||||
: 'text-gray-300 hover:bg-white/10 hover:text-pink-300 hover:scale-105 hover:border-white/20 border border-transparent'
|
||||
}`}
|
||||
flex h-14 flex-col items-center justify-center gap-1 rounded-lg border border-transparent p-2 text-xs font-medium transition-all duration-200 ease-out
|
||||
touch-manipulation backdrop-blur-md
|
||||
${isActive ? 'scale-[1.04]' : 'text-white/70 hover:text-white'}
|
||||
`}
|
||||
style={activeStyle}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
@@ -33,10 +44,12 @@ export default function BottomNav() {
|
||||
const location = useLocation();
|
||||
const { event, status } = useEventData();
|
||||
const { t } = useTranslation();
|
||||
const { branding } = useEventBranding();
|
||||
|
||||
const isReady = status === 'ready' && !!event;
|
||||
|
||||
if (!token || !isReady) return null; // Only show bottom nav within event context
|
||||
if (!token || !isReady) return null;
|
||||
|
||||
const base = `/e/${encodeURIComponent(token)}`;
|
||||
const currentPath = location.pathname;
|
||||
|
||||
@@ -47,33 +60,36 @@ export default function BottomNav() {
|
||||
gallery: t('navigation.gallery'),
|
||||
};
|
||||
|
||||
// Improved active state logic
|
||||
const isHomeActive = currentPath === base || currentPath === `/${token}`;
|
||||
const isTasksActive = currentPath.startsWith(`${base}/tasks`) || currentPath === `${base}/upload`;
|
||||
const isAchievementsActive = currentPath.startsWith(`${base}/achievements`);
|
||||
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/30 bg-white/70 px-2 py-2 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-gradient-to-t dark:from-gray-950/85 dark:via-gray-900/70 dark:to-gray-900/35 dark:backdrop-blur-2xl dark:shadow-[0_18px_60px_rgba(15,15,30,0.6)]">
|
||||
<div className="mx-auto flex max-w-sm items-center justify-around">
|
||||
<TabLink to={`${base}`} isActive={isHomeActive}>
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/30 via-black/10 to-transparent px-3 py-2 shadow-xl backdrop-blur-xl dark:border-white/10 dark:from-gray-950/85 dark:via-gray-900/70 dark:to-gray-900/35 dark:backdrop-blur-2xl dark:shadow-[0_18px_60px_rgba(15,15,30,0.6)]">
|
||||
<div className="mx-auto flex max-w-sm items-center justify-around gap-2">
|
||||
<TabLink to={`${base}`} isActive={isHomeActive} accentColor={branding.primaryColor}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Home className="h-5 w-5" /> <span className="text-xs">{labels.home}</span>
|
||||
<Home className="h-5 w-5" aria-hidden />
|
||||
<span>{labels.home}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/tasks`} isActive={isTasksActive}>
|
||||
<TabLink to={`${base}/tasks`} isActive={isTasksActive} accentColor={branding.primaryColor}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<CheckSquare className="h-5 w-5" /> <span className="text-xs">{labels.tasks}</span>
|
||||
<CheckSquare className="h-5 w-5" aria-hidden />
|
||||
<span>{labels.tasks}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/achievements`} isActive={isAchievementsActive}>
|
||||
<TabLink to={`${base}/achievements`} isActive={isAchievementsActive} accentColor={branding.primaryColor}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Trophy className="h-5 w-5" /> <span className="text-xs">{labels.achievements}</span>
|
||||
<Trophy className="h-5 w-5" aria-hidden />
|
||||
<span>{labels.achievements}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/gallery`} isActive={isGalleryActive}>
|
||||
<TabLink to={`${base}/gallery`} isActive={isGalleryActive} accentColor={branding.primaryColor}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<GalleryHorizontal className="h-5 w-5" /> <span className="text-xs">{labels.gallery}</span>
|
||||
<GalleryHorizontal className="h-5 w-5" aria-hidden />
|
||||
<span>{labels.gallery}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useOptionalEventStats } from '../context/EventStatsContext';
|
||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { SettingsSheet } from './settings-sheet';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||||
|
||||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
heart: Heart,
|
||||
@@ -36,7 +37,7 @@ function getInitials(name: string): string {
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function renderEventAvatar(name: string, icon: unknown) {
|
||||
function renderEventAvatar(name: string, icon: unknown, accentColor: string, textColor: string) {
|
||||
if (typeof icon === 'string') {
|
||||
const trimmed = icon.trim();
|
||||
if (trimmed) {
|
||||
@@ -44,7 +45,10 @@ function renderEventAvatar(name: string, icon: unknown) {
|
||||
const IconComponent = EVENT_ICON_COMPONENTS[normalized];
|
||||
if (IconComponent) {
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full shadow-sm"
|
||||
style={{ backgroundColor: accentColor, color: textColor }}
|
||||
>
|
||||
<IconComponent className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
);
|
||||
@@ -52,7 +56,10 @@ function renderEventAvatar(name: string, icon: unknown) {
|
||||
|
||||
if (isLikelyEmoji(trimmed)) {
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 text-xl">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-xl shadow-sm"
|
||||
style={{ backgroundColor: accentColor, color: textColor }}
|
||||
>
|
||||
<span aria-hidden>{trimmed}</span>
|
||||
<span className="sr-only">{name}</span>
|
||||
</div>
|
||||
@@ -62,7 +69,10 @@ function renderEventAvatar(name: string, icon: unknown) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 font-semibold text-sm">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full font-semibold text-sm shadow-sm"
|
||||
style={{ backgroundColor: accentColor, color: textColor }}
|
||||
>
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
);
|
||||
@@ -72,11 +82,18 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
const statsContext = useOptionalEventStats();
|
||||
const identity = useOptionalGuestIdentity();
|
||||
const { t } = useTranslation();
|
||||
const brandingContext = useOptionalEventBranding();
|
||||
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
||||
const primaryForeground = '#ffffff';
|
||||
const { event, status } = useEventData();
|
||||
|
||||
if (!eventToken) {
|
||||
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div
|
||||
className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40"
|
||||
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="font-semibold">{title}</div>
|
||||
{guestName && (
|
||||
@@ -93,14 +110,21 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
);
|
||||
}
|
||||
|
||||
const { event, status } = useEventData();
|
||||
const guestName =
|
||||
identity && identity.eventKey === eventToken && identity.hydrated && identity.name ? identity.name : null;
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
|
||||
color: primaryForeground,
|
||||
fontFamily: branding.fontFamily ?? undefined,
|
||||
};
|
||||
|
||||
const accentColor = branding.secondaryColor;
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div className="font-semibold">{t('header.loading')}</div>
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-2 text-white shadow-sm backdrop-blur" style={headerStyle}>
|
||||
<div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppearanceToggleDropdown />
|
||||
<SettingsSheet />
|
||||
@@ -117,17 +141,20 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div
|
||||
className="sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-3 text-white shadow-sm backdrop-blur"
|
||||
style={headerStyle}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{renderEventAvatar(event.name, event.type?.icon)}
|
||||
<div className="flex flex-col">
|
||||
{renderEventAvatar(event.name, event.type?.icon, accentColor, primaryForeground)}
|
||||
<div className="flex flex-col" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
||||
<div className="font-semibold text-base">{event.name}</div>
|
||||
{guestName && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-xs text-white/80">
|
||||
{`${t('common.hi')} ${guestName}`}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-xs text-white/70">
|
||||
{stats && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
|
||||
115
resources/js/guest/context/EventBrandingContext.tsx
Normal file
115
resources/js/guest/context/EventBrandingContext.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo } from 'react';
|
||||
import type { EventBranding } from '../types/event-branding';
|
||||
|
||||
type EventBrandingContextValue = {
|
||||
branding: EventBranding;
|
||||
isCustom: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_EVENT_BRANDING: EventBranding = {
|
||||
primaryColor: '#f43f5e',
|
||||
secondaryColor: '#fb7185',
|
||||
backgroundColor: '#ffffff',
|
||||
fontFamily: null,
|
||||
logoUrl: null,
|
||||
};
|
||||
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 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;
|
||||
}
|
||||
|
||||
return {
|
||||
primaryColor: normaliseHexColor(input.primaryColor, DEFAULT_EVENT_BRANDING.primaryColor),
|
||||
secondaryColor: normaliseHexColor(input.secondaryColor, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||
backgroundColor: normaliseHexColor(input.backgroundColor, DEFAULT_EVENT_BRANDING.backgroundColor),
|
||||
fontFamily: input.fontFamily?.trim() || null,
|
||||
logoUrl: input.logoUrl?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCssVariables(branding: EventBranding) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--guest-primary', branding.primaryColor);
|
||||
root.style.setProperty('--guest-secondary', branding.secondaryColor);
|
||||
root.style.setProperty('--guest-background', branding.backgroundColor);
|
||||
|
||||
if (branding.fontFamily) {
|
||||
root.style.setProperty('--guest-font-family', branding.fontFamily);
|
||||
} else {
|
||||
root.style.removeProperty('--guest-font-family');
|
||||
}
|
||||
}
|
||||
|
||||
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-font-family');
|
||||
}
|
||||
|
||||
export function EventBrandingProvider({
|
||||
branding,
|
||||
children,
|
||||
}: {
|
||||
branding?: EventBranding | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const resolved = useMemo(() => resolveBranding(branding), [branding]);
|
||||
|
||||
useEffect(() => {
|
||||
applyCssVariables(resolved);
|
||||
|
||||
return () => {
|
||||
resetCssVariables();
|
||||
applyCssVariables(DEFAULT_EVENT_BRANDING);
|
||||
};
|
||||
}, [resolved]);
|
||||
|
||||
const value = useMemo<EventBrandingContextValue>(() => ({
|
||||
branding: resolved,
|
||||
isCustom:
|
||||
resolved.primaryColor.toLowerCase() !== DEFAULT_PRIMARY
|
||||
|| resolved.secondaryColor.toLowerCase() !== DEFAULT_SECONDARY
|
||||
|| resolved.backgroundColor.toLowerCase() !== DEFAULT_BACKGROUND,
|
||||
}), [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);
|
||||
}
|
||||
@@ -92,37 +92,16 @@ export default function GalleryPage() {
|
||||
const galleryLimits = eventPackage?.limits?.gallery ?? null;
|
||||
|
||||
const galleryCountdown = React.useMemo(() => {
|
||||
if (!galleryLimits) {
|
||||
if (!galleryLimits || galleryLimits.state !== 'expired') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (galleryLimits.state === 'expired') {
|
||||
return {
|
||||
tone: 'danger' as const,
|
||||
label: t('galleryCountdown.expired'),
|
||||
description: t('galleryCountdown.expiredDescription'),
|
||||
cta: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (galleryLimits.state === 'warning') {
|
||||
const days = Math.max(0, galleryLimits.days_remaining ?? 0);
|
||||
const label = days <= 1
|
||||
? t('galleryCountdown.expiresToday')
|
||||
: t('galleryCountdown.expiresIn').replace('{days}', `${days}`);
|
||||
|
||||
return {
|
||||
tone: days <= 1 ? ('danger' as const) : ('warning' as const),
|
||||
label,
|
||||
description: t('galleryCountdown.description'),
|
||||
cta: {
|
||||
type: 'upload' as const,
|
||||
label: t('galleryCountdown.ctaUpload'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
return {
|
||||
tone: 'danger' as const,
|
||||
label: t('galleryCountdown.expired'),
|
||||
description: t('galleryCountdown.expiredDescription'),
|
||||
cta: null,
|
||||
};
|
||||
}, [galleryLimits, t]);
|
||||
|
||||
const handleCountdownCta = React.useCallback(() => {
|
||||
@@ -146,18 +125,6 @@ export default function GalleryPage() {
|
||||
.replace('{used}', `${photoLimits.used}`)
|
||||
.replace('{max}', `${photoLimits.limit}`),
|
||||
});
|
||||
} else if (
|
||||
photoLimits?.state === 'warning'
|
||||
&& typeof photoLimits.remaining === 'number'
|
||||
&& typeof photoLimits.limit === 'number'
|
||||
) {
|
||||
warnings.push({
|
||||
id: 'photos-warning',
|
||||
tone: 'warning',
|
||||
message: t('upload.limitWarning')
|
||||
.replace('{remaining}', `${photoLimits.remaining}`)
|
||||
.replace('{max}', `${photoLimits.limit}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (galleryLimits?.state === 'expired') {
|
||||
@@ -166,14 +133,6 @@ export default function GalleryPage() {
|
||||
tone: 'danger',
|
||||
message: t('upload.errors.galleryExpired'),
|
||||
});
|
||||
} else if (galleryLimits?.state === 'warning') {
|
||||
const days = Math.max(0, galleryLimits.days_remaining ?? 0);
|
||||
const key = days === 1 ? 'upload.galleryWarningDay' : 'upload.galleryWarningDays';
|
||||
warnings.push({
|
||||
id: 'gallery-warning',
|
||||
tone: 'warning',
|
||||
message: t(key).replace('{days}', `${days}`),
|
||||
});
|
||||
}
|
||||
|
||||
return warnings;
|
||||
|
||||
@@ -9,8 +9,10 @@ import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { useEventStats } from '../context/EventStatsContext';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react';
|
||||
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset, X } from 'lucide-react';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import type { EventBranding } from '../types/event-branding';
|
||||
|
||||
export default function HomePage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
@@ -19,12 +21,51 @@ export default function HomePage() {
|
||||
const { event } = useEventData();
|
||||
const { completedCount } = useGuestTaskProgress(token);
|
||||
const { t } = useTranslation();
|
||||
const { branding } = useEventBranding();
|
||||
|
||||
if (!token) return null;
|
||||
const heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed';
|
||||
const [heroVisible, setHeroVisible] = React.useState(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.sessionStorage.getItem(heroStorageKey) !== '1';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setHeroVisible(window.sessionStorage.getItem(heroStorageKey) !== '1');
|
||||
} catch {
|
||||
setHeroVisible(true);
|
||||
}
|
||||
}, [heroStorageKey]);
|
||||
|
||||
const dismissHero = React.useCallback(() => {
|
||||
setHeroVisible(false);
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(heroStorageKey, '1');
|
||||
} catch {
|
||||
// ignore storage exceptions (e.g. private mode)
|
||||
}
|
||||
}, [heroStorageKey]);
|
||||
|
||||
const displayName = hydrated && name ? name : t('home.fallbackGuestName');
|
||||
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
|
||||
const latestUploadText = formatLatestUpload(stats.latestPhotoAt, t);
|
||||
const accentColor = branding.primaryColor;
|
||||
const secondaryAccent = branding.secondaryColor;
|
||||
|
||||
const primaryActions = React.useMemo(
|
||||
() => [
|
||||
@@ -32,19 +73,19 @@ export default function HomePage() {
|
||||
to: 'tasks',
|
||||
label: t('home.actions.items.tasks.label'),
|
||||
description: t('home.actions.items.tasks.description'),
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
icon: <Sparkles className="h-5 w-5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
to: 'upload',
|
||||
label: t('home.actions.items.upload.label'),
|
||||
description: t('home.actions.items.upload.description'),
|
||||
icon: <UploadCloud className="h-5 w-5" />,
|
||||
icon: <UploadCloud className="h-5 w-5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
to: 'gallery',
|
||||
label: t('home.actions.items.gallery.label'),
|
||||
description: t('home.actions.items.gallery.description'),
|
||||
icon: <Images className="h-5 w-5" />,
|
||||
icon: <Images className="h-5 w-5" aria-hidden />,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
@@ -59,42 +100,54 @@ export default function HomePage() {
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-24">
|
||||
<HeroCard
|
||||
name={displayName}
|
||||
eventName={eventNameDisplay}
|
||||
tasksCompleted={completedCount}
|
||||
t={t}
|
||||
/>
|
||||
{heroVisible && (
|
||||
<HeroCard
|
||||
name={displayName}
|
||||
eventName={eventNameDisplay}
|
||||
tasksCompleted={completedCount}
|
||||
t={t}
|
||||
branding={branding}
|
||||
onDismiss={dismissHero}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Card style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
||||
<CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4">
|
||||
<StatTile
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
icon={<Users className="h-4 w-4" aria-hidden />}
|
||||
label={t('home.stats.online')}
|
||||
value={`${stats.onlineGuests}`}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
icon={<Sparkles className="h-4 w-4" aria-hidden />}
|
||||
label={t('home.stats.tasksSolved')}
|
||||
value={`${stats.tasksSolved}`}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<TimerReset className="h-4 w-4" />}
|
||||
icon={<TimerReset className="h-4 w-4" aria-hidden />}
|
||||
label={t('home.stats.lastUpload')}
|
||||
value={latestUploadText}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
icon={<CheckCircle2 className="h-4 w-4" aria-hidden />}
|
||||
label={t('home.stats.completedTasks')}
|
||||
value={`${completedCount}`}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('home.actions.title')}
|
||||
</h2>
|
||||
@@ -102,14 +155,20 @@ export default function HomePage() {
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{primaryActions.map((action) => (
|
||||
<Link to={action.to} key={action.to} className="block">
|
||||
<Link to={action.to} key={action.to} className="block touch-manipulation">
|
||||
<Card className="transition-all hover:shadow-lg">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-pink-100 text-pink-600">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg shadow-sm"
|
||||
style={{
|
||||
backgroundColor: `${secondaryAccent}1a`,
|
||||
color: secondaryAccent,
|
||||
}}
|
||||
>
|
||||
{action.icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base font-semibold">{action.label}</span>
|
||||
<span className="text-base font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{action.label}</span>
|
||||
<span className="text-sm text-muted-foreground">{action.description}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -117,20 +176,25 @@ export default function HomePage() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" asChild className="w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
className="w-full touch-manipulation"
|
||||
style={{ borderColor: `${accentColor}33` }}
|
||||
>
|
||||
<Link to="queue">{t('home.actions.queueButton')}</Link>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
||||
<CardTitle>{t('home.checklist.title')}</CardTitle>
|
||||
<CardDescription>{t('home.checklist.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{checklistItems.map((item) => (
|
||||
<div key={item} className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 text-green-500" />
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 text-green-500" aria-hidden />
|
||||
<span className="text-sm leading-relaxed text-muted-foreground">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -151,11 +215,15 @@ function HeroCard({
|
||||
eventName,
|
||||
tasksCompleted,
|
||||
t,
|
||||
branding,
|
||||
onDismiss,
|
||||
}: {
|
||||
name: string;
|
||||
eventName: string;
|
||||
tasksCompleted: number;
|
||||
t: TranslateFn;
|
||||
branding: EventBranding;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const heroTitle = t('home.hero.title').replace('{name}', name);
|
||||
const heroDescription = t('home.hero.description').replace('{eventName}', eventName);
|
||||
@@ -163,22 +231,41 @@ function HeroCard({
|
||||
? t('home.hero.progress.some').replace('{count}', `${tasksCompleted}`)
|
||||
: t('home.hero.progress.none');
|
||||
|
||||
const style = React.useMemo(() => ({
|
||||
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
|
||||
color: '#ffffff',
|
||||
fontFamily: branding.fontFamily ?? undefined,
|
||||
}), [branding.fontFamily, branding.primaryColor, branding.secondaryColor]);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-0 bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<Card className="relative overflow-hidden border-0 text-white shadow-md" style={style}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/20 text-white hover:bg-white/30"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
<span className="sr-only">{t('common.actions.close')}</span>
|
||||
</Button>
|
||||
<CardHeader className="space-y-2 pr-10">
|
||||
<CardDescription className="text-sm text-white/80">{t('home.hero.subtitle')}</CardDescription>
|
||||
<CardTitle className="text-2xl font-bold">{heroTitle}</CardTitle>
|
||||
<p className="text-sm text-white/80">{heroDescription}</p>
|
||||
<CardTitle className="text-2xl font-bold leading-snug">{heroTitle}</CardTitle>
|
||||
<p className="text-sm text-white/85">{heroDescription}</p>
|
||||
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatTile({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
function StatTile({ icon, label, value, accentColor }: { icon: React.ReactNode; label: string; value: string; accentColor: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-pink-600 shadow-sm">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm"
|
||||
style={{ color: accentColor }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
|
||||
@@ -283,7 +283,6 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
|
||||
let canUploadCurrent = pkg.limits?.can_upload_photos ?? true;
|
||||
let errorMessage: string | null = null;
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (photoLimits?.state === 'limit_reached') {
|
||||
canUploadCurrent = false;
|
||||
@@ -294,32 +293,16 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
} else {
|
||||
errorMessage = t('upload.errors.photoLimit');
|
||||
}
|
||||
} else if (
|
||||
photoLimits?.state === 'warning'
|
||||
&& typeof photoLimits.remaining === 'number'
|
||||
&& typeof photoLimits.limit === 'number'
|
||||
) {
|
||||
warnings.push(
|
||||
t('upload.limitWarning')
|
||||
.replace('{remaining}', `${photoLimits.remaining}`)
|
||||
.replace('{max}', `${photoLimits.limit}`)
|
||||
);
|
||||
}
|
||||
|
||||
if (galleryLimits?.state === 'expired') {
|
||||
canUploadCurrent = false;
|
||||
errorMessage = t('upload.errors.galleryExpired');
|
||||
} else if (galleryLimits?.state === 'warning') {
|
||||
const daysLeft = Math.max(0, galleryLimits.days_remaining ?? 0);
|
||||
const key = daysLeft === 1 ? 'upload.galleryWarningDay' : 'upload.galleryWarningDays';
|
||||
warnings.push(
|
||||
t(key).replace('{days}', `${daysLeft}`)
|
||||
);
|
||||
}
|
||||
|
||||
setCanUpload(canUploadCurrent);
|
||||
setUploadError(errorMessage);
|
||||
setUploadWarning(errorMessage ? null : (warnings.length > 0 ? warnings.join(' · ') : null));
|
||||
setUploadWarning(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to check package limits', err);
|
||||
setCanUpload(false);
|
||||
|
||||
@@ -5,9 +5,9 @@ import Header from './components/Header';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import { useEventData } from './hooks/useEventData';
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import type { FetchEventErrorCode } from './services/eventApi';
|
||||
import { EventStatsProvider } from './context/EventStatsContext';
|
||||
import { GuestIdentityProvider } from './context/GuestIdentityContext';
|
||||
import { EventBrandingProvider } from './context/EventBrandingContext';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import ProfileSetupPage from './pages/ProfileSetupPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
@@ -26,6 +26,8 @@ import NotFoundPage from './pages/NotFoundPage';
|
||||
import { LocaleProvider } from './i18n/LocaleContext';
|
||||
import { DEFAULT_LOCALE, isLocaleCode } from './i18n/messages';
|
||||
import { useTranslation, type TranslateFn } from './i18n/useTranslation';
|
||||
import type { EventBranding } from './types/event-branding';
|
||||
import type { EventBrandingPayload, FetchEventErrorCode } from './services/eventApi';
|
||||
|
||||
function HomeLayout() {
|
||||
const { token } = useParams();
|
||||
@@ -92,37 +94,43 @@ function EventBoundary({ token }: { token: string }) {
|
||||
|
||||
const eventLocale = isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
|
||||
const localeStorageKey = `guestLocale_event_${event.id ?? token}`;
|
||||
const branding = mapEventBranding(event.branding);
|
||||
|
||||
return (
|
||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-16">
|
||||
<Header eventToken={token} />
|
||||
<div className="px-4 py-3">
|
||||
<Outlet />
|
||||
<EventBrandingProvider branding={branding}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-16">
|
||||
<Header eventToken={token} />
|
||||
<div className="px-4 py-3">
|
||||
<Outlet />
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
</EventStatsProvider>
|
||||
</EventStatsProvider>
|
||||
</EventBrandingProvider>
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupLayout() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
if (!token) return null;
|
||||
const { event } = useEventData();
|
||||
if (!token) return null;
|
||||
const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
|
||||
const localeStorageKey = event ? `guestLocale_event_${event.id}` : `guestLocale_event_${token}`;
|
||||
const branding = event ? mapEventBranding(event.branding) : null;
|
||||
return (
|
||||
<GuestIdentityProvider eventKey={token}>
|
||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-0">
|
||||
<Header eventToken={token} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</EventStatsProvider>
|
||||
<EventBrandingProvider branding={branding}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-0">
|
||||
<Header eventToken={token} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</EventStatsProvider>
|
||||
</EventBrandingProvider>
|
||||
</LocaleProvider>
|
||||
</GuestIdentityProvider>
|
||||
);
|
||||
@@ -141,6 +149,20 @@ function EventLoadingView() {
|
||||
);
|
||||
}
|
||||
|
||||
function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
primaryColor: raw.primary_color ?? '',
|
||||
secondaryColor: raw.secondary_color ?? '',
|
||||
backgroundColor: raw.background_color ?? '',
|
||||
fontFamily: raw.font_family ?? null,
|
||||
logoUrl: raw.logo_url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
interface EventErrorViewProps {
|
||||
code: FetchEventErrorCode | null;
|
||||
message: string | null;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
export interface EventBrandingPayload {
|
||||
primary_color?: string | null;
|
||||
secondary_color?: string | null;
|
||||
background_color?: string | null;
|
||||
font_family?: string | null;
|
||||
logo_url?: string | null;
|
||||
}
|
||||
|
||||
export interface EventData {
|
||||
id: number;
|
||||
slug: string;
|
||||
@@ -11,8 +19,9 @@ export interface EventData {
|
||||
type?: {
|
||||
slug: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
icon: string | null;
|
||||
};
|
||||
branding?: EventBrandingPayload | null;
|
||||
}
|
||||
|
||||
export interface PackageData {
|
||||
|
||||
8
resources/js/guest/types/event-branding.ts
Normal file
8
resources/js/guest/types/event-branding.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface EventBranding {
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
backgroundColor: string;
|
||||
fontFamily: string | null;
|
||||
logoUrl: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user