From 331e2db953e784ed9e15dde630e0047a387be439 Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sun, 2 Aug 2026 19:35:14 -0300 Subject: [PATCH 1/2] feat: make the app readable, reachable and less pushy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four labels sat at roughly 2:1 against the dark background — the AUTO and MANUAL switch and the reset button among them — while the same file got it right two lines below. The settings panel crushed its title into three lines on a phone, and the period selector spilled "Ano" onto a row of its own. Nothing announced results to a screen reader, while the clock ticking every second was the one thing left readable; that is now inverted. Switching tabs replaced the whole panel without moving focus. "?view=salary" rendered the journey tab first and both tabs shared one title and one canonical, because the page resolved the view on the client. It resolves on the server now, so a shared link opens where it says it does and each tab describes itself. The daily journey was configured twice, once per tab, free to disagree in silence. Overtime in reais reads the hourly rate the salary tab leaves behind instead of mounting a second copy of its hook. The video ad no longer opens on top of someone mid-sentence, the adblock notice asks from the footer instead of blocking the door, and accepting cookies stops reloading the page out from under a filled form. Empregado Público is gone: it carried the exact same contribution table as CLT, so it was a choice with no consequence. Anyone who had it selected lands on CLT, and the regime picker now stays folded for the majority who never needs it. Co-Authored-By: Claude Opus 5 --- __tests__/ad-block-modal.test.tsx | 65 ----------- __tests__/ad-block-notice.test.tsx | 52 +++++++++ __tests__/ad-manager.test.tsx | 125 ++++++++++++--------- __tests__/analytics-wrapper.test.tsx | 27 ++++- __tests__/app-header.test.tsx | 87 ++++++++++++++ __tests__/calculator-view.test.ts | 11 ++ __tests__/calculator-views.test.tsx | 41 +++---- __tests__/cookie-consent.test.tsx | 104 ++++++++--------- __tests__/page.test.tsx | 97 ++++++++-------- __tests__/salary-calculator.test.tsx | 33 +++++- __tests__/side-ads.test.tsx | 31 +++-- __tests__/use-hourly-rate.test.ts | 32 ++++++ __tests__/use-salary-calculator.test.ts | 27 ++++- __tests__/work-calculator.test.tsx | 12 +- app/page.tsx | 105 +++++++---------- components/molecules/ad-block-modal.tsx | 79 ------------- components/molecules/ad-block-notice.tsx | 58 ++++++++++ components/molecules/cookie-consent.tsx | 9 +- components/molecules/extra-entry-row.tsx | 2 +- components/molecules/period-selector.tsx | 4 +- components/molecules/side-ads.tsx | 38 +++---- components/organisms/ad-manager.tsx | 54 +++++++-- components/organisms/analytics-wrapper.tsx | 8 +- components/organisms/app-header.tsx | 77 +++++++++++++ components/organisms/calculator-views.tsx | 36 +++--- components/organisms/journey-form.tsx | 66 ++++++----- components/organisms/salary-calculator.tsx | 35 +++--- components/organisms/work-calculator.tsx | 23 ++-- hooks/use-hourly-rate.ts | 15 +++ hooks/use-salary-calculator.ts | 67 ++++++----- lib/calculator-view.ts | 7 ++ lib/consent.ts | 2 + lib/storage.ts | 3 + 33 files changed, 895 insertions(+), 537 deletions(-) delete mode 100644 __tests__/ad-block-modal.test.tsx create mode 100644 __tests__/ad-block-notice.test.tsx create mode 100644 __tests__/app-header.test.tsx create mode 100644 __tests__/calculator-view.test.ts create mode 100644 __tests__/use-hourly-rate.test.ts delete mode 100644 components/molecules/ad-block-modal.tsx create mode 100644 components/molecules/ad-block-notice.tsx create mode 100644 components/organisms/app-header.tsx create mode 100644 hooks/use-hourly-rate.ts create mode 100644 lib/calculator-view.ts diff --git a/__tests__/ad-block-modal.test.tsx b/__tests__/ad-block-modal.test.tsx deleted file mode 100644 index 8a36b2e..0000000 --- a/__tests__/ad-block-modal.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AdBlockModal } from "@/components/molecules/ad-block-modal"; - -describe("AdBlockModal", () => { - const mockOnClose = vi.fn(); - const mockOnConfirm = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("does not render when isOpen is false", () => { - const { container } = render(); - expect(container.querySelector("h2")).toBeNull(); - }); - - it("renders modal content when isOpen is true", () => { - render(); - expect(screen.getByText("Opa! Uma ajudinha?")).toBeDefined(); - expect(screen.getByText("Prometemos não ser chatos")).toBeDefined(); - expect(screen.getByText("Já desativei, pode contar comigo!")).toBeDefined(); - expect(screen.getByText("Continuar com AdBlock ativo")).toBeDefined(); - }); - - it("calls onConfirm when confirm button is clicked", async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByText("Já desativei, pode contar comigo!")); - expect(mockOnConfirm).toHaveBeenCalledOnce(); - }); - - it("calls onClose when continue button is clicked", async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByText("Continuar com AdBlock ativo")); - expect(mockOnClose).toHaveBeenCalled(); - }); - - it("calls onClose when X button is clicked", async () => { - const user = userEvent.setup(); - render(); - await user.click( - screen.getByRole("button", { - name: "Fechar aviso do bloqueador de anúncios", - }), - ); - expect(mockOnClose).toHaveBeenCalledOnce(); - }); - - it("opens as a modal dialog named by its heading", () => { - const { container } = render(); - const dialog = container.querySelector("dialog") as HTMLDialogElement; - expect(dialog.open).toBe(true); - expect(dialog.getAttribute("aria-labelledby")).toBe("ad-block-modal-title"); - expect(screen.getByText("Opa! Uma ajudinha?").id).toBe("ad-block-modal-title"); - }); - - it("calls onClose when the native close event fires", () => { - const { container } = render(); - fireEvent(container.querySelector("dialog") as Element, new Event("close")); - expect(mockOnClose).toHaveBeenCalledOnce(); - }); -}); diff --git a/__tests__/ad-block-notice.test.tsx b/__tests__/ad-block-notice.test.tsx new file mode 100644 index 0000000..bec3cd2 --- /dev/null +++ b/__tests__/ad-block-notice.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AdBlockNotice } from "@/components/molecules/ad-block-notice"; + +describe("AdBlockNotice", () => { + const mockOnClose = vi.fn(); + const mockOnConfirm = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not render when isOpen is false", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("renders the notice content when isOpen is true", () => { + render(); + expect(screen.getByText("Opa! Uma ajudinha?")).toBeDefined(); + expect(screen.getByText("Prometemos não ser chatos")).toBeDefined(); + expect(screen.getByText("Já desativei, pode contar comigo!")).toBeDefined(); + expect(screen.getByText("Continuar com AdBlock ativo")).toBeDefined(); + }); + + it("renders as a dismissible footer notice instead of a blocking dialog", () => { + const { container } = render(); + + expect(container.querySelector("dialog")).toBeNull(); + + const notice = screen.getByRole("complementary"); + expect(notice.getAttribute("aria-labelledby")).toBe("ad-block-notice-title"); + expect(screen.getByText("Opa! Uma ajudinha?").id).toBe("ad-block-notice-title"); + expect(notice.className).toContain("fixed"); + expect(notice.className).toContain("env(safe-area-inset-bottom)"); + }); + + it("calls onConfirm when confirm button is clicked", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText("Já desativei, pode contar comigo!")); + expect(mockOnConfirm).toHaveBeenCalledOnce(); + }); + + it("calls onClose when the dismiss button is clicked", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText("Continuar com AdBlock ativo")); + expect(mockOnClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/ad-manager.test.tsx b/__tests__/ad-manager.test.tsx index 6514236..d59c8e6 100644 --- a/__tests__/ad-manager.test.tsx +++ b/__tests__/ad-manager.test.tsx @@ -5,7 +5,8 @@ import { AdManager } from "@/components/organisms/ad-manager"; const MOCK_ADSENSE_ID = "ca-pub-123456789"; const SIDE_AD_KEY = "workload_side_ads_last_view"; const VIDEO_AD_KEY = "workload_video_ad_last_view"; -const _ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; +const VIDEO_AD_DELAY_MS = 120000; +const REQUIRED_IDLE_MS = 30000; const mockFetch = vi.fn(); global.fetch = mockFetch; @@ -16,6 +17,17 @@ Object.defineProperty(window, "location", { value: { ...window.location, reload: mockReload }, }); +const enableAdsEnv = () => { + vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); + vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); +}; + +const advanceBy = (milliseconds: number) => { + act(() => { + vi.advanceTimersByTime(milliseconds); + }); +}; + describe("AdManager", () => { beforeEach(() => { localStorage.clear(); @@ -32,83 +44,103 @@ describe("AdManager", () => { }); it("checks cooldowns in localStorage on mount", () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + enableAdsEnv(); const spy = vi.spyOn(Storage.prototype, "getItem"); render(); expect(spy).toHaveBeenCalledWith(SIDE_AD_KEY); expect(spy).toHaveBeenCalledWith(VIDEO_AD_KEY); }); - it("shows side ads after 2 seconds when no previous view exists", async () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + it("shows side ads after 2 seconds when no previous view exists", () => { + enableAdsEnv(); render(); expect(screen.queryByText("Espaço do Apoiador")).toBeNull(); - act(() => { - vi.advanceTimersByTime(2000); - }); + advanceBy(2000); expect(screen.getAllByText("Espaço do Apoiador")).toHaveLength(2); }); - it("shows video modal after 30 seconds", async () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + it("shows the video modal once the user has been idle for the whole delay", () => { + enableAdsEnv(); render(); expect(screen.queryByText("Vídeo da Semana")).toBeNull(); - act(() => { - vi.advanceTimersByTime(120000); - }); + advanceBy(VIDEO_AD_DELAY_MS); expect(screen.getByText("Vídeo da Semana")).toBeDefined(); }); + it("reschedules the video modal while the user is still interacting", () => { + enableAdsEnv(); + render(); + + advanceBy(VIDEO_AD_DELAY_MS - 10000); + fireEvent.keyDown(document.body, { key: "a" }); + advanceBy(10000); + + expect(screen.queryByText("Vídeo da Semana")).toBeNull(); + + advanceBy(REQUIRED_IDLE_MS); + + expect(screen.getByText("Vídeo da Semana")).toBeDefined(); + }); + + it("reschedules the video modal while a form field holds the focus", () => { + enableAdsEnv(); + const formField = document.createElement("input"); + document.body.appendChild(formField); + formField.focus(); + + render(); + + advanceBy(VIDEO_AD_DELAY_MS); + expect(screen.queryByText("Vídeo da Semana")).toBeNull(); + + formField.blur(); + advanceBy(REQUIRED_IDLE_MS); + + expect(screen.getByText("Vídeo da Semana")).toBeDefined(); + formField.remove(); + }); + it("hides side ads when viewed less than a week ago", () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + enableAdsEnv(); localStorage.setItem(SIDE_AD_KEY, Date.now().toString()); render(); - act(() => { - vi.advanceTimersByTime(2000); - }); + advanceBy(2000); expect(screen.queryByText("Espaço do Apoiador")).toBeNull(); }); it("hides video ad when viewed less than a week ago", () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + enableAdsEnv(); localStorage.setItem(VIDEO_AD_KEY, Date.now().toString()); render(); - act(() => { - vi.advanceTimersByTime(120000); - }); + advanceBy(VIDEO_AD_DELAY_MS); expect(screen.queryByText("Vídeo da Semana")).toBeNull(); }); - it("shows adblock modal when fetch fails", async () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + it("shows the adblock notice without blocking the page when fetch fails", async () => { + enableAdsEnv(); mockFetch.mockRejectedValueOnce(new Error("blocked")); - render(); + const { container } = render(); await vi.waitFor(() => { expect(screen.getByText("Opa! Uma ajudinha?")).toBeDefined(); }); + expect(container.querySelector("dialog[open]")).toBeNull(); + expect(document.body.style.overflow).toBe(""); }); - it("closes the adblock modal without reloading when dismissed", async () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + it("closes the adblock notice without reloading when dismissed", async () => { + enableAdsEnv(); mockFetch.mockRejectedValueOnce(new Error("blocked")); render(); @@ -119,12 +151,12 @@ describe("AdManager", () => { fireEvent.click(screen.getByText("Continuar com AdBlock ativo")); + expect(screen.queryByText("Opa! Uma ajudinha?")).toBeNull(); expect(mockReload).not.toHaveBeenCalled(); }); - it("reloads the page and closes the modal when confirming adblock is disabled", async () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + it("reloads the page and closes the notice when confirming adblock is disabled", async () => { + enableAdsEnv(); mockFetch.mockRejectedValueOnce(new Error("blocked")); render(); @@ -138,37 +170,28 @@ describe("AdManager", () => { expect(mockReload).toHaveBeenCalledOnce(); }); - it("persists the side ads cooldown and hides them when closed early", () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + it("persists the side ads cooldown and hides them when closed", () => { + enableAdsEnv(); render(); - act(() => { - vi.advanceTimersByTime(2000); - }); + advanceBy(2000); expect(screen.getAllByText("Espaço do Apoiador")).toHaveLength(2); - const closeButtons = screen.getAllByRole("button"); - fireEvent.click(closeButtons[0]); + fireEvent.click(screen.getByRole("button", { name: "Fechar anúncio do lado esquerdo" })); expect(localStorage.getItem(SIDE_AD_KEY)).not.toBeNull(); expect(screen.queryByText("Espaço do Apoiador")).toBeNull(); }); it("marks the video ad as watched and closes it once playback completes", () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + enableAdsEnv(); render(); - act(() => { - vi.advanceTimersByTime(120000); - }); + advanceBy(VIDEO_AD_DELAY_MS); expect(screen.getByText("Vídeo da Semana")).toBeDefined(); fireEvent.click(screen.getByText("Ver vídeo e apoiar o projeto")); - act(() => { - vi.advanceTimersByTime(15000); - }); + advanceBy(15000); fireEvent.click(screen.getByRole("button", { name: "Fechar vídeo" })); diff --git a/__tests__/analytics-wrapper.test.tsx b/__tests__/analytics-wrapper.test.tsx index db46f5a..98a359d 100644 --- a/__tests__/analytics-wrapper.test.tsx +++ b/__tests__/analytics-wrapper.test.tsx @@ -1,6 +1,7 @@ -import { render } from "@testing-library/react"; +import { act, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AnalyticsWrapper } from "@/components/organisms/analytics-wrapper"; +import { CONSENT_CHANGED_EVENT } from "@/lib/consent"; const CONSENT_KEY = "workload_cookie_consent"; @@ -43,4 +44,28 @@ describe("AnalyticsWrapper", () => { const { getByTestId } = render(); expect(getByTestId("google-analytics")).toHaveAttribute("data-ga-id", "GA-TEST-ID"); }); + + it("loads analytics as soon as consent is granted, without a reload", () => { + vi.stubEnv("NEXT_PUBLIC_GA_ID", "GA-TEST-ID"); + const { container, queryByTestId } = render(); + expect(container).toBeEmptyDOMElement(); + + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true })); + act(() => { + window.dispatchEvent(new Event(CONSENT_CHANGED_EVENT)); + }); + + expect(queryByTestId("google-analytics")).toBeInTheDocument(); + }); + + it("stops listening for consent changes once unmounted", () => { + vi.stubEnv("NEXT_PUBLIC_GA_ID", "GA-TEST-ID"); + const removeEventListener = vi.spyOn(window, "removeEventListener"); + const { unmount } = render(); + + unmount(); + + expect(removeEventListener).toHaveBeenCalledWith(CONSENT_CHANGED_EVENT, expect.any(Function)); + removeEventListener.mockRestore(); + }); }); diff --git a/__tests__/app-header.test.tsx b/__tests__/app-header.test.tsx new file mode 100644 index 0000000..ed7576b --- /dev/null +++ b/__tests__/app-header.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderToString } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppHeader } from "@/components/organisms/app-header"; +import { safeGAEvent } from "@/lib/analytics"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +const themeState: { resolvedTheme: string | undefined; setTheme: () => void } = { + resolvedTheme: undefined, + setTheme: vi.fn(), +}; + +vi.mock("next-themes", () => ({ + useTheme: () => themeState, +})); + +function renderAtFixedTime() { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-06T09:30:00")); + return render(); +} + +describe("AppHeader", () => { + beforeEach(() => { + vi.clearAllMocks(); + themeState.resolvedTheme = undefined; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("names the application and holds the clock still on the server", () => { + const markup = renderToString(); + + expect(markup).toContain("WorkLoad"); + expect(markup).toContain("Sua jornada de trabalho, clara e no seu controle"); + expect(markup).toContain("--:--:--"); + }); + + it("shows the live clock once the client takes over", () => { + renderAtFixedTime(); + + expect(screen.getByText("09:30:00")).toBeInTheDocument(); + }); + + it("hides the ticking clock from assistive technology", () => { + renderAtFixedTime(); + + expect(screen.getByText("09:30:00").closest("[aria-hidden='true']")).toBeInTheDocument(); + }); + + it("reports the session metadata on mount", () => { + render(); + + expect(safeGAEvent).toHaveBeenCalledWith( + "session_metadata", + expect.objectContaining({ viewport_width: window.innerWidth }), + ); + }); + + it("offers the dark theme while the light one is active", async () => { + themeState.resolvedTheme = "light"; + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Alternar tema" })); + + expect(themeState.setTheme).toHaveBeenCalledWith("dark"); + expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { theme: "dark" }); + }); + + it("offers the light theme while the dark one is active", async () => { + themeState.resolvedTheme = "dark"; + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Alternar tema" })); + + expect(themeState.setTheme).toHaveBeenCalledWith("light"); + expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { theme: "light" }); + }); +}); diff --git a/__tests__/calculator-view.test.ts b/__tests__/calculator-view.test.ts new file mode 100644 index 0000000..29164fd --- /dev/null +++ b/__tests__/calculator-view.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { toCalculatorView } from "@/lib/calculator-view"; + +describe("toCalculatorView", () => { + it("only accepts the salary view, falling back to the journey", () => { + expect(toCalculatorView("salary")).toBe("salary"); + expect(toCalculatorView("work")).toBe("work"); + expect(toCalculatorView("anything-else")).toBe("work"); + expect(toCalculatorView(null)).toBe("work"); + }); +}); diff --git a/__tests__/calculator-views.test.tsx b/__tests__/calculator-views.test.tsx index 310288e..e871ae2 100644 --- a/__tests__/calculator-views.test.tsx +++ b/__tests__/calculator-views.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { CalculatorViews, CalculatorViewsFromUrl, toCalculatorView } from "@/components/organisms/calculator-views"; +import { CalculatorViews } from "@/components/organisms/calculator-views"; import { safeGAEvent } from "@/lib/analytics"; vi.mock("@/lib/analytics", () => ({ @@ -16,25 +16,9 @@ vi.mock("@/components/organisms/salary-calculator", () => ({ SalaryCalculator: () =>

Painel do custo da hora

, })); -const searchParams = { current: new URLSearchParams() }; - -vi.mock("next/navigation", () => ({ - useSearchParams: () => searchParams.current, -})); - -describe("toCalculatorView", () => { - it("only accepts the salary view, falling back to the journey", () => { - expect(toCalculatorView("salary")).toBe("salary"); - expect(toCalculatorView("work")).toBe("work"); - expect(toCalculatorView("anything-else")).toBe("work"); - expect(toCalculatorView(null)).toBe("work"); - }); -}); - describe("CalculatorViews", () => { beforeEach(() => { vi.clearAllMocks(); - searchParams.current = new URLSearchParams(); }); it("marks the active tab and links both views by URL", () => { @@ -63,16 +47,25 @@ describe("CalculatorViews", () => { expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "salary" }); }); - it("reads the active view from the query string", () => { - searchParams.current = new URLSearchParams("view=salary"); - render(); + it("leaves the focus alone on the first render", () => { + const { container } = render(); - expect(screen.getByText("Painel do custo da hora")).toBeInTheDocument(); + expect(container.querySelector("#main-content")).not.toHaveFocus(); + expect(document.activeElement).toBe(document.body); }); - it("falls back to the journey when the query string has no view", () => { - render(); + it("moves the focus to the panel once the view changes", () => { + const { container, rerender } = render(); - expect(screen.getByText("Painel da jornada")).toBeInTheDocument(); + rerender(); + + expect(container.querySelector("#main-content")).toHaveFocus(); + }); + + it("promises that nothing leaves the browser and that the numbers are an estimate", () => { + render(); + + expect(screen.getByText(/fica salvo apenas neste navegador/)).toBeInTheDocument(); + expect(screen.getByText(/não substituem seu holerite/)).toBeInTheDocument(); }); }); diff --git a/__tests__/cookie-consent.test.tsx b/__tests__/cookie-consent.test.tsx index b1fdd28..ebd2eff 100644 --- a/__tests__/cookie-consent.test.tsx +++ b/__tests__/cookie-consent.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CookieConsent } from "@/components/molecules/cookie-consent"; const CONSENT_KEY = "workload_cookie_consent"; +const CONSENT_CHANGED_EVENT = "workload:consent-changed"; const BANNER_DELAY_MS = 1500; const mockReload = vi.fn(); @@ -11,20 +12,34 @@ Object.defineProperty(window, "location", { value: { ...window.location, reload: mockReload }, }); +let consentEventDetails: { telemetry: boolean }[] = []; +const captureConsentEvent: EventListener = (event) => { + consentEventDetails.push((event as CustomEvent<{ telemetry: boolean }>).detail); +}; + const openSettingsFromShieldButton = () => { fireEvent.click(screen.getByRole("button", { name: "Configurações de Privacidade" })); }; const getTelemetryToggle = () => screen.getByRole("switch"); +const showBanner = () => { + act(() => { + vi.advanceTimersByTime(BANNER_DELAY_MS); + }); +}; + describe("CookieConsent", () => { beforeEach(() => { localStorage.clear(); vi.clearAllMocks(); vi.useFakeTimers(); + consentEventDetails = []; + window.addEventListener(CONSENT_CHANGED_EVENT, captureConsentEvent); }); afterEach(() => { + window.removeEventListener(CONSENT_CHANGED_EVENT, captureConsentEvent); vi.useRealTimers(); }); @@ -35,10 +50,8 @@ describe("CookieConsent", () => { it("renders the banner after the delay elapses when no consent is stored", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); - expect(screen.getByText("Respeitamos sua privacidade")).toBeDefined(); + showBanner(); + expect(screen.getByRole("heading", { level: 2, name: "Respeitamos sua privacidade" })).toBeDefined(); }); it("clears the mount timer on unmount so it never fires", () => { @@ -51,62 +64,54 @@ describe("CookieConsent", () => { it("does not show the banner and seeds the toggle as enabled when consent was stored as true", () => { localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true })); render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); expect(screen.queryByText("Respeitamos sua privacidade")).toBeNull(); openSettingsFromShieldButton(); fireEvent.click(screen.getByText("Salvar Preferências")); expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(true); - expect(mockReload).toHaveBeenCalledOnce(); + expect(consentEventDetails).toEqual([{ telemetry: true }]); }); it("does not show the banner and seeds the toggle as disabled when consent was stored as false", () => { localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false })); render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); expect(screen.queryByText("Respeitamos sua privacidade")).toBeNull(); openSettingsFromShieldButton(); fireEvent.click(screen.getByText("Salvar Preferências")); expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(false); - expect(mockReload).toHaveBeenCalledOnce(); + expect(consentEventDetails).toEqual([{ telemetry: false }]); }); - it("persists accepting all cookies and reloads the page", () => { + it("persists accepting all cookies and announces the change without reloading", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Aceitar Tudo")); expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(true); - expect(mockReload).toHaveBeenCalledOnce(); + expect(consentEventDetails).toEqual([{ telemetry: true }]); + expect(mockReload).not.toHaveBeenCalled(); }); - it("persists refusing cookies and reloads the page", () => { + it("persists refusing cookies and announces the change without reloading", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Recusar")); expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(false); - expect(mockReload).toHaveBeenCalledOnce(); + expect(consentEventDetails).toEqual([{ telemetry: false }]); + expect(mockReload).not.toHaveBeenCalled(); }); it("opens the settings dialog from the banner's Configurar link", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); @@ -115,9 +120,7 @@ describe("CookieConsent", () => { it("opens the settings dialog as a modal named by its heading", () => { const { container } = render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); const dialog = container.querySelector("dialog") as HTMLDialogElement; @@ -128,23 +131,19 @@ describe("CookieConsent", () => { it("closes the settings dialog from the backdrop without persisting anything", () => { const { container } = render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); fireEvent.click(container.querySelector("dialog") as Element); expect(screen.queryByText("Privacidade")).toBeNull(); expect(localStorage.getItem(CONSENT_KEY)).toBeNull(); - expect(mockReload).not.toHaveBeenCalled(); + expect(consentEventDetails).toEqual([]); }); it("closes the settings dialog when the native close event fires", () => { const { container } = render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); fireEvent(container.querySelector("dialog") as Element, new Event("close")); @@ -155,9 +154,7 @@ describe("CookieConsent", () => { it("closes the settings dialog from the X button without persisting anything", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); fireEvent.click( @@ -168,24 +165,20 @@ describe("CookieConsent", () => { expect(screen.queryByText("Privacidade")).toBeNull(); expect(localStorage.getItem(CONSENT_KEY)).toBeNull(); - expect(mockReload).not.toHaveBeenCalled(); + expect(consentEventDetails).toEqual([]); }); it("announces the always-on state of the essential cookies", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); expect(screen.getByText("Sempre ativo")).toBeDefined(); }); - it("toggles telemetry off and saves the toggled value", () => { + it("toggles telemetry off, saves the toggled value and closes the dialog", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); const toggle = getTelemetryToggle(); @@ -197,15 +190,14 @@ describe("CookieConsent", () => { fireEvent.click(screen.getByText("Salvar Preferências")); expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(false); - expect(mockReload).toHaveBeenCalledOnce(); + expect(consentEventDetails).toEqual([{ telemetry: false }]); + expect(screen.queryByText("Privacidade")).toBeNull(); }); it("toggles telemetry back on from a stored disabled consent and saves the toggled value", () => { localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false })); render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); openSettingsFromShieldButton(); @@ -216,6 +208,16 @@ describe("CookieConsent", () => { fireEvent.click(screen.getByText("Salvar Preferências")); expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(true); - expect(mockReload).toHaveBeenCalledOnce(); + expect(consentEventDetails).toEqual([{ telemetry: true }]); + }); + + it("keeps the privacy shortcut readable and focusable", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true })); + render(); + showBanner(); + + const shortcut = screen.getByRole("button", { name: "Configurações de Privacidade" }); + expect(shortcut.className).not.toContain("opacity-30"); + expect(shortcut.className).toContain("focus-visible:ring-2"); }); }); diff --git a/__tests__/page.test.tsx b/__tests__/page.test.tsx index df5b83b..6d242af 100644 --- a/__tests__/page.test.tsx +++ b/__tests__/page.test.tsx @@ -1,21 +1,13 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { renderToString } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import Home from "@/app/page"; -import { safeGAEvent } from "@/lib/analytics"; +import { describe, expect, it, vi } from "vitest"; +import Home, { generateMetadata } from "@/app/page"; vi.mock("@/lib/analytics", () => ({ safeGAEvent: vi.fn(), })); -const themeState: { resolvedTheme: string | undefined; setTheme: () => void } = { - resolvedTheme: undefined, - setTheme: vi.fn(), -}; - vi.mock("next-themes", () => ({ - useTheme: () => themeState, + useTheme: () => ({ resolvedTheme: undefined, setTheme: vi.fn() }), })); vi.mock("@/components/organisms/work-calculator", () => ({ @@ -26,18 +18,17 @@ vi.mock("@/components/organisms/salary-calculator", () => ({ SalaryCalculator: () =>

Painel do custo da hora

, })); -vi.mock("next/navigation", () => ({ - useSearchParams: () => new URLSearchParams(), -})); +function searchParamsOf(view?: string | string[]) { + return Promise.resolve(view === undefined ? {} : { view }); +} -describe("Home", () => { - beforeEach(() => { - vi.clearAllMocks(); - themeState.resolvedTheme = undefined; - }); +async function renderPage(view?: string | string[]) { + return renderToString(await Home({ searchParams: searchParamsOf(view) })); +} - it("renders the whole shell on the server instead of a blank document", () => { - const markup = renderToString(); +describe("Home", () => { + it("renders the whole shell on the server instead of a blank document", async () => { + const markup = await renderPage(); expect(markup).toContain("WorkLoad"); expect(markup).toContain("Jornada"); @@ -46,56 +37,56 @@ describe("Home", () => { expect(markup).toContain("--:--:--"); }); - it("describes the application for search engines", () => { - const markup = renderToString(); + it("describes the application for search engines", async () => { + const markup = await renderPage(); expect(markup).toContain("application/ld+json"); expect(markup).toContain("WebApplication"); }); - it("shows the live clock once the client takes over", () => { - render(); + it("starts on the journey view", async () => { + const markup = await renderPage(); - expect(screen.getByText(/^\d{2}:\d{2}:\d{2}$/)).toBeInTheDocument(); + expect(markup).toContain("Painel da jornada"); + expect(markup).not.toContain("Painel do custo da hora"); }); - it("reports the session metadata on mount", () => { - render(); + it("serves the salary panel on the very first frame", async () => { + const markup = await renderPage("salary"); - expect(safeGAEvent).toHaveBeenCalledWith( - "session_metadata", - expect.objectContaining({ viewport_width: window.innerWidth }), - ); + expect(markup).toContain("Painel do custo da hora"); + expect(markup).not.toContain("Painel da jornada"); }); +}); - it("starts on the journey view", () => { - render(); +describe("generateMetadata", () => { + it("describes the journey view by default", async () => { + const metadata = await generateMetadata({ searchParams: searchParamsOf() }); - expect(screen.getByText("Painel da jornada")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Jornada" })).toHaveAttribute("aria-current", "page"); + expect(metadata.title).toContain("Jornada"); + expect(metadata.description).toContain("hora extra"); + expect(metadata.alternates?.canonical).toBe("/"); + expect(metadata.openGraph).toMatchObject({ url: "/" }); }); - it("offers the dark theme while the light one is active", async () => { - themeState.resolvedTheme = "light"; - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle("Alternar tema")); + it("describes the salary view", async () => { + const metadata = await generateMetadata({ searchParams: searchParamsOf("salary") }); - expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { - theme: "dark", - }); + expect(metadata.title).toContain("Salário Líquido"); + expect(metadata.alternates?.canonical).toBe("/?view=salary"); }); - it("offers the light theme while the dark one is active", async () => { - themeState.resolvedTheme = "dark"; - const user = userEvent.setup(); - render(); + it("keeps the first value when the view is repeated in the query string", async () => { + const metadata = await generateMetadata({ searchParams: searchParamsOf(["salary", "work"]) }); + + expect(metadata.alternates?.canonical).toBe("/?view=salary"); + }); - await user.click(screen.getByTitle("Alternar tema")); + it("falls back to the journey for an unknown or empty view", async () => { + const unknown = await generateMetadata({ searchParams: searchParamsOf("anything-else") }); + const missing = await generateMetadata({ searchParams: searchParamsOf([]) }); - expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { - theme: "light", - }); + expect(unknown.alternates?.canonical).toBe("/"); + expect(missing.alternates?.canonical).toBe("/"); }); }); diff --git a/__tests__/salary-calculator.test.tsx b/__tests__/salary-calculator.test.tsx index 4a34bc5..a705430 100644 --- a/__tests__/salary-calculator.test.tsx +++ b/__tests__/salary-calculator.test.tsx @@ -16,7 +16,7 @@ describe("SalaryCalculator", () => { render(); expect(screen.getByLabelText("Salário Bruto (R$)")).toHaveValue("0,00"); - expect(screen.getByText(/0,00 por hora/)).toBeInTheDocument(); + expect(screen.getByText(/0,00 por minuto/)).toBeInTheDocument(); }); it("shows the stored salary, the workload and the resulting hourly value", () => { @@ -110,14 +110,29 @@ describe("SalaryCalculator", () => { expect(screen.getByText(/58\.480,37/)).toBeInTheDocument(); }); - it("summarises net salary, total received and total deductions", () => { + it("summarises net salary and total deductions", () => { render(); expect(screen.getByText("Salário Líquido")).toBeInTheDocument(); - expect(screen.getByText("Total Recebido")).toBeInTheDocument(); expect(screen.getByText("Total Descontos")).toBeInTheDocument(); }); + it("keeps the total received hidden while it would only repeat the net salary", () => { + localStorage.setItem("grossSalary", "5000"); + render(); + + expect(screen.queryByText("Total Recebido")).toBeNull(); + }); + + it("shows the total received once there are extra gains to add", () => { + localStorage.setItem("grossSalary", "5000"); + localStorage.setItem("extraGains", JSON.stringify([{ id: "bonus", name: "Bônus", value: 300 }])); + render(); + + expect(screen.getByText("Total Recebido")).toBeInTheDocument(); + expect(screen.getByText("Líquido + Extras")).toBeInTheDocument(); + }); + it("recalculates the hourly value when the salary changes", async () => { const user = userEvent.setup(); render(); @@ -152,7 +167,17 @@ describe("SalaryCalculator", () => { render(); expect(screen.queryByRole("alert")).toBeNull(); - expect(screen.getByText(/20,45 por hora/)).toBeInTheDocument(); + expect(screen.getByText(/0,34 por minuto/)).toBeInTheDocument(); + }); + + it("spells out the hourly rate once the headline shows another period", async () => { + const user = userEvent.setup(); + localStorage.setItem("grossSalary", "5000"); + render(); + + await user.click(screen.getByRole("radio", { name: "Mês" })); + + expect(screen.getByText(/20,45 por hora · R\$ 0,34 por minuto/)).toBeInTheDocument(); }); it("accepts a new workload", async () => { diff --git a/__tests__/side-ads.test.tsx b/__tests__/side-ads.test.tsx index bc885c0..4a20091 100644 --- a/__tests__/side-ads.test.tsx +++ b/__tests__/side-ads.test.tsx @@ -23,13 +23,20 @@ describe("SideAds", () => { expect(screen.getAllByText("Espaço do Apoiador")).toHaveLength(2); }); - it("auto-hides and calls onClose after 45 seconds", () => { + it("keeps the ads on screen until the user closes them", () => { render(); act(() => { - vi.advanceTimersByTime(45000); + vi.advanceTimersByTime(600000); }); - expect(mockOnClose).toHaveBeenCalledOnce(); - expect(screen.queryByText("Espaço do Apoiador")).toBeNull(); + expect(screen.getAllByText("Espaço do Apoiador")).toHaveLength(2); + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it("clears the reveal timer on unmount so it never fires", () => { + const clearTimeoutSpy = vi.spyOn(window, "clearTimeout"); + const { unmount } = render(); + unmount(); + expect(clearTimeoutSpy).toHaveBeenCalled(); }); it("closes the left ad and calls onClose when its close button is clicked", () => { @@ -37,8 +44,7 @@ describe("SideAds", () => { act(() => { vi.advanceTimersByTime(2000); }); - const closeButtons = screen.getAllByRole("button"); - fireEvent.click(closeButtons[0]); + fireEvent.click(screen.getByRole("button", { name: "Fechar anúncio do lado esquerdo" })); expect(mockOnClose).toHaveBeenCalledOnce(); }); @@ -47,8 +53,17 @@ describe("SideAds", () => { act(() => { vi.advanceTimersByTime(2000); }); - const closeButtons = screen.getAllByRole("button"); - fireEvent.click(closeButtons[1]); + fireEvent.click(screen.getByRole("button", { name: "Fechar anúncio do lado direito" })); expect(mockOnClose).toHaveBeenCalledOnce(); }); + + it("reveals the close buttons on keyboard focus", () => { + render(); + act(() => { + vi.advanceTimersByTime(2000); + }); + for (const closeButton of screen.getAllByRole("button")) { + expect(closeButton.className).toContain("focus-visible:opacity-100"); + } + }); }); diff --git a/__tests__/use-hourly-rate.test.ts b/__tests__/use-hourly-rate.test.ts new file mode 100644 index 0000000..6b07528 --- /dev/null +++ b/__tests__/use-hourly-rate.test.ts @@ -0,0 +1,32 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useHourlyRate } from "@/hooks/use-hourly-rate"; +import { HOURLY_RATE_KEY } from "@/lib/storage"; + +describe("useHourlyRate", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("reads the stored hourly rate", () => { + localStorage.setItem(HOURLY_RATE_KEY, "20.45"); + + const { result } = renderHook(() => useHourlyRate()); + + expect(result.current).toBe(20.45); + }); + + it("stays unknown while nothing was stored", () => { + const { result } = renderHook(() => useHourlyRate()); + + expect(result.current).toBeNull(); + }); + + it("treats a zeroed rate as unknown instead of free work", () => { + localStorage.setItem(HOURLY_RATE_KEY, "0"); + + const { result } = renderHook(() => useHourlyRate()); + + expect(result.current).toBeNull(); + }); +}); diff --git a/__tests__/use-salary-calculator.test.ts b/__tests__/use-salary-calculator.test.ts index 3669e3a..950b67a 100644 --- a/__tests__/use-salary-calculator.test.ts +++ b/__tests__/use-salary-calculator.test.ts @@ -1,6 +1,7 @@ import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vitest"; import { useSalaryCalculator } from "@/hooks/use-salary-calculator"; +import { DAILY_MINUTES_KEY, HOURLY_RATE_KEY } from "@/lib/storage"; describe("useSalaryCalculator", () => { beforeEach(() => { @@ -215,10 +216,34 @@ describe("useSalaryCalculator", () => { result.current.setDailyMinutes(450); }); - expect(localStorage.getItem("dailyMinutes")).toBe("450"); expect(result.current.stats.periodValue).toBeCloseTo(result.current.stats.hourlyRate * 7.5, 6); }); + it("shares the daily journey with the journey tab instead of keeping a second copy", () => { + localStorage.setItem(DAILY_MINUTES_KEY, "450"); + + const { result } = renderHook(() => useSalaryCalculator()); + expect(result.current.dailyMinutes).toBe(450); + + act(() => { + result.current.setDailyMinutes(480); + }); + + expect(localStorage.getItem(DAILY_MINUTES_KEY)).toBe("480"); + }); + + it("publishes the hourly value for the journey tab to price overtime with", () => { + const { result } = renderHook(() => useSalaryCalculator(5000)); + + expect(Number(localStorage.getItem(HOURLY_RATE_KEY))).toBeCloseTo(20.4477, 4); + + act(() => { + result.current.setGrossSalary(10000); + }); + + expect(Number(localStorage.getItem(HOURLY_RATE_KEY))).toBeCloseTo(result.current.stats.hourlyRate, 6); + }); + it("has no hourly value while the monthly hours are cleared", () => { const { result } = renderHook(() => useSalaryCalculator()); diff --git a/__tests__/work-calculator.test.tsx b/__tests__/work-calculator.test.tsx index de4e456..343d550 100644 --- a/__tests__/work-calculator.test.tsx +++ b/__tests__/work-calculator.test.tsx @@ -198,12 +198,20 @@ describe("WorkCalculator", () => { }); it("prices the overtime once the salary tab knows the hourly value", () => { - localStorage.setItem("grossSalary", "5000"); + localStorage.setItem("hourlyRate", "20"); vi.setSystemTime(new Date(`${DAY}T19:00:00`)); render(); expect(screen.queryByRole("link", { name: /Calcule o valor da sua hora/ })).toBeNull(); - expect(screen.getByText(/36,81/)).toBeInTheDocument(); + expect(screen.getByText(/36,00/)).toBeInTheDocument(); + }); + + it("ignores a zeroed hourly value instead of pricing the day at nothing", () => { + localStorage.setItem("hourlyRate", "0"); + vi.setSystemTime(new Date(`${DAY}T19:00:00`)); + render(); + + expect(screen.getByRole("link", { name: /Calcule o valor da sua hora/ })).toBeInTheDocument(); }); it("offers the salary tab while the hourly value is unknown", () => { diff --git a/app/page.tsx b/app/page.tsx index f306055..4933fbe 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,31 +1,45 @@ -"use client"; +import type { Metadata } from "next"; +import { AppHeader } from "@/components/organisms/app-header"; +import { CalculatorViews } from "@/components/organisms/calculator-views"; +import { type CalculatorView, toCalculatorView } from "@/lib/calculator-view"; -import { Clock, Moon, Sun, Wallet } from "lucide-react"; -import { useTheme } from "next-themes"; -import { Suspense, useEffect } from "react"; -import { Button } from "@/components/atoms/button"; -import { CalculatorViews, CalculatorViewsFromUrl } from "@/components/organisms/calculator-views"; -import { useCurrentTime } from "@/hooks/use-current-time"; -import { safeGAEvent } from "@/lib/analytics"; -import { formatClockTime } from "@/lib/utils"; +interface HomeProps { + searchParams: Promise<{ view?: string | string[] }>; +} + +const VIEW_METADATA: Record = { + work: { + title: "Calculadora de Jornada, Horas Extras e Banco de Horas", + description: + "Veja a que horas você pode sair, quanto já trabalhou hoje e quanto tem de hora extra, com adicional noturno e os limites da CLT.", + canonical: "/", + }, + salary: { + title: "Calculadora de Valor da Hora e Salário Líquido CLT", + description: + "Descubra quanto vale a sua hora de trabalho a partir do salário bruto, com INSS, IRRF, dependentes, descontos e ganhos extras.", + canonical: "/?view=salary", + }, +}; + +async function readView(searchParams: HomeProps["searchParams"]): Promise { + const { view } = await searchParams; + return toCalculatorView(Array.isArray(view) ? (view[0] ?? null) : (view ?? null)); +} -const PLACEHOLDER_CLOCK = "--:--:--"; +export async function generateMetadata({ searchParams }: HomeProps): Promise { + const { title, description, canonical } = VIEW_METADATA[await readView(searchParams)]; -export default function Home() { - const currentTime = useCurrentTime(); - const { setTheme, resolvedTheme } = useTheme(); + return { + title, + description, + alternates: { canonical }, + openGraph: { title, description, url: canonical }, + }; +} - useEffect(() => { - safeGAEvent("session_metadata", { - screen_width: window.screen.width, - screen_height: window.screen.height, - viewport_width: window.innerWidth, - viewport_height: window.innerHeight, - device_pixel_ratio: window.devicePixelRatio, - user_language: navigator.language, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - }); - }, []); +export default async function Home({ searchParams }: HomeProps) { + const activeView = await readView(searchParams); return ( <> @@ -55,48 +69,9 @@ export default function Home() { Pular para o conteúdo principal
-
-
-
-
-
-

WorkLoad

-
- -
-
-
- -
-
-
+ - }> - - +
diff --git a/components/molecules/ad-block-modal.tsx b/components/molecules/ad-block-modal.tsx deleted file mode 100644 index 2adf4e9..0000000 --- a/components/molecules/ad-block-modal.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { Heart, ShieldAlert, X } from "lucide-react"; -import { Button } from "@/components/atoms/button"; -import { ModalDialog } from "@/components/atoms/modal-dialog"; - -interface AdBlockModalProps { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; -} - -export function AdBlockModal({ isOpen, onClose, onConfirm }: AdBlockModalProps) { - return ( - - - -
-
- - -
-

- Opa! Uma ajudinha? -

-

- Este projeto é gratuito e mantido com carinho. Exibimos apenas{" "} - um único anúncio por semana — o - suficiente para me ajudar a pagar um café e continuar codando! ☕ -

-
- -
-
-
-
-
-

Prometemos não ser chatos

-

- Sua visualização semanal garante que o WorkLoad continue online e evoluindo para todos. -

-
-
-
- -
- - -
-
- - ); -} diff --git a/components/molecules/ad-block-notice.tsx b/components/molecules/ad-block-notice.tsx new file mode 100644 index 0000000..1af1875 --- /dev/null +++ b/components/molecules/ad-block-notice.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Heart } from "lucide-react"; +import { motion } from "motion/react"; +import { Button } from "@/components/atoms/button"; + +interface AdBlockNoticeProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export function AdBlockNotice({ isOpen, onClose, onConfirm }: AdBlockNoticeProps) { + if (!isOpen) return null; + + return ( + +
+
+
+ +
+

+ Opa! Uma ajudinha? +

+

+ Este projeto é gratuito e mantido com carinho. Exibimos apenas{" "} + um único anúncio por semana — o + suficiente para me ajudar a pagar um café e continuar codando! ☕ +

+

+ Prometemos não ser chatos: sua visualização semanal garante que o + WorkLoad continue online e evoluindo para todos. +

+
+ +
+ + +
+
+
+ ); +} diff --git a/components/molecules/cookie-consent.tsx b/components/molecules/cookie-consent.tsx index ce94bde..825fc5f 100644 --- a/components/molecules/cookie-consent.tsx +++ b/components/molecules/cookie-consent.tsx @@ -5,7 +5,7 @@ import { AnimatePresence, motion } from "motion/react"; import { useEffect, useState } from "react"; import { Button } from "@/components/atoms/button"; import { ModalDialog } from "@/components/atoms/modal-dialog"; -import { readTelemetryConsent, writeTelemetryConsent } from "@/lib/consent"; +import { CONSENT_CHANGED_EVENT, readTelemetryConsent, writeTelemetryConsent } from "@/lib/consent"; const BANNER_DELAY_MS = 1500; @@ -29,7 +29,8 @@ export function CookieConsent() { writeTelemetryConsent(telemetry); setTelemetryEnabled(telemetry); setIsVisible(false); - window.location.reload(); + setShowSettings(false); + window.dispatchEvent(new CustomEvent(CONSENT_CHANGED_EVENT, { detail: { telemetry } })); }; return ( @@ -49,7 +50,7 @@ export function CookieConsent() {
-

Respeitamos sua privacidade

+

Respeitamos sua privacidade

Usamos cookies para melhorar sua experiência e entender como você usa o WorkLoad. Você pode optar por desativar a telemetria a qualquer momento. @@ -165,7 +166,7 @@ export function CookieConsent() {

diff --git a/components/molecules/period-selector.tsx b/components/molecules/period-selector.tsx index 452b640..c9e98c5 100644 --- a/components/molecules/period-selector.tsx +++ b/components/molecules/period-selector.tsx @@ -7,12 +7,12 @@ interface PeriodSelectorProps { export function PeriodSelector({ value, onChange }: PeriodSelectorProps) { return ( -
+
Visualizar o valor por período {SALARY_PERIODS.map((period) => (