From 86613e3c933372dbcb196dd331bba94c0ef4bcc8 Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 17 Mar 2026 19:04:29 +0100 Subject: [PATCH 01/19] refactor: login page (@miodec) (#7595) Co-authored-by: Christian Fehmer Co-authored-by: Christian Fehmer --- frontend/.oxlintrc.json | 3 +- .../components/ui/ValidatedInput.spec.tsx | 82 ---- .../components/ui/form/Checkbox.spec.tsx | 91 +++++ .../ui/form/FieldIndicator.spec.tsx | 88 +++++ .../components/ui/form/InputField.spec.tsx | 118 ++++++ .../components/ui/form/SubmitButton.spec.tsx | 74 ++++ .../components/ui/form/utils.spec.ts | 133 +++++++ frontend/package.json | 3 +- frontend/src/html/pages/login.html | 100 ----- frontend/src/index.html | 4 +- frontend/src/styles/index.scss | 6 +- frontend/src/styles/login.scss | 79 ---- frontend/src/styles/tailwind.css | 11 + frontend/src/ts/auth.tsx | 66 +--- frontend/src/ts/components/common/Balloon.tsx | 21 +- frontend/src/ts/components/common/Button.tsx | 8 +- .../src/ts/components/common/Separator.tsx | 44 ++- frontend/src/ts/components/mount.tsx | 2 + .../src/ts/components/pages/login/Login.tsx | 153 ++++++++ .../ts/components/pages/login/LoginPage.tsx | 43 +++ .../ts/components/pages/login/Register.tsx | 261 +++++++++++++ .../pages/profile/ProfileSearchPage.tsx | 88 +++-- .../src/ts/components/ui/ValidatedInput.tsx | 55 --- .../src/ts/components/ui/form/Checkbox.tsx | 54 +++ .../ts/components/ui/form/FieldIndicator.tsx | 58 +++ .../src/ts/components/ui/form/InputField.tsx | 45 +++ .../ts/components/ui/form/SubmitButton.tsx | 53 +++ frontend/src/ts/components/ui/form/utils.ts | 55 +++ frontend/src/ts/constants/typo-list.ts | 1 + .../src/ts/controllers/page-controller.ts | 3 +- frontend/src/ts/elements/input-indicator.ts | 15 +- frontend/src/ts/elements/input-validation.ts | 5 + frontend/src/ts/event-handlers/login.ts | 8 - frontend/src/ts/firebase.ts | 10 +- frontend/src/ts/index.ts | 2 - frontend/src/ts/modals/google-sign-up.ts | 8 - frontend/src/ts/pages/login.ts | 350 ------------------ frontend/src/ts/ready.ts | 8 +- frontend/src/ts/stores/login.ts | 9 + frontend/src/ts/utils/remote-validation.ts | 56 ++- .../storybook/stories/Checkbox.stories.tsx | 64 ++++ .../stories/FieldIndicator.stories.tsx | 90 +++++ frontend/storybook/stories/Form.stories.tsx | 108 ++++++ .../storybook/stories/InputField.stories.tsx | 109 ++++++ .../stories/ValidatedInput.stories.tsx | 91 ----- pnpm-lock.yaml | 60 ++- 46 files changed, 1884 insertions(+), 911 deletions(-) delete mode 100644 frontend/__tests__/components/ui/ValidatedInput.spec.tsx create mode 100644 frontend/__tests__/components/ui/form/Checkbox.spec.tsx create mode 100644 frontend/__tests__/components/ui/form/FieldIndicator.spec.tsx create mode 100644 frontend/__tests__/components/ui/form/InputField.spec.tsx create mode 100644 frontend/__tests__/components/ui/form/SubmitButton.spec.tsx create mode 100644 frontend/__tests__/components/ui/form/utils.spec.ts delete mode 100644 frontend/src/html/pages/login.html delete mode 100644 frontend/src/styles/login.scss create mode 100644 frontend/src/ts/components/pages/login/Login.tsx create mode 100644 frontend/src/ts/components/pages/login/LoginPage.tsx create mode 100644 frontend/src/ts/components/pages/login/Register.tsx delete mode 100644 frontend/src/ts/components/ui/ValidatedInput.tsx create mode 100644 frontend/src/ts/components/ui/form/Checkbox.tsx create mode 100644 frontend/src/ts/components/ui/form/FieldIndicator.tsx create mode 100644 frontend/src/ts/components/ui/form/InputField.tsx create mode 100644 frontend/src/ts/components/ui/form/SubmitButton.tsx create mode 100644 frontend/src/ts/components/ui/form/utils.ts delete mode 100644 frontend/src/ts/event-handlers/login.ts delete mode 100644 frontend/src/ts/pages/login.ts create mode 100644 frontend/src/ts/stores/login.ts create mode 100644 frontend/storybook/stories/Checkbox.stories.tsx create mode 100644 frontend/storybook/stories/FieldIndicator.stories.tsx create mode 100644 frontend/storybook/stories/Form.stories.tsx create mode 100644 frontend/storybook/stories/InputField.stories.tsx delete mode 100644 frontend/storybook/stories/ValidatedInput.stories.tsx diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json index 68491cab5df3..6ae3e1f8e911 100644 --- a/frontend/.oxlintrc.json +++ b/frontend/.oxlintrc.json @@ -21,7 +21,8 @@ "rules": { "explicit-function-return-type": "off", "no-explicit-any": "off", - "no-unsafe-assignment": "off" + "no-unsafe-assignment": "off", + "no-empty-function": "off" } }, { diff --git a/frontend/__tests__/components/ui/ValidatedInput.spec.tsx b/frontend/__tests__/components/ui/ValidatedInput.spec.tsx deleted file mode 100644 index 90f48a74ac67..000000000000 --- a/frontend/__tests__/components/ui/ValidatedInput.spec.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { render, waitFor } from "@solidjs/testing-library"; -import userEvent from "@testing-library/user-event"; -import { createSignal } from "solid-js"; -import { describe, expect, it, vi } from "vitest"; -import { z } from "zod"; - -import { ValidatedInput } from "../../../src/ts/components/ui/ValidatedInput"; - -vi.mock("../../../src/ts/config", () => ({})); - -describe("ValidatedInput", () => { - it("renders with valid initial value", async () => { - const schema = z.string().min(4); - const { container } = render(() => ( - - )); - - await waitFor(() => container.querySelector(".inputAndIndicator") !== null); - - const wrapper = container.querySelector(".inputAndIndicator"); - const input = container.querySelector("input"); - console.log(container?.innerHTML); - expect(wrapper).toHaveClass("inputAndIndicator"); - expect(wrapper).toHaveAttribute("data-indicator-status", "success"); - expect(input).toHaveValue("Kevin"); - - const indicator = wrapper?.querySelector("div.indicator:not(.hidden)"); - expect(indicator).toBeInTheDocument(); - expect(indicator).toHaveAttribute("data-option-id", "success"); - expect(indicator?.querySelector("i")).toHaveClass("fa-check"); - }); - - it("renders with invalid initial value", async () => { - const schema = z.string().min(4); - const { container } = render(() => ( - - )); - - await waitFor(() => container.querySelector(".inputAndIndicator") !== null); - - const wrapper = container.querySelector(".inputAndIndicator"); - const input = container.querySelector("input"); - console.log(container?.innerHTML); - expect(wrapper).toHaveClass("inputAndIndicator"); - expect(wrapper).toHaveAttribute("data-indicator-status", "failed"); - expect(input).toHaveValue("Bob"); - - const indicator = wrapper?.querySelector("div.indicator:not(.hidden)"); - expect(indicator).toBeInTheDocument(); - expect(indicator).toHaveAttribute("data-option-id", "failed"); - expect(indicator?.querySelector("i")).toHaveClass("fa-times"); - }); - - it("updates callback", async () => { - const [value, setValue] = createSignal("Bob"); - const schema = z.string().min(4); - const { container } = render(() => ( - - )); - - await waitFor(() => container.querySelector(".inputAndIndicator") !== null); - console.log(container.innerHTML); - const input = container.querySelector("input") as HTMLInputElement; - expect(container.querySelector(".inputAndIndicator")).toHaveAttribute( - "data-indicator-status", - "failed", - ); - - await userEvent.type(input, "ington"); - - expect(value()).toEqual("Bobington"); - expect(container.querySelector(".inputAndIndicator")).toHaveAttribute( - "data-indicator-status", - "success", - ); - }); -}); diff --git a/frontend/__tests__/components/ui/form/Checkbox.spec.tsx b/frontend/__tests__/components/ui/form/Checkbox.spec.tsx new file mode 100644 index 000000000000..71936a160875 --- /dev/null +++ b/frontend/__tests__/components/ui/form/Checkbox.spec.tsx @@ -0,0 +1,91 @@ +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import { describe, it, expect, vi } from "vitest"; + +import { Checkbox } from "../../../../src/ts/components/ui/form/Checkbox"; + +function makeField(name: string, checked = false) { + return { + name, + state: { value: checked }, + handleBlur: vi.fn(), + handleChange: vi.fn(), + } as any; +} + +describe("Checkbox", () => { + it("renders with label text", () => { + const field = makeField("agree"); + render(() => field} label="I agree" />); + + expect(screen.getByText("I agree")).toBeInTheDocument(); + }); + + it("renders checkbox with field name", () => { + const field = makeField("terms"); + render(() => field} />); + + const input = screen.getByRole("checkbox", { hidden: true }); + expect(input).toHaveAttribute("id", "terms"); + expect(input).toHaveAttribute("name", "terms"); + }); + + it("reflects checked state", () => { + const field = makeField("opt", true); + render(() => field} />); + + const input = screen.getByRole("checkbox", { hidden: true }); + expect(input).toBeChecked(); + }); + + it("reflects unchecked state", () => { + const field = makeField("opt", false); + render(() => field} />); + + const input = screen.getByRole("checkbox", { hidden: true }); + expect(input).not.toBeChecked(); + }); + + it("calls handleChange on change", async () => { + const field = makeField("opt"); + render(() => field} />); + + const input = screen.getByRole("checkbox", { hidden: true }); + await fireEvent.change(input, { target: { checked: true } }); + expect(field.handleChange).toHaveBeenCalledWith(true); + }); + + it("calls handleBlur on blur", async () => { + const field = makeField("opt"); + render(() => field} />); + + const input = screen.getByRole("checkbox", { hidden: true }); + await fireEvent.blur(input); + expect(field.handleBlur).toHaveBeenCalled(); + }); + + it("renders disabled checkbox", () => { + const field = makeField("opt"); + render(() => field} disabled />); + + const input = screen.getByRole("checkbox", { hidden: true }); + expect(input).toBeDisabled(); + }); + + it("shows check icon styling when checked", () => { + const field = makeField("opt", true); + const { container } = render(() => field} />); + + const icon = container.querySelector(".fa-check"); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveClass("text-main"); + }); + + it("shows transparent icon styling when unchecked", () => { + const field = makeField("opt", false); + const { container } = render(() => field} />); + + const icon = container.querySelector(".fa-check"); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveClass("text-transparent"); + }); +}); diff --git a/frontend/__tests__/components/ui/form/FieldIndicator.spec.tsx b/frontend/__tests__/components/ui/form/FieldIndicator.spec.tsx new file mode 100644 index 000000000000..5a96d645125c --- /dev/null +++ b/frontend/__tests__/components/ui/form/FieldIndicator.spec.tsx @@ -0,0 +1,88 @@ +import { render } from "@solidjs/testing-library"; +import { describe, it, expect } from "vitest"; + +import { FieldIndicator } from "../../../../src/ts/components/ui/form/FieldIndicator"; + +function makeField(overrides: { + isValidating?: boolean; + isTouched?: boolean; + isValid?: boolean; + isDefaultValue?: boolean; + errors?: string[]; + hasWarning?: boolean; + warnings?: string[]; +}) { + return { + state: { + meta: { + isValidating: overrides.isValidating ?? false, + isTouched: overrides.isTouched ?? false, + isValid: overrides.isValid ?? true, + isDefaultValue: overrides.isDefaultValue ?? true, + errors: overrides.errors ?? [], + }, + }, + getMeta: () => ({ + hasWarning: overrides.hasWarning ?? false, + warnings: overrides.warnings ?? [], + }), + } as any; +} + +describe("FieldIndicator", () => { + it("shows loading spinner when validating", () => { + const { container } = render(() => ( + + )); + expect(container.querySelector(".fa-circle-notch")).toBeInTheDocument(); + }); + + it("shows error icon when touched and invalid", () => { + const { container } = render(() => ( + + )); + expect(container.querySelector(".fa-times")).toBeInTheDocument(); + }); + + it("shows warning icon when has warning", () => { + const { container } = render(() => ( + + )); + expect( + container.querySelector(".fa-exclamation-triangle"), + ).toBeInTheDocument(); + }); + + it("shows success check when touched, valid, and not default", () => { + const { container } = render(() => ( + + )); + expect(container.querySelector(".fa-check")).toBeInTheDocument(); + }); + + it("shows nothing when untouched and not validating", () => { + const { container } = render(() => ( + + )); + expect(container.querySelector(".fa-times")).not.toBeInTheDocument(); + expect(container.querySelector(".fa-check")).not.toBeInTheDocument(); + expect(container.querySelector(".fa-circle-notch")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/ui/form/InputField.spec.tsx b/frontend/__tests__/components/ui/form/InputField.spec.tsx new file mode 100644 index 000000000000..7b5659ad14f1 --- /dev/null +++ b/frontend/__tests__/components/ui/form/InputField.spec.tsx @@ -0,0 +1,118 @@ +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import { describe, it, expect, vi } from "vitest"; + +import { InputField } from "../../../../src/ts/components/ui/form/InputField"; + +function makeField(name: string, value = "") { + return { + name, + state: { + value, + meta: { + isValidating: false, + isTouched: false, + isValid: true, + isDefaultValue: true, + errors: [], + }, + }, + handleBlur: vi.fn(), + handleChange: vi.fn(), + getMeta: () => ({ hasWarning: false, warnings: [] }), + } as any; +} + +describe("InputField", () => { + it("renders input with field name as id", () => { + const field = makeField("email"); + render(() => field} />); + + const input = screen.getByRole("textbox"); + expect(input).toHaveAttribute("id", "email"); + expect(input).toHaveAttribute("name", "email"); + }); + + it("uses field name as default placeholder", () => { + const field = makeField("username"); + render(() => field} />); + + expect(screen.getByPlaceholderText("username")).toBeInTheDocument(); + }); + + it("uses custom placeholder when provided", () => { + const field = makeField("email"); + render(() => field} placeholder="Enter email" />); + + expect(screen.getByPlaceholderText("Enter email")).toBeInTheDocument(); + }); + + it("defaults to text type", () => { + const field = makeField("name"); + render(() => field} />); + + expect(screen.getByRole("textbox")).toHaveAttribute("type", "text"); + }); + + it("uses custom type", () => { + const field = makeField("password"); + const { container } = render(() => ( + field} type="password" /> + )); + + expect(container.querySelector("input")).toHaveAttribute( + "type", + "password", + ); + }); + + it("calls handleChange on input", async () => { + const field = makeField("name"); + render(() => field} />); + + await fireEvent.input(screen.getByRole("textbox"), { + target: { value: "test" }, + }); + expect(field.handleChange).toHaveBeenCalledWith("test"); + }); + + it("calls handleBlur on blur", async () => { + const field = makeField("name"); + render(() => field} />); + + await fireEvent.blur(screen.getByRole("textbox")); + expect(field.handleBlur).toHaveBeenCalled(); + }); + + it("calls onFocus callback", async () => { + const field = makeField("name"); + const onFocus = vi.fn(); + render(() => field} onFocus={onFocus} />); + + await fireEvent.focus(screen.getByRole("textbox")); + expect(onFocus).toHaveBeenCalled(); + }); + + it("renders disabled input", () => { + const field = makeField("name"); + render(() => field} disabled />); + + expect(screen.getByRole("textbox")).toBeDisabled(); + }); + + it("shows FieldIndicator when showIndicator is true", () => { + const field = makeField("name"); + field.state.meta.isValidating = true; + const { container } = render(() => ( + field} showIndicator /> + )); + + expect(container.querySelector(".fa-circle-notch")).toBeInTheDocument(); + }); + + it("hides FieldIndicator by default", () => { + const field = makeField("name"); + const { container } = render(() => field} />); + + expect(container.querySelector(".fa-circle-notch")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/ui/form/SubmitButton.spec.tsx b/frontend/__tests__/components/ui/form/SubmitButton.spec.tsx new file mode 100644 index 000000000000..d98a197e4697 --- /dev/null +++ b/frontend/__tests__/components/ui/form/SubmitButton.spec.tsx @@ -0,0 +1,74 @@ +import { render, screen } from "@solidjs/testing-library"; +import { JSXElement } from "solid-js"; +import { describe, it, expect } from "vitest"; + +import { SubmitButton } from "../../../../src/ts/components/ui/form/SubmitButton"; + +type FormState = { + canSubmit: boolean; + isSubmitting: boolean; + isValid: boolean; + isDirty: boolean; +}; + +function makeForm(state: Partial = {}) { + const fullState: FormState = { + canSubmit: true, + isSubmitting: false, + isValid: true, + isDirty: true, + ...state, + }; + + return { + Subscribe: (props: { + selector: (state: FormState) => FormState; + children: (state: () => FormState) => JSXElement; + }) => props.children(() => props.selector(fullState)), + }; +} + +describe("SubmitButton", () => { + it("renders enabled when form is dirty, valid, and can submit", () => { + render(() => ); + expect(screen.getByRole("button", { name: "Save" })).not.toBeDisabled(); + }); + + it("renders as submit type", () => { + render(() => ); + expect(screen.getByRole("button")).toHaveAttribute("type", "submit"); + }); + + it("disables when form is not dirty", () => { + render(() => ( + + )); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + it("disables when form cannot submit", () => { + render(() => ( + + )); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + it("disables when form is submitting", () => { + render(() => ( + + )); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + it("disables when form is not valid", () => { + render(() => ( + + )); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + it("disables when disabled prop is true even if form is ready", () => { + render(() => ); + expect(screen.getByRole("button")).toBeDisabled(); + }); +}); diff --git a/frontend/__tests__/components/ui/form/utils.spec.ts b/frontend/__tests__/components/ui/form/utils.spec.ts new file mode 100644 index 000000000000..1b1447ca31ef --- /dev/null +++ b/frontend/__tests__/components/ui/form/utils.spec.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from "vitest"; +import { z } from "zod"; + +import { + fromSchema, + handleResult, + allFieldsMandatory, + fieldMandatory, + type ValidationResult, +} from "../../../../src/ts/components/ui/form/utils"; + +describe("fromSchema", () => { + const schema = z.string().min(3, "too short").max(10, "too long"); + const validate = fromSchema(schema); + + it("returns undefined for valid value", () => { + expect(validate({ value: "hello" })).toBeUndefined(); + }); + + it("returns error messages for invalid value", () => { + expect(validate({ value: "ab" })).toEqual(["too short"]); + }); + + it("returns multiple error messages", () => { + const numSchema = z.number().min(5, "too small").max(3, "too big"); + const v = fromSchema(numSchema); + const result = v({ value: 4 }); + // 4 fails min(5) but passes max(3)? Actually 4 > 3, so both fail + // number 4: min(5) fails, max(3) fails + expect(result).toEqual(["too small", "too big"]); + }); +}); + +describe("handleResult", () => { + const mockSetMeta = vi.fn(); + + function makeField() { + mockSetMeta.mockClear(); + return { setMeta: mockSetMeta } as any; + } + + it("returns undefined for undefined results", () => { + expect(handleResult(makeField(), undefined)).toBeUndefined(); + }); + + it("returns undefined for empty results", () => { + expect(handleResult(makeField(), [])).toBeUndefined(); + }); + + it("returns error messages and ignores warnings", () => { + const results: ValidationResult[] = [ + { type: "error", message: "bad email" }, + { type: "error", message: "too short" }, + ]; + expect(handleResult(makeField(), results)).toEqual([ + "bad email", + "too short", + ]); + }); + + it("sets warning meta on field", () => { + const results: ValidationResult[] = [ + { type: "warning", message: "weak password" }, + ]; + const field = makeField(); + const result = handleResult(field, results); + + expect(result).toBeUndefined(); + expect(mockSetMeta).toHaveBeenCalledOnce(); + + const updater = mockSetMeta.mock.calls[0]![0]; + const newMeta = updater({ existing: true }); + expect(newMeta).toEqual({ + existing: true, + hasWarning: true, + warnings: ["weak password"], + }); + }); + + it("handles both errors and warnings", () => { + const results: ValidationResult[] = [ + { type: "warning", message: "not recommended" }, + { type: "error", message: "invalid" }, + ]; + const field = makeField(); + const result = handleResult(field, results); + + expect(mockSetMeta).toHaveBeenCalledOnce(); + expect(result).toEqual(["invalid"]); + }); +}); + +describe("allFieldsMandatory", () => { + const validate = allFieldsMandatory<{ a: string; b: string }>(); + + it("returns undefined when all fields have values", () => { + expect(validate({ value: { a: "x", b: "y" } })).toBeUndefined(); + }); + + it("returns error when a field is empty string", () => { + expect(validate({ value: { a: "x", b: "" } })).toBe( + "all fields are mandatory", + ); + }); + + it("returns error when a field is undefined", () => { + expect(validate({ value: { a: "x", b: undefined } as any })).toBe( + "all fields are mandatory", + ); + }); +}); + +describe("fieldMandatory", () => { + it("returns undefined for non-empty value", () => { + const validate = fieldMandatory(); + expect(validate({ value: "hello" })).toBeUndefined(); + }); + + it("returns default message for empty string", () => { + const validate = fieldMandatory(); + expect(validate({ value: "" })).toBe("mandatory"); + }); + + it("returns default message for undefined", () => { + const validate = fieldMandatory(); + expect(validate({ value: undefined })).toBe("mandatory"); + }); + + it("returns custom message", () => { + const validate = fieldMandatory("required field"); + expect(validate({ value: "" })).toBe("required field"); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 438af31ec477..a0703ffdb6cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@date-fns/utc": "1.2.0", - "@leonabcd123/modern-caps-lock": "2.2.2", + "@leonabcd123/modern-caps-lock": "3.0.4", "@monkeytype/contracts": "workspace:*", "@monkeytype/funbox": "workspace:*", "@monkeytype/schemas": "workspace:*", @@ -39,6 +39,7 @@ "@tanstack/pacer-lite": "0.2.1", "@tanstack/query-db-collection": "1.0.27", "@tanstack/solid-db": "0.2.10", + "@tanstack/solid-form": "1.28.4", "@tanstack/solid-query": "5.90.23", "@tanstack/solid-query-devtools": "5.91.3", "@tanstack/solid-table": "8.21.3", diff --git a/frontend/src/html/pages/login.html b/frontend/src/html/pages/login.html deleted file mode 100644 index f5321e360b8a..000000000000 --- a/frontend/src/html/pages/login.html +++ /dev/null @@ -1,100 +0,0 @@ - diff --git a/frontend/src/index.html b/frontend/src/index.html index c4ea32a36413..26f1c442e032 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -37,7 +37,9 @@ - + + } + else={ +
+ } + /> ); } diff --git a/frontend/src/ts/components/mount.tsx b/frontend/src/ts/components/mount.tsx index 57da6b278ec3..7e96184bfe23 100644 --- a/frontend/src/ts/components/mount.tsx +++ b/frontend/src/ts/components/mount.tsx @@ -13,6 +13,7 @@ import { Modals } from "./modals/Modals"; import { AboutPage } from "./pages/AboutPage"; import { MyProfile } from "./pages/account/MyProfile"; import { LeaderboardPage } from "./pages/leaderboard/LeaderboardPage"; +import { LoginPage } from "./pages/login/LoginPage"; import { ProfilePage } from "./pages/profile/ProfilePage"; import { ProfileSearchPage } from "./pages/profile/ProfileSearchPage"; import { Popups } from "./popups/Popups"; @@ -20,6 +21,7 @@ import { Popups } from "./popups/Popups"; const components: Record JSXElement> = { footer: () =>