guest pwa: hide tasks when inactive and improve empty gallery state
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-08 21:53:47 +01:00
parent 6cc463fc70
commit 83cf863548
6 changed files with 228 additions and 5 deletions

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const useEventDataMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useLocation: () => ({ pathname: '/e/demo' }),
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => useEventDataMock(),
}));
vi.mock('@/shared/guest/context/NotificationCenterContext', () => ({
useOptionalNotificationCenter: () => ({ unreadCount: 0 }),
}));
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
vi.mock('../lib/guestTheme', () => ({
useGuestThemeVariant: () => ({ isDark: false }),
}));
vi.mock('@tamagui/stacks', () => ({
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, disabled, ...rest }: { children: React.ReactNode; disabled?: boolean }) => (
<button type="button" disabled={disabled} {...rest}>
{children}
</button>
),
}));
vi.mock('../components/TopBar', () => ({
default: () => <div>topbar</div>,
}));
vi.mock('../components/FloatingActionButton', () => ({
default: () => <div>fab</div>,
}));
vi.mock('../components/CompassHub', () => ({
default: ({ quadrants }: { quadrants: Array<{ key: string; label: string; disabled?: boolean }> }) => (
<div>
{quadrants.map((item) => (
<button key={item.key} type="button" disabled={item.disabled}>
{item.label}
</button>
))}
</div>
),
}));
vi.mock('../components/AmbientBackground', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/NotificationSheet', () => ({
default: () => null,
}));
vi.mock('../components/SettingsSheet', () => ({
default: () => null,
}));
vi.mock('../components/GuestAnalyticsNudge', () => ({
default: () => null,
}));
vi.mock('lucide-react', () => ({
Sparkles: () => <span>sparkles</span>,
Share2: () => <span>share</span>,
Image: () => <span>image</span>,
Camera: () => <span>camera</span>,
Settings: () => <span>settings</span>,
Home: () => <span>home</span>,
Menu: () => <span>menu</span>,
}));
import AppShell from '../components/AppShell';
describe('AppShell', () => {
it('disables task link when no active tasks are available', () => {
useEventDataMock.mockReturnValue({
tasksEnabled: true,
hasActiveTasks: false,
event: { name: 'Demo Event' },
token: 'demo',
});
render(<AppShell><div>content</div></AppShell>);
expect(screen.getByRole('button', { name: 'Tasks' })).toBeDisabled();
});
it('keeps task link enabled when active tasks exist', () => {
useEventDataMock.mockReturnValue({
tasksEnabled: true,
hasActiveTasks: true,
event: { name: 'Demo Event' },
token: 'demo',
});
render(<AppShell><div>content</div></AppShell>);
expect(screen.getByRole('button', { name: 'Tasks' })).toBeEnabled();
});
});

View File

@@ -135,6 +135,7 @@ describe('HomeScreen', () => {
fetchGalleryMock.mockResolvedValueOnce({ data: [] }); fetchGalleryMock.mockResolvedValueOnce({ data: [] });
useEventDataMock.mockReturnValue({ useEventDataMock.mockReturnValue({
tasksEnabled: true, tasksEnabled: true,
hasActiveTasks: true,
token: 'demo', token: 'demo',
event: { name: 'Demo Event' }, event: { name: 'Demo Event' },
}); });
@@ -149,6 +150,7 @@ describe('HomeScreen', () => {
fetchGalleryMock.mockResolvedValueOnce({ data: [] }); fetchGalleryMock.mockResolvedValueOnce({ data: [] });
useEventDataMock.mockReturnValue({ useEventDataMock.mockReturnValue({
tasksEnabled: false, tasksEnabled: false,
hasActiveTasks: false,
token: 'demo', token: 'demo',
event: { name: 'Demo Event' }, event: { name: 'Demo Event' },
}); });
@@ -165,6 +167,7 @@ describe('HomeScreen', () => {
}); });
useEventDataMock.mockReturnValue({ useEventDataMock.mockReturnValue({
tasksEnabled: false, tasksEnabled: false,
hasActiveTasks: false,
token: 'demo', token: 'demo',
event: { name: 'Demo Event' }, event: { name: 'Demo Event' },
}); });
@@ -176,4 +179,34 @@ describe('HomeScreen', () => {
expect(navigateMock).toHaveBeenCalledWith('/e/demo/gallery?photo=42'); expect(navigateMock).toHaveBeenCalledWith('/e/demo/gallery?photo=42');
}); });
it('does not mention tasks when no active tasks are available', () => {
fetchGalleryMock.mockResolvedValueOnce({ data: [] });
useEventDataMock.mockReturnValue({
tasksEnabled: true,
hasActiveTasks: false,
token: 'demo',
event: { name: 'Demo Event' },
});
render(<HomeScreen />);
expect(screen.getByText('Capture ready')).toBeInTheDocument();
expect(screen.queryByText("Let's go!")).not.toBeInTheDocument();
});
it('shows a friendly empty gallery message instead of placeholders', async () => {
fetchGalleryMock.mockResolvedValueOnce({ data: [] });
useEventDataMock.mockReturnValue({
tasksEnabled: false,
hasActiveTasks: false,
token: 'demo',
event: { name: 'Demo Event' },
});
render(<HomeScreen />);
expect(await screen.findByText('No photos yet')).toBeInTheDocument();
expect(screen.getByText('You should start taking some and fill this gallery with moments.')).toBeInTheDocument();
});
}); });

View File

@@ -24,7 +24,7 @@ export default function AppShell({ children }: AppShellProps) {
const [compassOpen, setCompassOpen] = React.useState(false); const [compassOpen, setCompassOpen] = React.useState(false);
const [notificationsOpen, setNotificationsOpen] = React.useState(false); const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const [settingsOpen, setSettingsOpen] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false);
const { tasksEnabled, event, token } = useEventData(); const { tasksEnabled, hasActiveTasks, event, token } = useEventData();
const notificationCenter = useOptionalNotificationCenter(); const notificationCenter = useOptionalNotificationCenter();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -73,7 +73,8 @@ export default function AppShell({ children }: AppShellProps) {
key: 'tasks', key: 'tasks',
label: t('navigation.tasks', 'Tasks'), label: t('navigation.tasks', 'Tasks'),
icon: <Sparkles size={18} color={actionIconColor} />, icon: <Sparkles size={18} color={actionIconColor} />,
onPress: goTo('/tasks'), onPress: hasActiveTasks ? goTo('/tasks') : undefined,
disabled: !hasActiveTasks,
} }
: { : {
key: 'settings', key: 'settings',

View File

@@ -11,6 +11,7 @@ export type CompassAction = {
label: string; label: string;
icon?: React.ReactNode; icon?: React.ReactNode;
onPress?: () => void; onPress?: () => void;
disabled?: boolean;
}; };
type CompassHubProps = { type CompassHubProps = {
@@ -128,7 +129,12 @@ export default function CompassHub({
{quadrants.map((action, index) => ( {quadrants.map((action, index) => (
<Button <Button
key={action.key} key={action.key}
disabled={action.disabled}
opacity={action.disabled ? 0.45 : 1}
onPress={() => { onPress={() => {
if (action.disabled) {
return;
}
action.onPress?.(); action.onPress?.();
close(); close();
}} }}

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { fetchEvent, type EventData, FetchEventError } from '../services/eventApi'; import { fetchEvent, type EventData, FetchEventError } from '../services/eventApi';
import { isTaskModeEnabled } from '@/shared/guest/lib/engagement'; import { isTaskModeEnabled } from '@/shared/guest/lib/engagement';
import { fetchTasks } from '../services/tasksApi';
type EventDataStatus = 'idle' | 'loading' | 'ready' | 'error'; type EventDataStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -10,6 +11,7 @@ type EventDataContextValue = {
error: string | null; error: string | null;
token: string | null; token: string | null;
tasksEnabled: boolean; tasksEnabled: boolean;
hasActiveTasks: boolean;
}; };
const EventDataContext = React.createContext<EventDataContextValue>({ const EventDataContext = React.createContext<EventDataContextValue>({
@@ -18,6 +20,7 @@ const EventDataContext = React.createContext<EventDataContextValue>({
error: null, error: null,
token: null, token: null,
tasksEnabled: true, tasksEnabled: true,
hasActiveTasks: true,
}); });
type EventDataProviderProps = { type EventDataProviderProps = {
@@ -82,6 +85,41 @@ export function EventDataProvider({
}, [token]); }, [token]);
const tasksEnabled = event ? isTaskModeEnabled(event) : tasksEnabledFallback; const tasksEnabled = event ? isTaskModeEnabled(event) : tasksEnabledFallback;
const [hasActiveTasks, setHasActiveTasks] = React.useState<boolean>(tasksEnabledFallback);
React.useEffect(() => {
if (!token) {
setHasActiveTasks(tasksEnabledFallback);
return;
}
if (!tasksEnabled) {
setHasActiveTasks(false);
return;
}
let cancelled = false;
fetchTasks(token, { page: 1, perPage: 1 })
.then((tasks) => {
if (cancelled) {
return;
}
setHasActiveTasks(tasks.length > 0);
})
.catch(() => {
if (cancelled) {
return;
}
setHasActiveTasks(true);
});
return () => {
cancelled = true;
};
}, [tasksEnabled, tasksEnabledFallback, token]);
return ( return (
<EventDataContext.Provider <EventDataContext.Provider
@@ -91,6 +129,7 @@ export function EventDataProvider({
error, error,
token: token ?? null, token: token ?? null,
tasksEnabled, tasksEnabled,
hasActiveTasks,
}} }}
> >
{children} {children}

View File

@@ -164,7 +164,7 @@ function normalizeImageUrl(src?: string | null) {
} }
export default function HomeScreen() { export default function HomeScreen() {
const { tasksEnabled, token, event } = useEventData(); const { tasksEnabled, hasActiveTasks, token, event } = useEventData();
const navigate = useNavigate(); const navigate = useNavigate();
const revealStage = useStaggeredReveal({ steps: 5, intervalMs: 140, delayMs: 120 }); const revealStage = useStaggeredReveal({ steps: 5, intervalMs: 140, delayMs: 120 });
const { stats } = usePollStats(token ?? null); const { stats } = usePollStats(token ?? null);
@@ -537,6 +537,7 @@ export default function HomeScreen() {
selectRandomTask(tasks); selectRandomTask(tasks);
setHasSwiped(true); setHasSwiped(true);
}, [selectRandomTask, tasks]); }, [selectRandomTask, tasks]);
const showTaskHero = tasksEnabled && hasActiveTasks;
return ( return (
<AppShell> <AppShell>
@@ -595,7 +596,7 @@ export default function HomeScreen() {
</YStack> </YStack>
) : null} ) : null}
{tasksEnabled ? ( {showTaskHero ? (
<YStack <YStack
animation="slow" animation="slow"
animateOnly={['transform', 'opacity']} animateOnly={['transform', 'opacity']}
@@ -704,7 +705,7 @@ export default function HomeScreen() {
paddingBottom: 6, paddingBottom: 6,
}} }}
> >
{(galleryLoading || preview.length === 0 ? [1, 2, 3, 4] : preview).map((tile) => { {(galleryLoading ? [1, 2, 3, 4] : preview).map((tile) => {
if (typeof tile === 'number') { if (typeof tile === 'number') {
return ( return (
<YStack key={tile} flexShrink={0} width={140}> <YStack key={tile} flexShrink={0} width={140}>
@@ -736,6 +737,32 @@ export default function HomeScreen() {
); );
})} })}
</XStack> </XStack>
{!galleryLoading && preview.length === 0 ? (
<YStack
padding="$3"
borderRadius="$bento"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.03)' : 'rgba(15, 23, 42, 0.03)'}
borderWidth={1}
borderColor={bentoSurface.borderColor}
gap="$2"
alignItems="center"
>
<Text fontSize="$3" fontWeight="$7" textAlign="center">
{t('homeV2.galleryPreview.emptyTitle', 'No photos yet')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.75} textAlign="center">
{t('homeV2.galleryPreview.emptyDescription', 'You should start taking some and fill this gallery with moments.')}
</Text>
<Button
size="$3"
backgroundColor="$primary"
borderRadius="$pill"
onPress={goTo('/upload')}
>
{t('homeV2.galleryPreview.emptyCta', 'Take first photo')}
</Button>
</YStack>
) : null}
</YStack> </YStack>
<YStack <YStack