Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions app/web/components/AppRoute.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { render, screen, waitFor } from "@testing-library/react";
import useAuthStore from "features/auth/useAuthStore";
import React from "react";
import wrapper from "test/hookWrapper";

import { appGetLayout } from "./AppRoute";

jest.mock("features/auth/useAuthStore");
jest.mock("components/Navigation", () => ({
__esModule: true,
default: () => null,
}));
jest.mock("components/Footer", () => ({
__esModule: true,
default: () => null,
}));
jest.mock("components/CookieBanner", () => ({
__esModule: true,
default: () => null,
}));

jest.mock("features/notifications/PushNotificationBanner", () => ({
PushNotificationBanner: () => (
<div role="status" aria-label="Push notification banner">
Push notification banner
</div>
),
}));

const mockUseAuthStore = useAuthStore as jest.MockedFunction<
typeof useAuthStore
>;

describe("AppRoute", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("renders the push notification banner when the user is authenticated", async () => {
mockUseAuthStore.mockReturnValue({
authState: {
authenticated: true,
error: null,
jailed: false,
loading: false,
userId: 1,
flowState: null,
},
authActions: {
authError: jest.fn(),
clearError: jest.fn(),
firstLogin: jest.fn(),
logout: jest.fn(),
passwordLogin: jest.fn(),
updateJailStatus: jest.fn(),
updateSignupState: jest.fn(),
},
});

render(appGetLayout({ isPrivate: false })(<div>content</div>), { wrapper });

await waitFor(() => {
expect(
screen.getByLabelText("Push notification banner"),
).toBeInTheDocument();
});
});

it("does not render the push notification banner when the user is not authenticated", () => {
mockUseAuthStore.mockReturnValue({
authState: {
authenticated: false,
error: null,
jailed: false,
loading: false,
userId: null,
flowState: null,
},
authActions: {
authError: jest.fn(),
clearError: jest.fn(),
firstLogin: jest.fn(),
logout: jest.fn(),
passwordLogin: jest.fn(),
updateJailStatus: jest.fn(),
updateSignupState: jest.fn(),
},
});

render(appGetLayout({ isPrivate: false })(<div>content</div>), { wrapper });

expect(
screen.queryByLabelText("Push notification banner"),
).not.toBeInTheDocument();
});
});
32 changes: 28 additions & 4 deletions app/web/components/AppRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import CookieBanner from "components/CookieBanner";
import ErrorBoundary from "components/ErrorBoundary";
import Footer from "components/Footer";
import { useAuthContext } from "features/auth/AuthProvider";
import { PushNotificationBanner } from "features/notifications/PushNotificationBanner";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react";
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { jailRoute, loginRoute } from "routes";
import { theme } from "theme";
import { useIsNativeEmbed } from "utils/nativeLink";
Expand Down Expand Up @@ -61,12 +62,14 @@ const PageWrapper = styled(Box, {
display: "flex",
flexDirection: "column",
flex: 1,
paddingBottom: "var(--cookie-banner-height, 0px)",
...(isNoOverflow && {
overflow: "hidden",
minHeight: 0,
}),
...(hasBottomNav && {
paddingBottom: "calc(56px + env(safe-area-inset-bottom, 0px))",
paddingBottom:
"calc(56px + env(safe-area-inset-bottom, 0px) + var(--cookie-banner-height, 0px))",
}),
}),
);
Expand Down Expand Up @@ -106,6 +109,24 @@ function AppRoute({
const isJailed = authState.jailed;
const isNativeEmbed = useIsNativeEmbed();

const headerRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const updateNavHeight = () => {
if (headerRef.current) {
document.documentElement.style.setProperty(
"--nav-height",
`${headerRef.current.offsetHeight}px`,
);
}
};
updateNavHeight();
const resizeObserver = new ResizeObserver(updateNavHeight);
if (headerRef.current) {
resizeObserver.observe(headerRef.current);
}
return () => resizeObserver.disconnect();
}, [isAuthenticated, isNativeEmbed]);

//there must be the same loading state on auth'd pages on server and client
//for hydration matching, so we will display a loader until mounted.
const [isMounted, setIsMounted] = useState(false);
Expand All @@ -128,7 +149,11 @@ function AppRoute({
) : (
<>
{variant === "no-overflow" ? globalStylesNoOverflow : globalStyles}
<Navigation />
<div ref={headerRef}>
<Navigation />
{!isNativeEmbed && isAuthenticated && <PushNotificationBanner />}
</div>
<CookieBanner />
{/* Temporary container injected for marketing to test dynamic "announcements".
* Find a better spot to componentise this code once plan is more finalised with this */}
<div id="announcements"></div>
Expand Down Expand Up @@ -159,7 +184,6 @@ function AppRoute({
</PageWrapper>
</>
)}
{!isPrivate && <CookieBanner />}
</ErrorBoundary>
);
}
Expand Down
38 changes: 26 additions & 12 deletions app/web/components/CookieBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@ import { styled, Typography } from "@mui/material";
import IconButton from "components/IconButton";
import { CloseIcon } from "components/Icons";
import StyledLink from "components/StyledLink";
import { useAuthContext } from "features/auth/AuthProvider";
import { Trans, useTranslation } from "i18n";
import { usePersistedState } from "platform/usePersistedState";
import { useEffect, useRef } from "react";
import { tosRoute } from "routes";
import { useIsMounted } from "utils/hooks";
import { useIsNativeEmbed } from "utils/nativeLink";

const StyledWrapper = styled("div")(({ theme }) => ({
position: "fixed",
bottom: 0,
left: 0,
right: 0,
zIndex: theme.zIndex.snackbar,
left: theme.spacing(0),
right: theme.spacing(0),
backgroundColor: "var(--mui-palette-background-paper)",
bottom: 0,
padding: theme.spacing(2, 4),
boxShadow: theme.shadows[4],

Expand All @@ -28,8 +28,7 @@ const StyledWrapper = styled("div")(({ theme }) => ({

const StyledCloseButton = styled(IconButton)(({ theme }) => ({
position: "absolute",
top: theme.spacing(4),
transform: "translateY(-50%)",
top: theme.spacing(1),
right: theme.spacing(1),
}));

Expand All @@ -38,18 +37,33 @@ export default function CookieBanner() {
// since we are using localStorage, make sure don't render unless mounted
// or there will be hydration mismatches
const isMounted = useIsMounted().current;
const auth = useAuthContext();
const [hasSeen, setHasSeen] = usePersistedState("hasSeenCookieBanner", false);
const isNativeEmbed = useIsNativeEmbed();
const bannerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const el = bannerRef.current;
if (!el) return;
const updateHeight = () => {
document.documentElement.style.setProperty(
"--cookie-banner-height",
`${el.offsetHeight}px`,
);
};
updateHeight();
const observer = new ResizeObserver(updateHeight);
observer.observe(el);
return () => {
observer.disconnect();
document.documentElement.style.removeProperty("--cookie-banner-height");
};
}, [hasSeen, isMounted]);

// Don't show cookie banner in native mobile app
// Mobile apps use app store privacy mechanisms, not cookie banners
// Only essential cookies are used (session/auth), which don't require consent
if (auth.authState.authenticated || isNativeEmbed) return null;
if (isNativeEmbed) return null;

//specifically not using our snackbar, which is designed for alerts
return isMounted && !hasSeen ? (
<StyledWrapper aria-live="polite">
<StyledWrapper ref={bannerRef} aria-live="polite">
<StyledCloseButton
aria-label={t("close")}
onClick={() => setHasSeen(true)}
Expand Down
4 changes: 2 additions & 2 deletions app/web/components/Navigation/BottomNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import {
searchRoute,
} from "routes";

const StyledPaper = styled(Paper)(({ theme }) => ({
const StyledPaper = styled(Paper)(() => ({
position: "fixed",
bottom: 0,
bottom: "var(--cookie-banner-height, 0px)",
left: 0,
right: 0,
zIndex: 1100,
Expand Down
67 changes: 0 additions & 67 deletions app/web/components/Navigation/Navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,6 @@ jest.mock("features/donations/DonationBanner", () => ({
),
}));

jest.mock("features/notifications/PushNotificationBanner", () => ({
PushNotificationBanner: () => (
<div role="status" aria-label="Push notification banner">
Push notification banner
</div>
),
}));

const mockUseAuthStore = useAuthStore as jest.MockedFunction<
typeof useAuthStore
>;
Expand Down Expand Up @@ -61,37 +53,6 @@ describe("Navigation", () => {
});
});

it("renders the push notification banner when the user is authenticated", async () => {
mockUseAuthStore.mockReturnValue({
authState: {
authenticated: true,
error: null,
jailed: false,
loading: false,
userId: 1,
flowState: null,
},
authActions: {
authError: jest.fn(),
clearError: jest.fn(),
firstLogin: jest.fn(),
logout: jest.fn(),
passwordLogin: jest.fn(),
updateJailStatus: jest.fn(),
updateSignupState: jest.fn(),
},
});

render(<Navigation />, { wrapper });

// Wait for component to mount and banners to appear
await waitFor(() => {
expect(
screen.getByLabelText("Push notification banner"),
).toBeInTheDocument();
});
});

it("does not render the donation banner when the user is not authenticated", () => {
mockUseAuthStore.mockReturnValue({
authState: {
Expand All @@ -117,32 +78,4 @@ describe("Navigation", () => {

expect(screen.queryByLabelText("Donation banner")).not.toBeInTheDocument();
});

it("does not render the push notification banner when the user is not authenticated", () => {
mockUseAuthStore.mockReturnValue({
authState: {
authenticated: false,
error: null,
jailed: false,
loading: false,
userId: null,
flowState: null,
},
authActions: {
authError: jest.fn(),
clearError: jest.fn(),
firstLogin: jest.fn(),
logout: jest.fn(),
passwordLogin: jest.fn(),
updateJailStatus: jest.fn(),
updateSignupState: jest.fn(),
},
});

render(<Navigation />, { wrapper });

expect(
screen.queryByLabelText("Push notification banner"),
).not.toBeInTheDocument();
});
});
Loading
Loading