Filter/search call to action to log in

This commit is contained in:
Kalle
2026-09-09 21:35:40 +03:00
parent f5cc5aa3cb
commit 4fd3526b6f
33 changed files with 293 additions and 75 deletions

View File

@@ -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;
}

View File

@@ -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<Record<string, unknown>>;
}) {
const { t } = useTranslation(["common"]);
return (
<SendouPopover trigger={children} popoverClassName={styles.popover}>
<div className={styles.text}>{t("common:logInPrompt")}</div>
<LogInButtonContainer>
<SendouButton
type="submit"
size="small"
icon={<LogIn />}
data-testid="log-in-popover-button"
>
{t("common:header.login.discord")}
</SendouButton>
</LogInButtonContainer>
</SendouPopover>
);
}

View File

@@ -87,6 +87,29 @@ function TestFilterBar(props: {
);
}
function TestHighlightsFilterBar() {
const [highlights, setHighlights] = useState(true);
return (
<FilterBar
pills={[
{
key: "highlights",
name: "Highlights",
formattedValue: highlights ? "Only" : null,
usableLoggedOut: true,
onRemove: () => setHighlights(false),
popover: (
<button type="button" onClick={() => setHighlights(!highlights)}>
Toggle highlights
</button>
),
},
]}
/>
);
}
describe("FilterBar", () => {
test("renders a set pill with its name and formatted value", async () => {
const screen = await render(<TestFilterBar initialMode="SZ" />);
@@ -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(<TestFilterBar initialMode="SZ" />);
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(<TestFilterBar />);
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(<TestHighlightsFilterBar />);
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();
});

View File

@@ -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<ReadonlySet<string>>(
new Set(),
);
const [openPillKey, setOpenPillKey] = React.useState<string | null>(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({
<FilterPill
key={pill.key}
pill={pill}
showLogInPrompt={!isLoggedIn && !pill.usableLoggedOut}
isOpen={openPillKey === pill.key}
onOpenChange={(isOpen) => setOpenPillKey(isOpen ? pill.key : null)}
onRemove={pill.onRemove ? () => removePill(pill) : undefined}
onRemove={isRemovable(pill) ? () => removePill(pill) : undefined}
/>
))}
{hiddenPills.length > 0 ? (
<AddFilterMenu pills={hiddenPills} onAdd={addPill} />
<AddFilterMenu
pills={hiddenPills}
isLoggedIn={isLoggedIn}
onAdd={addPill}
/>
) : null}
{onReset || actions ? (
<div className={styles.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 = (
<button
type="button"
className={styles.trigger}
data-active={pill.formattedValue !== null}
data-testid={pill.testId}
>
{pill.icon ? <span className={styles.icon}>{pill.icon}</span> : null}
<span>{pill.name}</span>
{pill.formattedValue !== null ? (
<span className={styles.value}>{pill.formattedValue}</span>
) : null}
<ChevronDown className={styles.chevron} />
</button>
);
return (
<div className={styles.pill}>
<SendouPopover
isOpen={isOpen}
onOpenChange={onOpenChange}
popoverClassName={clsx(styles.popover, pill.popoverClassName)}
trigger={
<button
type="button"
className={styles.trigger}
data-active={pill.formattedValue !== null}
data-testid={pill.testId}
>
{pill.icon ? (
<span className={styles.icon}>{pill.icon}</span>
) : null}
<span>{pill.name}</span>
{pill.formattedValue !== null ? (
<span className={styles.value}>{pill.formattedValue}</span>
) : null}
<ChevronDown className={styles.chevron} />
</button>
}
>
{pill.popover}
</SendouPopover>
{showLogInPrompt ? (
<LogInPopover>{trigger}</LogInPopover>
) : (
<SendouPopover
isOpen={isOpen}
onOpenChange={onOpenChange}
popoverClassName={clsx(styles.popover, pill.popoverClassName)}
trigger={trigger}
>
{pill.popover}
</SendouPopover>
)}
{onRemove ? (
<button
type="button"
@@ -155,27 +176,37 @@ function FilterPill({
function AddFilterMenu({
pills,
isLoggedIn,
onAdd,
}: {
pills: FilterBarPill[];
isLoggedIn: boolean;
onAdd: (pill: FilterBarPill) => void;
}) {
const { t } = useTranslation();
const trigger = (
<button
type="button"
className={styles.trigger}
data-testid="add-filter-button"
>
<Plus className={styles.plus} />
<span>{t("filterBar.addFilter")}</span>
</button>
);
if (!isLoggedIn) {
return (
<div className={styles.pill}>
<LogInPopover>{trigger}</LogInPopover>
</div>
);
}
return (
<div className={styles.pill}>
<SendouMenu
trigger={
<button
type="button"
className={styles.trigger}
data-testid="add-filter-button"
>
<Plus className={styles.plus} />
<span>{t("filterBar.addFilter")}</span>
</button>
}
>
<SendouMenu trigger={trigger}>
{pills.map((pill) => (
<SendouMenuItem
key={pill.key}

View File

@@ -10,6 +10,7 @@ import { SendouRadio, SendouRadioGroup } from "~/components/elements/Radio";
import { Image } from "~/components/Image";
import { Input } from "~/components/Input";
import { LocaleTime } from "~/components/LocaleTime";
import { LogInPopover } from "~/components/LogInPopover";
import type { SearchLoaderData } from "~/features/search/routes/search";
import { searchSearchParams } from "~/features/search/search-search-params";
import { tournamentOrganizationPage } from "~/features/tournament-organization/tournament-organization-urls";
@@ -144,6 +145,20 @@ export function GlobalSearch() {
);
}
/** Search is logged in only, so a logged out visitor gets a log in prompt instead. */
export function LoggedOutGlobalSearch() {
const { t } = useTranslation(["common"]);
return (
<LogInPopover>
<button type="button" className={styles.searchButton}>
<Search className={styles.searchIcon} />
<span className={styles.searchPlaceholder}>{t("common:search")}</span>
</button>
</LogInPopover>
);
}
function resolveInitialWeapon(
weaponId: MainWeaponId | null,
t: TFunction<["common", "weapons"]>,

View File

@@ -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({
</div>
</>
) : null}
<div className={styles.searchAndAddContainer}>
<div className={styles.searchWrapper}>
{isLoggedIn ? <GlobalSearch /> : <LoggedOutGlobalSearch />}
</div>
{isLoggedIn ? <AnythingAdder /> : null}
</div>
{isLoggedIn ? (
<>
<div className={styles.searchAndAddContainer}>
{showSearch ? (
<div className={styles.searchWrapper}>
<GlobalSearch />
</div>
) : null}
<AnythingAdder />
</div>
{onChatToggle ? (
<div className={styles.chatButtonWrapperPersistent}>
<ChatButton

View File

@@ -462,7 +462,6 @@ export function Layout({
showSupport={Boolean(
data && !data?.user?.roles.includes("MINOR_SUPPORT"),
)}
showSearch={Boolean(data?.user)}
isLoggedIn={Boolean(data?.user)}
onChatToggle={
data?.user && !chatSidebarOpen

View File

@@ -13,10 +13,6 @@
width: 100%;
max-width: var(--columns-width);
margin-inline: auto;
&:empty {
display: none;
}
}
.buttonsContainer {

View File

@@ -95,14 +95,14 @@ export default function ScrimsPage() {
return (
<Main className="stack lg">
{user ? (
<div className="stack horizontal sm items-center flex-wrap">
<div className="stack horizontal sm items-center flex-wrap">
{user ? (
<LinkButton size="small" to={associationsPage()} variant="outlined">
{t("scrims:associations.title")}
</LinkButton>
<Filters />
</div>
) : null}
) : null}
<Filters />
</div>
<SendouTabs
key={pendingRequestPostId}
defaultSelectedKey={

View File

@@ -80,6 +80,7 @@ export function ResultsFiltersBar() {
formattedValue: filters.highlightsOnly ? t("results.filter.only") : null,
onRemove: () => setFilters({ highlightsOnly: false }),
onAdd: () => setFilters({ highlightsOnly: true }),
usableLoggedOut: true,
testId: "highlights-filter",
popover: (
<SendouSwitch

View File

@@ -35,16 +35,9 @@ export const loader = async ({ request, url }: LoaderFunctionArgs) => {
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
? {

View File

@@ -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

View File

@@ -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"),
};
}
}

View File

@@ -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" }),
};
}
}

View File

@@ -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 }) });
}

View File

@@ -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();

View File

@@ -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 ({

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -36,6 +36,7 @@
"header.profile": "פרופיל",
"header.logout": "התנתקות",
"header.login.discord": "",
"logInPrompt": "",
"header.language": "שפה",
"header.loggedInAs": "הנך מחובר בתור {{userName}}",
"header.theme": "נושא",

View File

@@ -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",

View File

@@ -36,6 +36,7 @@
"header.profile": "プロファイル",
"header.logout": "ログアウト",
"header.login.discord": "Discord",
"logInPrompt": "",
"header.language": "言語",
"header.loggedInAs": "{{userName}} でログインしています",
"header.theme": "テーマ",

View File

@@ -36,6 +36,7 @@
"header.profile": "프로필",
"header.logout": "로그아웃",
"header.login.discord": "",
"logInPrompt": "",
"header.language": "언어",
"header.loggedInAs": "{{userName}}로 로그인됨",
"header.theme": "테마",

View File

@@ -36,6 +36,7 @@
"header.profile": "Profiel",
"header.logout": "Log uit",
"header.login.discord": "",
"logInPrompt": "",
"header.language": "",
"header.loggedInAs": "",
"header.theme": "",

View File

@@ -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",

View File

@@ -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",

View File

@@ -36,6 +36,7 @@
"header.profile": "Профиль",
"header.logout": "Выйти",
"header.login.discord": "",
"logInPrompt": "",
"header.language": "Язык",
"header.loggedInAs": "Вы вошли как {{userName}}",
"header.theme": "Тема",

View File

@@ -36,6 +36,7 @@
"header.profile": "个人资料",
"header.logout": "退出登录",
"header.login.discord": "通过 Discord 登录",
"logInPrompt": "",
"header.language": "语言",
"header.loggedInAs": "已登录: {{userName}}",
"header.theme": "主题",