photo visibility for demo events, hardened the demo mode. fixed dark/light mode toggle and notification bell toggle. fixed photo upload page sizes & header visibility.
This commit is contained in:
@@ -82,12 +82,14 @@ export default function BottomNav() {
|
||||
const isUploadActive = currentPath.startsWith(`${base}/upload`);
|
||||
|
||||
const compact = isUploadActive;
|
||||
const navPaddingBottom = `calc(env(safe-area-inset-bottom, 0px) + ${compact ? 12 : 18}px)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`guest-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/40 via-black/20 to-transparent px-4 shadow-xl backdrop-blur-2xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/90 dark:via-gray-900/70 dark:to-gray-900/35 ${
|
||||
compact ? 'pb-1 pt-1 translate-y-3' : 'pb-3 pt-2'
|
||||
className={`guest-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/70 via-black/45 to-black/10 px-4 shadow-xl backdrop-blur-2xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/90 dark:via-gray-900/70 dark:to-gray-900/35 ${
|
||||
compact ? 'pt-1' : 'pt-2 pb-1'
|
||||
}`}
|
||||
style={{ paddingBottom: navPaddingBottom }}
|
||||
>
|
||||
<div className="mx-auto flex max-w-lg items-center gap-3">
|
||||
<div className="flex flex-1 justify-evenly gap-2">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Props = { token: string };
|
||||
|
||||
@@ -90,7 +91,11 @@ export default function GalleryPreview({ token }: Props) {
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="border border-muted/30 shadow-sm" style={{ borderRadius: radius, background: 'var(--guest-surface)', fontFamily: bodyFont }}>
|
||||
<Card
|
||||
className="border border-muted/30 bg-[var(--guest-surface)] shadow-sm dark:border-slate-800/70 dark:bg-slate-950/70"
|
||||
data-testid="gallery-preview"
|
||||
style={{ borderRadius: radius, fontFamily: bodyFont }}
|
||||
>
|
||||
<CardContent className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -107,28 +112,36 @@ export default function GalleryPreview({ token }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
{filters.map((filter) => (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
onClick={() => setMode(filter.value)}
|
||||
style={{
|
||||
borderRadius: radius,
|
||||
border: mode === filter.value ? `1px solid ${branding.primaryColor}` : `1px solid ${branding.primaryColor}22`,
|
||||
background: mode === filter.value ? branding.primaryColor : 'var(--guest-surface)',
|
||||
color: mode === filter.value ? '#ffffff' : 'var(--foreground)',
|
||||
boxShadow: mode === filter.value ? `0 8px 18px ${branding.primaryColor}33` : 'none',
|
||||
}}
|
||||
className="px-4 py-1 transition"
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filters.map((filter) => {
|
||||
const isActive = mode === filter.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
onClick={() => setMode(filter.value)}
|
||||
style={{
|
||||
borderRadius: radius,
|
||||
border: isActive ? `1px solid ${branding.primaryColor}` : `1px solid ${branding.primaryColor}22`,
|
||||
background: isActive ? branding.primaryColor : undefined,
|
||||
boxShadow: isActive ? `0 8px 18px ${branding.primaryColor}33` : 'none',
|
||||
}}
|
||||
className={cn(
|
||||
'px-4 py-1 transition',
|
||||
isActive
|
||||
? 'text-white'
|
||||
: 'bg-[var(--guest-surface)] text-foreground dark:bg-slate-950/70 dark:text-slate-100',
|
||||
)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">Lädt…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-muted/30 bg-[var(--guest-surface)] p-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-muted/30 bg-[var(--guest-surface)] p-3 text-sm text-muted-foreground dark:border-slate-800/60 dark:bg-slate-950/60">
|
||||
<Heart className="h-4 w-4" style={{ color: branding.secondaryColor }} aria-hidden />
|
||||
Noch keine Fotos. Starte mit deinem ersten Upload!
|
||||
</div>
|
||||
@@ -139,11 +152,10 @@ export default function GalleryPreview({ token }: Props) {
|
||||
<Link
|
||||
key={p.id}
|
||||
to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`}
|
||||
className="group relative block overflow-hidden text-foreground"
|
||||
className="group relative block overflow-hidden bg-[var(--guest-surface)] text-foreground dark:bg-slate-950/70"
|
||||
style={{
|
||||
borderRadius: radius,
|
||||
border: `1px solid ${branding.primaryColor}22`,
|
||||
background: 'var(--guest-surface)',
|
||||
boxShadow: `0 12px 26px ${branding.primaryColor}22`,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -152,11 +152,15 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
const taskProgress = useGuestTaskProgress(eventToken);
|
||||
const tasksEnabled = isTaskModeEnabled(event);
|
||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!notificationsOpen) {
|
||||
return;
|
||||
}
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (notificationButtonRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if (!panelRef.current) return;
|
||||
if (panelRef.current.contains(event.target as Node)) return;
|
||||
setNotificationsOpen(false);
|
||||
@@ -245,6 +249,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
open={notificationsOpen}
|
||||
onToggle={() => setNotificationsOpen((prev) => !prev)}
|
||||
panelRef={panelRef}
|
||||
buttonRef={notificationButtonRef}
|
||||
taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined}
|
||||
t={t}
|
||||
/>
|
||||
@@ -262,13 +267,14 @@ type NotificationButtonProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
panelRef: React.RefObject<HTMLDivElement | null>;
|
||||
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
|
||||
t: TranslateFn;
|
||||
};
|
||||
|
||||
type PushState = ReturnType<typeof usePushSubscription>;
|
||||
|
||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, taskProgress, t }: NotificationButtonProps) {
|
||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, taskProgress, t }: NotificationButtonProps) {
|
||||
const badgeCount = center.unreadCount;
|
||||
const progressRatio = taskProgress
|
||||
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
||||
@@ -322,6 +328,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task
|
||||
return (
|
||||
<div className="relative z-50">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import GalleryPreview from '../GalleryPreview';
|
||||
|
||||
vi.mock('../../polling/usePollGalleryDelta', () => ({
|
||||
usePollGalleryDelta: () => ({
|
||||
photos: [],
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
locale: 'de',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventBrandingContext', () => ({
|
||||
useEventBranding: () => ({
|
||||
branding: {
|
||||
primaryColor: '#f43f5e',
|
||||
secondaryColor: '#fb7185',
|
||||
buttons: { radius: 12, linkColor: '#fb7185' },
|
||||
typography: {},
|
||||
fontFamily: 'Montserrat',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('GalleryPreview', () => {
|
||||
it('renders dark mode-ready surfaces', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<GalleryPreview token="demo" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const card = screen.getByTestId('gallery-preview');
|
||||
expect(card.className).toContain('bg-[var(--guest-surface)]');
|
||||
expect(card.className).toContain('dark:bg-slate-950/70');
|
||||
|
||||
const emptyState = screen.getByText(/Noch keine Fotos/i).closest('div');
|
||||
expect(emptyState).not.toBeNull();
|
||||
expect(emptyState?.className).toContain('dark:bg-slate-950/60');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import Header from '../Header';
|
||||
|
||||
vi.mock('../settings-sheet', () => ({
|
||||
SettingsSheet: () => <div data-testid="settings-sheet" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/appearance-dropdown', () => ({
|
||||
default: () => <div data-testid="appearance-toggle" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useEventData', () => ({
|
||||
useEventData: () => ({
|
||||
status: 'ready',
|
||||
event: {
|
||||
name: 'Demo Event',
|
||||
type: { icon: 'heart' },
|
||||
engagement_mode: 'photo_only',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventStatsContext', () => ({
|
||||
useOptionalEventStats: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../context/GuestIdentityContext', () => ({
|
||||
useOptionalGuestIdentity: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../context/NotificationCenterContext', () => ({
|
||||
useOptionalNotificationCenter: () => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
queueItems: [],
|
||||
queueCount: 0,
|
||||
totalCount: 0,
|
||||
loading: false,
|
||||
refresh: vi.fn(),
|
||||
setFilters: vi.fn(),
|
||||
markAsRead: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
eventToken: 'demo',
|
||||
lastFetchedAt: null,
|
||||
isOffline: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useGuestTaskProgress', () => ({
|
||||
useGuestTaskProgress: () => ({
|
||||
hydrated: false,
|
||||
completedCount: 0,
|
||||
}),
|
||||
TASK_BADGE_TARGET: 10,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/usePushSubscription', () => ({
|
||||
usePushSubscription: () => ({
|
||||
supported: false,
|
||||
permission: 'default',
|
||||
subscribed: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string | { defaultValue?: string }) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return _key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Header notifications toggle', () => {
|
||||
it('closes the panel when clicking the bell again', () => {
|
||||
render(<Header eventToken="demo" title="Demo" />);
|
||||
|
||||
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
|
||||
fireEvent.click(bellButton);
|
||||
|
||||
expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(bellButton);
|
||||
|
||||
expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user