diff --git a/apps/web/src/components/ui/donation-toast.test.tsx b/apps/web/src/components/ui/donation-toast.test.tsx index 0589e21d3..7e8547b27 100644 --- a/apps/web/src/components/ui/donation-toast.test.tsx +++ b/apps/web/src/components/ui/donation-toast.test.tsx @@ -14,9 +14,9 @@ type ToastOptions = { unstyled: boolean; }; -const useCookieMock = vi.hoisted(() => ({ - setDismissed: vi.fn(), +const cookieMock = vi.hoisted(() => ({ value: null as string | null, + set: vi.fn(), })); const toastMock = vi.hoisted(() => ({ @@ -26,8 +26,11 @@ const toastMock = vi.hoisted(() => ({ }, })); -vi.mock("@reactive-resume/ui/hooks/use-cookie", () => ({ - useCookie: vi.fn(() => [useCookieMock.value, useCookieMock.setDismissed, vi.fn()] as const), +vi.mock("js-cookie", () => ({ + default: { + get: vi.fn(() => cookieMock.value ?? undefined), + set: cookieMock.set, + }, })); vi.mock("sonner", () => ({ @@ -51,8 +54,8 @@ describe("DonationToast", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-11T12:00:00.000Z")); i18n.loadAndActivate({ locale: "en-US", messages: {} }); - useCookieMock.value = null; - useCookieMock.setDismissed.mockClear(); + cookieMock.value = null; + cookieMock.set.mockClear(); toastMock.toast.custom.mockClear(); toastMock.toast.dismiss.mockClear(); vi.spyOn(window, "open").mockReturnValue(null); @@ -89,7 +92,7 @@ describe("DonationToast", () => { }); it("does not show the toast after it has been dismissed", () => { - useCookieMock.value = "true"; + cookieMock.value = "true"; render(); @@ -110,7 +113,7 @@ describe("DonationToast", () => { fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); - expect(useCookieMock.setDismissed).toHaveBeenCalledWith("true", { + expect(cookieMock.set).toHaveBeenCalledWith("donation-toast-dismissed", "true", { expires: new Date("2026-06-10T12:05:00.000Z"), }); expect(toastMock.toast.dismiss).toHaveBeenCalledWith("donation-toast"); @@ -126,7 +129,7 @@ describe("DonationToast", () => { fireEvent.click(screen.getByRole("button", { name: "Donate" })); - expect(useCookieMock.setDismissed).toHaveBeenCalledWith("true", { + expect(cookieMock.set).toHaveBeenCalledWith("donation-toast-dismissed", "true", { expires: new Date("2026-06-10T12:05:00.000Z"), }); expect(window.open).toHaveBeenCalledWith( diff --git a/apps/web/src/components/ui/donation-toast.tsx b/apps/web/src/components/ui/donation-toast.tsx index debef1de4..8b5a4a98b 100644 --- a/apps/web/src/components/ui/donation-toast.tsx +++ b/apps/web/src/components/ui/donation-toast.tsx @@ -1,10 +1,10 @@ import { Trans } from "@lingui/react/macro"; import { HandHeartIcon } from "@phosphor-icons/react"; -import { useCallback } from "react"; +import Cookies from "js-cookie"; +import { useCallback, useState } from "react"; import { toast } from "sonner"; import { useTimeout } from "usehooks-ts"; import { Button } from "@reactive-resume/ui/components/button"; -import { useCookie } from "@reactive-resume/ui/hooks/use-cookie"; const TOAST_ID = "donation-toast"; const SHOW_TOAST_DELAY_MS = 5 * 60 * 1000; // 5 minutes @@ -14,7 +14,13 @@ const DISMISSED_COOKIE_EXPIRES_MS = 30 * 24 * 60 * 60 * 1000; // 30 days const getDismissedCookieExpiresAt = () => new Date(Date.now() + DISMISSED_COOKIE_EXPIRES_MS); export function DonationToast() { - const [dismissed, setDismissed] = useCookie(DISMISSED_COOKIE_NAME); + // ponytail: inlined from @reactive-resume/ui/hooks/use-cookie — only consumer, one read + one set-with-expiry + const [dismissed, setDismissedState] = useState(() => Cookies.get(DISMISSED_COOKIE_NAME) ?? null); + + const setDismissed = useCallback((value: string, options?: { expires?: Date }) => { + Cookies.set(DISMISSED_COOKIE_NAME, value, options); + setDismissedState(value); + }, []); const showToast = useCallback(() => { if (dismissed === "true") return; diff --git a/packages/ui/package.json b/packages/ui/package.json index e03ba3d67..35dd2c6e4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,7 +26,6 @@ "@shadcn/react": "^0.2.0", "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", - "js-cookie": "^3.0.8", "next-themes": "^0.4.6", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -39,7 +38,6 @@ "@reactive-resume/config": "workspace:*", "@tailwindcss/postcss": "^4.3.2", "@tailwindcss/typography": "^0.5.20", - "@types/js-cookie": "^3.0.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@typescript/native-preview": "7.0.0-dev.20260704.1", diff --git a/packages/ui/src/hooks/use-cookie.test.tsx b/packages/ui/src/hooks/use-cookie.test.tsx deleted file mode 100644 index aa8f0a608..000000000 --- a/packages/ui/src/hooks/use-cookie.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_COOKIE_ATTRIBUTES, useCookie } from "./use-cookie"; - -type CookieAttributes = { - expires?: Date | number; - path?: string; - sameSite?: string; - secure?: boolean; -}; - -const cookieMock = vi.hoisted(() => { - const values = new Map(); - - return { - get: vi.fn((name: string) => values.get(name)), - remove: vi.fn((name: string) => { - values.delete(name); - }), - reset: () => values.clear(), - set: vi.fn((name: string, value: string, _attributes?: CookieAttributes) => { - values.set(name, value); - }), - values, - }; -}); - -vi.mock("js-cookie", () => ({ - default: { - get: cookieMock.get, - remove: cookieMock.remove, - set: cookieMock.set, - }, -})); - -describe("useCookie", () => { - beforeEach(() => { - cookieMock.reset(); - cookieMock.get.mockClear(); - cookieMock.set.mockClear(); - cookieMock.remove.mockClear(); - }); - - it("reads an existing cookie value", () => { - cookieMock.values.set("theme", "dark"); - - const { result } = renderHook(() => useCookie("theme", "light")); - - expect(result.current[0]).toBe("dark"); - expect(cookieMock.set).not.toHaveBeenCalled(); - }); - - it("sets the default value with secure defaults when the cookie is missing", () => { - const { result } = renderHook(() => useCookie("theme", "dark")); - - expect(result.current[0]).toBe("dark"); - expect(cookieMock.set).toHaveBeenCalledWith("theme", "dark", DEFAULT_COOKIE_ATTRIBUTES); - }); - - it("does not write a cookie when no default value is provided", () => { - const { result } = renderHook(() => useCookie("theme")); - - expect(result.current[0]).toBeNull(); - expect(cookieMock.set).not.toHaveBeenCalled(); - }); - - it("updates the cookie value and state with secure defaults", () => { - const { result } = renderHook(() => useCookie("theme")); - - act(() => { - result.current[1]("dark"); - }); - - expect(result.current[0]).toBe("dark"); - expect(cookieMock.set).toHaveBeenCalledWith("theme", "dark", DEFAULT_COOKIE_ATTRIBUTES); - }); - - it("passes through native expires cookie attributes", () => { - const { result } = renderHook(() => useCookie("session")); - const expires = new Date("2026-05-11T12:01:00.000Z"); - - act(() => { - result.current[1]("active", { expires }); - }); - - const attributes = cookieMock.set.mock.calls.at(-1)?.[2] as CookieAttributes; - expect(attributes).toMatchObject({ path: "/", sameSite: "lax", secure: true }); - expect(attributes.expires).toBe(expires); - }); - - it("does not expose a custom expiresMs option", () => { - const { result } = renderHook(() => useCookie("session")); - - act(() => { - // @ts-expect-error use the native js-cookie expires attribute instead - result.current[1]("active", { expiresMs: 60_000 }); - }); - - const attributes = cookieMock.set.mock.calls.at(-1)?.[2] as CookieAttributes & { expiresMs?: number }; - expect(attributes).not.toHaveProperty("expiresMs"); - }); - - it("allows explicit cookie attributes to override defaults", () => { - const { result } = renderHook(() => useCookie("theme")); - - act(() => { - result.current[1]("dark", { sameSite: "strict", secure: false }); - }); - - expect(cookieMock.set).toHaveBeenCalledWith("theme", "dark", { - ...DEFAULT_COOKIE_ATTRIBUTES, - sameSite: "strict", - secure: false, - }); - }); - - it("removes the cookie with secure defaults and clears state", () => { - cookieMock.values.set("theme", "dark"); - const { result } = renderHook(() => useCookie("theme")); - - act(() => { - result.current[2](); - }); - - expect(result.current[0]).toBeNull(); - expect(cookieMock.remove).toHaveBeenCalledWith("theme", DEFAULT_COOKIE_ATTRIBUTES); - }); -}); diff --git a/packages/ui/src/hooks/use-cookie.tsx b/packages/ui/src/hooks/use-cookie.tsx deleted file mode 100644 index 1c3442db9..000000000 --- a/packages/ui/src/hooks/use-cookie.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import Cookie from "js-cookie"; -import { useCallback, useEffect, useMemo, useState } from "react"; - -type CookieAttributes = NonNullable[2]>; - -type SharedCookieOptions = { - domain?: CookieAttributes["domain"]; - partitioned?: boolean; - path?: CookieAttributes["path"]; - sameSite?: CookieAttributes["sameSite"]; - secure?: CookieAttributes["secure"]; -}; - -type UseCookieOptions = SharedCookieOptions & { - expires?: CookieAttributes["expires"]; -}; -type UseCookieRemoveOptions = SharedCookieOptions; - -export const DEFAULT_COOKIE_ATTRIBUTES = { - path: "/", - secure: true, - sameSite: "lax", -} as const satisfies UseCookieRemoveOptions; - -const canUseCookies = () => typeof document !== "undefined"; - -const getCookie = (name: string) => { - if (!canUseCookies()) return null; - return Cookie.get(name) ?? null; -}; - -const compactCookieOptions = ({ - domain, - expires, - partitioned, - path, - sameSite, - secure, -}: UseCookieOptions): CookieAttributes => ({ - ...(domain !== undefined && { domain }), - ...(expires !== undefined && { expires }), - ...(partitioned !== undefined && { partitioned }), - ...(path !== undefined && { path }), - ...(sameSite !== undefined && { sameSite }), - ...(secure !== undefined && { secure }), -}); - -const resolveCookieOptions = (options?: UseCookieOptions): CookieAttributes => { - return { - ...DEFAULT_COOKIE_ATTRIBUTES, - ...(options && compactCookieOptions(options)), - }; -}; - -const resolveRemoveOptions = (options?: UseCookieRemoveOptions): CookieAttributes => { - return { - ...DEFAULT_COOKIE_ATTRIBUTES, - ...(options && compactCookieOptions(options)), - }; -}; - -export function useCookie( - name: string, - defaultValue?: string, - defaultOptions?: UseCookieOptions, -): readonly [ - string | null, - (value: string, options?: UseCookieOptions) => void, - (options?: UseCookieRemoveOptions) => void, -] { - const initialValue = useMemo(() => getCookie(name) ?? defaultValue ?? null, [name, defaultValue]); - const [value, setValue] = useState(initialValue); - - useEffect(() => { - const cookie = getCookie(name); - let nextValue: string | null; - - if (cookie !== null) { - nextValue = cookie; - } else if (defaultValue === undefined) { - nextValue = null; - } else { - if (canUseCookies()) Cookie.set(name, defaultValue, resolveCookieOptions(defaultOptions)); - nextValue = defaultValue; - } - - setValue(nextValue); - }, [name, defaultValue, defaultOptions]); - - const updateCookie = useCallback( - (nextValue: string, options?: UseCookieOptions) => { - if (canUseCookies()) Cookie.set(name, nextValue, resolveCookieOptions(options)); - setValue(nextValue); - }, - [name], - ); - - const deleteCookie = useCallback( - (options?: UseCookieRemoveOptions) => { - if (canUseCookies()) Cookie.remove(name, resolveRemoveOptions(options)); - setValue(null); - }, - [name], - ); - - return [value, updateCookie, deleteCookie] as const; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d53d4cc2e..c311db387 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,9 +63,6 @@ importers: lefthook: specifier: ^2.1.9 version: 2.1.9 - npm-check-updates: - specifier: ^22.2.9 - version: 22.2.9 pg: specifier: ^8.22.0 version: 8.22.0 @@ -1028,9 +1025,6 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - js-cookie: - specifier: ^3.0.8 - version: 3.0.8 next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1062,9 +1056,6 @@ importers: '@tailwindcss/typography': specifier: ^0.5.20 version: 0.5.20(tailwindcss@4.3.2) - '@types/js-cookie': - specifier: ^3.0.6 - version: 3.0.6 '@types/react': specifier: ^19.2.17 version: 19.2.17 @@ -6242,11 +6233,6 @@ packages: normalize-wheel@1.0.1: resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} - npm-check-updates@22.2.9: - resolution: {integrity: sha512-DVeZ0KirHfliSsHuR2o7cHE+tW439sVHfJjF6cGWeDiY0Wyl3BI/jS4zV0eixtcMOquFbcF1Su/FsxOvk5MoYA==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10.0.0'} - hasBin: true - npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -12792,8 +12778,6 @@ snapshots: normalize-wheel@1.0.1: {} - npm-check-updates@22.2.9: {} - npm-run-path@4.0.1: dependencies: path-key: 3.1.1