diff --git a/app/components/LogInPopover.module.css b/app/components/LogInPopover.module.css new file mode 100644 index 000000000..dec92fbc7 --- /dev/null +++ b/app/components/LogInPopover.module.css @@ -0,0 +1,17 @@ +.popover { + max-width: 16rem; + + /* a display on the class itself would show the closed popovers too */ + &:popover-open { + display: flex; + flex-direction: column; + gap: var(--s-3); + } +} + +.text { + font-size: var(--font-xs); + color: var(--color-text-high); + text-align: center; + text-wrap: balance; +} diff --git a/app/components/LogInPopover.tsx b/app/components/LogInPopover.tsx new file mode 100644 index 000000000..dc28bf319 --- /dev/null +++ b/app/components/LogInPopover.tsx @@ -0,0 +1,31 @@ +import { LogIn } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { SendouButton } from "./elements/Button"; +import { SendouPopover } from "./elements/Popover"; +import styles from "./LogInPopover.module.css"; +import { LogInButtonContainer } from "./layout/LogInButtonContainer"; + +/** Wraps a trigger a logged out user can't use, prompting them to log in instead. */ +export function LogInPopover({ + children, +}: { + children: React.ReactElement>; +}) { + const { t } = useTranslation(["common"]); + + return ( + +
{t("common:logInPrompt")}
+ + } + data-testid="log-in-popover-button" + > + {t("common:header.login.discord")} + + +
+ ); +} diff --git a/app/components/filter-bar/FilterBar.browser.test.tsx b/app/components/filter-bar/FilterBar.browser.test.tsx index ab82e9a53..671e71c98 100644 --- a/app/components/filter-bar/FilterBar.browser.test.tsx +++ b/app/components/filter-bar/FilterBar.browser.test.tsx @@ -87,6 +87,29 @@ function TestFilterBar(props: { ); } +function TestHighlightsFilterBar() { + const [highlights, setHighlights] = useState(true); + + return ( + setHighlights(false), + popover: ( + + ), + }, + ]} + /> + ); +} + describe("FilterBar", () => { test("renders a set pill with its name and formatted value", async () => { const screen = await render(); @@ -189,16 +212,50 @@ describe("FilterBar", () => { .not.toBeInTheDocument(); }); - test("renders nothing for a logged out user", async () => { + test("prompts a logged out user to log in instead of opening a pill", async () => { useUser.mockReturnValue(null); const screen = await render(); + await screen.getByRole("button", { name: "Mode SZ" }).click(); + await expect - .element(screen.getByRole("button", { name: /Mode/ })) + .element(screen.getByText("Log in to use this")) + .toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: "Set SZ" })) .not.toBeInTheDocument(); + }); + + test("prompts a logged out user to log in instead of opening the add filter menu", async () => { + useUser.mockReturnValue(null); + + const screen = await render(); + + await screen.getByRole("button", { name: "Filter" }).click(); + await expect - .element(screen.getByRole("button", { name: "Filter" })) + .element(screen.getByText("Log in to use this")) + .toBeInTheDocument(); + await expect + .element(screen.getByRole("menuitem", { name: "Mode" })) + .not.toBeInTheDocument(); + }); + + test("keeps a pill marked usable logged out on the bar and out of the log in prompt", async () => { + useUser.mockReturnValue(null); + + const screen = await render(); + + await screen.getByRole("button", { name: "Highlights Only" }).click(); + await screen.getByRole("button", { name: "Toggle highlights" }).click(); + + // still there to be turned back on, without a remove button now that it is off + await expect + .element(screen.getByRole("button", { name: "Highlights", exact: true })) + .toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: "Remove Highlights filter" })) .not.toBeInTheDocument(); }); diff --git a/app/components/filter-bar/FilterBar.tsx b/app/components/filter-bar/FilterBar.tsx index 822e4a4f6..4fdd13a28 100644 --- a/app/components/filter-bar/FilterBar.tsx +++ b/app/components/filter-bar/FilterBar.tsx @@ -6,6 +6,7 @@ import { useUser } from "~/features/auth/core/user"; import { SendouButton } from "../elements/Button"; import { SendouMenu, SendouMenuItem } from "../elements/Menu"; import { SendouPopover } from "../elements/Popover"; +import { LogInPopover } from "../LogInPopover"; import styles from "./FilterBar.module.css"; export interface FilterBarPill { @@ -20,6 +21,8 @@ export interface FilterBarPill { onRemove?: () => void; /** Writes a starting value when the pill is added from the menu. */ onAdd?: () => void; + /** Usable logged out, where every other pill prompts to log in instead. */ + usableLoggedOut?: boolean; icon?: React.ReactNode; popoverClassName?: string; testId?: string; @@ -35,17 +38,24 @@ export function FilterBar({ onReset?: () => void; actions?: React.ReactNode; }) { - const user = useUser(); + const isLoggedIn = Boolean(useUser()); const { t } = useTranslation(); const [justAddedKeys, setJustAddedKeys] = React.useState>( new Set(), ); const [openPillKey, setOpenPillKey] = React.useState(null); - if (!user) return null; + /** A logged out visitor has no add filter menu to bring a pill back with. */ + const isPinned = (pill: FilterBarPill) => + !isLoggedIn && Boolean(pill.usableLoggedOut); const isVisible = (pill: FilterBarPill) => - pill.formattedValue !== null || justAddedKeys.has(pill.key); + pill.formattedValue !== null || + justAddedKeys.has(pill.key) || + isPinned(pill); + + const isRemovable = (pill: FilterBarPill) => + Boolean(pill.onRemove) && (pill.formattedValue !== null || !isPinned(pill)); const hiddenPills = pills.filter((pill) => !isVisible(pill)); @@ -79,13 +89,18 @@ export function FilterBar({ setOpenPillKey(isOpen ? pill.key : null)} - onRemove={pill.onRemove ? () => removePill(pill) : undefined} + onRemove={isRemovable(pill) ? () => removePill(pill) : undefined} /> ))} {hiddenPills.length > 0 ? ( - + ) : null} {onReset || actions ? (
@@ -103,41 +118,47 @@ export function FilterBar({ function FilterPill({ pill, + showLogInPrompt, isOpen, onOpenChange, onRemove, }: { pill: FilterBarPill; + showLogInPrompt: boolean; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; onRemove?: () => void; }) { + const trigger = ( + + ); + return (
- - {pill.icon ? ( - {pill.icon} - ) : null} - {pill.name} - {pill.formattedValue !== null ? ( - {pill.formattedValue} - ) : null} - - - } - > - {pill.popover} - + {showLogInPrompt ? ( + {trigger} + ) : ( + + {pill.popover} + + )} {onRemove ? ( + ); + + if (!isLoggedIn) { + return ( +
+ {trigger} +
+ ); + } + return (
- - - {t("filterBar.addFilter")} - - } - > + {pills.map((pill) => ( + + + ); +} + function resolveInitialWeapon( weaponId: MainWeaponId | null, t: TFunction<["common", "weapons"]>, diff --git a/app/components/layout/TopRightButtons.tsx b/app/components/layout/TopRightButtons.tsx index 8b4555a94..973208b95 100644 --- a/app/components/layout/TopRightButtons.tsx +++ b/app/components/layout/TopRightButtons.tsx @@ -3,20 +3,18 @@ import { useTranslation } from "react-i18next"; import { SUPPORT_PAGE } from "~/utils/urls"; import { LinkButton, SendouButton } from "../elements/Button"; import { AnythingAdder } from "./AnythingAdder"; -import { GlobalSearch } from "./GlobalSearch"; +import { GlobalSearch, LoggedOutGlobalSearch } from "./GlobalSearch"; import { LogInButtonContainer } from "./LogInButtonContainer"; import styles from "./TopRightButtons.module.css"; export function TopRightButtons({ showSupport, - showSearch, isLoggedIn, onChatToggle, onChatModalToggle, chatUnreadCount, }: { showSupport: boolean; - showSearch: boolean; isLoggedIn: boolean; onChatToggle?: () => void; onChatModalToggle?: () => void; @@ -49,16 +47,14 @@ export function TopRightButtons({
) : null} +
+
+ {isLoggedIn ? : } +
+ {isLoggedIn ? : null} +
{isLoggedIn ? ( <> -
- {showSearch ? ( -
- -
- ) : null} - -
{onChatToggle ? (
- {user ? ( -
+
+ {user ? ( {t("scrims:associations.title")} - -
- ) : null} + ) : null} + +
setFilters({ highlightsOnly: false }), onAdd: () => setFilters({ highlightsOnly: true }), + usableLoggedOut: true, testId: "highlights-filter", popover: ( { const isChoosingHighlights = url.pathname.includes("/results/highlights"); const canFilter = !isChoosingHighlights && Boolean(getUser()); - /** Logged out visitors are locked to the highlights, if there are any. */ - let showHighlightsOnly = hasHighlightedResults; - - if (canFilter && !highlightsOnly) { - showHighlightsOnly = false; - } - - if (isChoosingHighlights) { - showHighlightsOnly = false; - } + /** Turning the highlights off is the one filter a logged out visitor gets. */ + const showHighlightsOnly = + hasHighlightedResults && highlightsOnly && !isChoosingHighlights; const filters = canFilter ? { diff --git a/changelog/2026-09-09-logged-out-search-and-filters.md b/changelog/2026-09-09-logged-out-search-and-filters.md new file mode 100644 index 000000000..07ac2b2a4 --- /dev/null +++ b/changelog/2026-09-09-logged-out-search-and-filters.md @@ -0,0 +1,8 @@ +--- +type: feature +--- +Search and filters are no longer hidden when you are logged out + +- Both are shown, and using one prompts you to log in +- Clearing a filter someone shared with you in a link works without an account +- On a user's results page you can turn off the highlights only filter without logging in diff --git a/e2e/pages/layout/log-in-popover.ts b/e2e/pages/layout/log-in-popover.ts new file mode 100644 index 000000000..16aab0636 --- /dev/null +++ b/e2e/pages/layout/log-in-popover.ts @@ -0,0 +1,12 @@ +import type { Page } from "@playwright/test"; + +/** Shown when a logged out visitor uses a control that requires an account. */ +export class LogInPopover { + readonly locators; + + constructor(page: Page) { + this.locators = { + logInButton: page.getByTestId("log-in-popover-button"), + }; + } +} diff --git a/e2e/pages/layout/top-right-buttons.ts b/e2e/pages/layout/top-right-buttons.ts index 5917bdaf3..f08094fca 100644 --- a/e2e/pages/layout/top-right-buttons.ts +++ b/e2e/pages/layout/top-right-buttons.ts @@ -10,6 +10,10 @@ export class TopRightButtons { supportLink: page .getByRole("banner") .getByRole("link", { name: "Support" }), + // logged out only: logged in the search opener is a link, not a button + searchButton: page + .getByRole("banner") + .getByRole("button", { name: "Search" }), }; } } diff --git a/e2e/pages/user/user-results-page.ts b/e2e/pages/user/user-results-page.ts index 2a17cd549..65a922b64 100644 --- a/e2e/pages/user/user-results-page.ts +++ b/e2e/pages/user/user-results-page.ts @@ -16,9 +16,21 @@ export class UserResultsPage { chooseHighlightsButton: page.getByRole("link", { name: "Choose highlights", }), + highlightsFilter: page.getByTestId("highlights-filter"), + highlightsOnlySwitch: page.getByRole("switch", { + name: "Only highlighted results", + }), }; } + /** Flips the highlights only filter, which a logged out visitor may use too. */ + async toggleHighlightsOnly() { + await this.locators.highlightsFilter.click(); + // the switch indicator covers its input, like everywhere else this one is clicked + await this.locators.highlightsOnlySwitch.click({ force: true }); + await this.page.keyboard.press("Escape"); + } + async goto(discordId: string) { await navigate({ page: this.page, url: userResultsPage({ discordId }) }); } diff --git a/e2e/public-pages.spec.ts b/e2e/public-pages.spec.ts index 7a1f98f18..778562a29 100644 --- a/e2e/public-pages.spec.ts +++ b/e2e/public-pages.spec.ts @@ -11,6 +11,8 @@ import { FaqPage } from "./pages/info/faq-page"; import { LinksPage } from "./pages/info/links-page"; import { SupportPage } from "./pages/info/support-page"; import { ErrorPage } from "./pages/layout/error-page"; +import { LogInPopover } from "./pages/layout/log-in-popover"; +import { TopRightButtons } from "./pages/layout/top-right-buttons"; import { ScannerPage } from "./pages/scanner/scanner-page"; import { TournamentPage } from "./pages/tournament/tournament-page"; import { UserPage } from "./pages/user/user-page"; @@ -20,6 +22,7 @@ const PUBLIC_USER = { discordName: "Chirpy", }; const BUILD_WEAPON_ID = 40; +const BUILD_WEAPON_SLUG = "splattershot"; const EVENT_NAME = "Ink Clash Open"; const TOURNAMENT_NAME = "Public Pages Cup"; const ICS_EVENT_NAME = "ICS Feed Cup"; @@ -114,6 +117,23 @@ test.describe("Public pages", () => { await expect(page).toHaveURL("/"); }); + test("prompts a logged out visitor to log in when using search or a filter", async ({ + page, + }) => { + const weaponBuilds = new WeaponBuildsPage(page); + await weaponBuilds.goto(BUILD_WEAPON_SLUG); + + const logInPopover = new LogInPopover(page); + + await weaponBuilds.locators.addFilterButton.click(); + await expect(logInPopover.locators.logInButton).toBeVisible(); + + await page.keyboard.press("Escape"); + + await new TopRightButtons(page).locators.searchButton.click(); + await expect(logInPopover.locators.logInButton).toBeVisible(); + }); + test("lists articles and renders one by slug", async ({ page }) => { const articles = new ArticlesPage(page); await articles.goto(); diff --git a/e2e/user-page.spec.ts b/e2e/user-page.spec.ts index 2ca692097..d0488fec6 100644 --- a/e2e/user-page.spec.ts +++ b/e2e/user-page.spec.ts @@ -264,6 +264,16 @@ test.describe("User page", () => { await expect(resultsPage.eventName("In The Zone 30")).toBeVisible(); await isNotVisible(resultsPage.eventName("Paddling Pool 253")); + + await page.context().clearCookies(); + await resultsPage.goto(ADMIN_DISCORD_ID); + await isNotVisible(resultsPage.eventName("Paddling Pool 253")); + + await resultsPage.toggleHighlightsOnly(); + await expect(resultsPage.eventName("Paddling Pool 253")).toBeVisible(); + + await resultsPage.toggleHighlightsOnly(); + await isNotVisible(resultsPage.eventName("Paddling Pool 253")); }); test("edits profile widgets, lists vods and shows season stats", async ({ diff --git a/locales/da/common.json b/locales/da/common.json index 8ec863651..4daac573e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -36,6 +36,7 @@ "header.profile": "Profil", "header.logout": "Log ud", "header.login.discord": "", + "logInPrompt": "", "header.language": "Sprog", "header.loggedInAs": "Du er logget ind som {{userName}}", "header.theme": "Tema", diff --git a/locales/de/common.json b/locales/de/common.json index 1d2e5d205..96639c830 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -36,6 +36,7 @@ "header.profile": "Profil", "header.logout": "Ausloggen", "header.login.discord": "", + "logInPrompt": "", "header.language": "Sprache", "header.loggedInAs": "Eingeloggt als {{userName}}", "header.theme": "Theme", diff --git a/locales/en/common.json b/locales/en/common.json index 135ef3992..44e3156e7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -36,6 +36,7 @@ "header.profile": "Profile", "header.logout": "Log out", "header.login.discord": "Log in via Discord", + "logInPrompt": "Log in to use this", "header.language": "Language", "header.loggedInAs": "Logged in as {{userName}}", "header.theme": "Theme", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 9b25ab086..af94265c3 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -36,6 +36,7 @@ "header.profile": "Perfil", "header.logout": "Cerrar sesión", "header.login.discord": "Iniciar sesión con Discord", + "logInPrompt": "", "header.language": "Idioma", "header.loggedInAs": "Conectado como {{userName}}", "header.theme": "Tema", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 8e626023c..b85dcb1a9 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -36,6 +36,7 @@ "header.profile": "Perfil", "header.logout": "Cerrar sesión", "header.login.discord": "Iniciar sesión con Discord", + "logInPrompt": "", "header.language": "Idioma", "header.loggedInAs": "Ingresado como {{userName}}", "header.theme": "Tema", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 565bd9970..8c109030f 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -36,6 +36,7 @@ "header.profile": "Profil", "header.logout": "Déconnexion", "header.login.discord": "", + "logInPrompt": "", "header.language": "Langue", "header.loggedInAs": "Connecté en tant que {{userName}}", "header.theme": "Thème", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 48d3460cd..baf5aca90 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -36,6 +36,7 @@ "header.profile": "Profil", "header.logout": "Déconnexion", "header.login.discord": "", + "logInPrompt": "", "header.language": "Langue", "header.loggedInAs": "Connecté en tant que {{userName}}", "header.theme": "Thème", diff --git a/locales/he/common.json b/locales/he/common.json index f4d236385..6cddcb6ab 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -36,6 +36,7 @@ "header.profile": "פרופיל", "header.logout": "התנתקות", "header.login.discord": "", + "logInPrompt": "", "header.language": "שפה", "header.loggedInAs": "הנך מחובר בתור {{userName}}", "header.theme": "נושא", diff --git a/locales/it/common.json b/locales/it/common.json index 463bbd608..ddf2993c1 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -36,6 +36,7 @@ "header.profile": "Profilo", "header.logout": "Esci", "header.login.discord": "", + "logInPrompt": "", "header.language": "Lingua", "header.loggedInAs": "Autenticato come {{userName}}", "header.theme": "Tema", diff --git a/locales/ja/common.json b/locales/ja/common.json index d378a32a8..3e863d088 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -36,6 +36,7 @@ "header.profile": "プロファイル", "header.logout": "ログアウト", "header.login.discord": "Discord", + "logInPrompt": "", "header.language": "言語", "header.loggedInAs": "{{userName}} でログインしています", "header.theme": "テーマ", diff --git a/locales/ko/common.json b/locales/ko/common.json index 35cb27782..66bed23b9 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -36,6 +36,7 @@ "header.profile": "프로필", "header.logout": "로그아웃", "header.login.discord": "", + "logInPrompt": "", "header.language": "언어", "header.loggedInAs": "{{userName}}로 로그인됨", "header.theme": "테마", diff --git a/locales/nl/common.json b/locales/nl/common.json index 6ada8ff70..68c365a7d 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -36,6 +36,7 @@ "header.profile": "Profiel", "header.logout": "Log uit", "header.login.discord": "", + "logInPrompt": "", "header.language": "", "header.loggedInAs": "", "header.theme": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index 86c10f521..d0fc96817 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -36,6 +36,7 @@ "header.profile": "Profil", "header.logout": "Wyloguj się", "header.login.discord": "", + "logInPrompt": "", "header.language": "Język", "header.loggedInAs": "Zalogowany/a jako {{userName}}", "header.theme": "Motyw", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index a2e10c83d..dbbb98520 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -36,6 +36,7 @@ "header.profile": "Perfil", "header.logout": "Sair", "header.login.discord": "", + "logInPrompt": "", "header.language": "Idioma", "header.loggedInAs": "Logado como {{userName}}", "header.theme": "Tema", diff --git a/locales/ru/common.json b/locales/ru/common.json index 262ff7109..d1bea613e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -36,6 +36,7 @@ "header.profile": "Профиль", "header.logout": "Выйти", "header.login.discord": "", + "logInPrompt": "", "header.language": "Язык", "header.loggedInAs": "Вы вошли как {{userName}}", "header.theme": "Тема", diff --git a/locales/zh/common.json b/locales/zh/common.json index 263951aa7..e15375b2c 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -36,6 +36,7 @@ "header.profile": "个人资料", "header.logout": "退出登录", "header.login.discord": "通过 Discord 登录", + "logInPrompt": "", "header.language": "语言", "header.loggedInAs": "已登录: {{userName}}", "header.theme": "主题",