diff --git a/env/.env.development b/env/.env.development index f67635d6..ff9ff95f 100644 --- a/env/.env.development +++ b/env/.env.development @@ -1,3 +1,4 @@ VITE_BACKEND_BASE_URL=https://webuddhist-dev-backend.onrender.com VITE_DEFAULT_LANGUAGE=en VITE_TOKEN_EXPIRY_TIME_SEC=60000 +VITE_WEBUDDHIST_PLAN_VIEWER_URL=https://plans.webuddhist.com diff --git a/src/App.css b/src/App.css index cbf139bc..e94a381b 100644 --- a/src/App.css +++ b/src/App.css @@ -172,6 +172,12 @@ div::-webkit-scrollbar { } } +@layer utilities { + .h-dvh-nav { + height: calc(100dvh - 60px); + } +} + @keyframes fadeInUp { from { opacity: 0; diff --git a/src/App.tsx b/src/App.tsx index b47c6fc2..63a8fb08 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import "./App.css"; -import { Route, Routes, useNavigate, Navigate } from "react-router-dom"; -import { useMutation } from "react-query"; +import { Route, Routes, useNavigate, useSearchParams } from "react-router-dom"; +import { useMutation, useQueryClient } from "react-query"; import { AuthenticationGuard } from "./config/AuthenticationGuard.tsx"; import { useEffect, useState, Suspense, lazy } from "react"; import axiosInstance from "./config/axios-config.ts"; @@ -20,6 +20,8 @@ import SheetChapters from "./routes/chapterV2/SheetChapters.tsx"; import { MainLayout } from "./layouts/MainLayout"; import { AuthLayout } from "./layouts/AuthLayout"; import { NoFooterLayout } from "./layouts/NoFooterLayout"; +import { useTolgee } from "@tolgee/react"; +import { changeLanguage } from "./routes/navbar/NavigationBar.tsx"; const tokenRefreshIntervalMs = Number(import.meta.env.VITE_TOKEN_EXPIRY_TIME_SEC) || 0; @@ -49,9 +51,7 @@ const InitialChat = lazy( () => import("./routes/chat/components/molecules/InitialChat/InitialChat.tsx"), ); -const PrivacyPolicy = lazy( - () => import("./routes/privacy-policy/PrivacyPolicy.tsx"), -); +const Planviewer = lazy(() => import("./routes/planviewer/Planviewer.tsx")); type Auth0UserType = { getIdTokenClaims: () => Promise; @@ -72,6 +72,10 @@ function App() { > | null>(null); const { getIdTokenClaims, isAuthenticated, logout }: Auth0UserType = useAuth0() as Auth0UserType; + const [searchParams] = useSearchParams(); + const tolgee = useTolgee(["language"]); + const queryClient = useQueryClient(); + const [hasInitializedLanguage, setHasInitializedLanguage] = useState(false); useEffect(() => { if (isAuthenticated) { @@ -147,6 +151,19 @@ function App() { setFontVariables(localStorage.getItem(LANGUAGE) || "en"); }, []); + // Handle language parameter on first load + useEffect(() => { + const langParam = searchParams.get("lang"); + if (langParam && !hasInitializedLanguage) { + // Validate the language parameter against supported languages + const supportedLanguages = ["en", "bo-IN", "zh-Hans-CN"]; + if (supportedLanguages.includes(langParam)) { + changeLanguage(langParam, queryClient, tolgee); + setHasInitializedLanguage(true); + } + } + }, [searchParams, hasInitializedLanguage, queryClient, tolgee]); + return ( @@ -165,12 +182,12 @@ function App() { }> + } /> } /> } /> }> - } /> } /> { + Object.defineProperty(navigator, "userAgent", { + value: userAgent, + writable: true, + }); +}; + +// Mock window.location +const mockLocation = (pathname = "/", search = "", hash = "") => { + delete (window as any).location; + (window as any).location = { + pathname, + search, + hash, + href: `https://webuddhist.com${pathname}${search}${hash}`, + }; +}; + +describe("AppOpenBanner Component", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.getItem.mockReturnValue(null); + mockLocation(); + }); + + test("does not render on desktop devices", () => { + mockUserAgent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + ); + + render(); + + expect( + screen.queryByText("Open in WebBuddhist App"), + ).not.toBeInTheDocument(); + }); + + test("renders on Android devices", async () => { + mockUserAgent( + "Mozilla/5.0 (Android 12; Mobile; rv:68.0) Gecko/68.0 Firefox/68.0", + ); + + render(); + + await waitFor(() => { + expect(screen.getByText("Open in WebBuddhist App")).toBeInTheDocument(); + expect( + screen.getByText("Faster reading, offline access"), + ).toBeInTheDocument(); + expect(screen.getByText("Get App")).toHaveAttribute( + "href", + PLAY_STORE_URL, + ); + }); + }); + + test("renders on iOS devices with App Store link", async () => { + mockUserAgent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15", + ); + + render(); + + await waitFor(() => { + expect(screen.getByText("Open in WebBuddhist App")).toBeInTheDocument(); + expect(screen.getByText("Get App")).toHaveAttribute( + "href", + APP_STORE_URL, + ); + }); + }); + + test("does not render if recently dismissed", () => { + const recentDismissTime = Date.now() - DISMISS_TIME_INTERVAL_MS / 2; // 12 hours ago + mockUserAgent( + "Mozilla/5.0 (Android 12; Mobile; rv:68.0) Gecko/68.0 Firefox/68.0", + ); + localStorageMock.getItem.mockReturnValue(recentDismissTime.toString()); + + render(); + + expect( + screen.queryByText("Open in WebBuddhist App"), + ).not.toBeInTheDocument(); + }); + + test("renders if dismiss time has expired", async () => { + const oldDismissTime = Date.now() - (DISMISS_TIME_INTERVAL_MS + 1000); // More than 24 hours ago + mockUserAgent( + "Mozilla/5.0 (Android 12; Mobile; rv:68.0) Gecko/68.0 Firefox/68.0", + ); + localStorageMock.getItem.mockReturnValue(oldDismissTime.toString()); + + render(); + + await waitFor(() => { + expect(screen.getByText("Open in WebBuddhist App")).toBeInTheDocument(); + }); + }); + + test("dismisses banner when close button is clicked", async () => { + mockUserAgent( + "Mozilla/5.0 (Android 12; Mobile; rv:68.0) Gecko/68.0 Firefox/68.0", + ); + + render(); + + await waitFor(() => { + expect(screen.getByText("Open in WebBuddhist App")).toBeInTheDocument(); + }); + + const dismissButton = screen.getByLabelText("Dismiss"); + fireEvent.click(dismissButton); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + DISMISS_KEY, + expect.any(String), + ); + expect( + screen.queryByText("Open in WebBuddhist App"), + ).not.toBeInTheDocument(); + }); + + test("handles app opening with current URL", async () => { + mockLocation("/texts/123", "?lang=en", "#section1"); + mockUserAgent( + "Mozilla/5.0 (Android 12; Mobile; rv:68.0) Gecko/68.0 Firefox/68.0", + ); + + // Mock window.location.href setter + let currentHref = "https://webuddhist.com/texts/123?lang=en#section1"; + Object.defineProperty(window.location, "href", { + get: () => currentHref, + set: (value) => { + currentHref = value; + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Open in WebBuddhist App")).toBeInTheDocument(); + }); + + // Note: We can't easily test the setTimeout behavior in this test environment, + // but we can verify the banner renders and the function exists + expect( + screen.getByText("Faster reading, offline access"), + ).toBeInTheDocument(); + }); + + test("detects iPad as Apple device", async () => { + mockUserAgent( + "Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15", + ); + + render(); + + await waitFor(() => { + expect(screen.getByText("Get App")).toHaveAttribute( + "href", + APP_STORE_URL, + ); + }); + }); + + test("has correct accessibility attributes", async () => { + mockUserAgent( + "Mozilla/5.0 (Android 12; Mobile; rv:68.0) Gecko/68.0 Firefox/68.0", + ); + + render(); + + await waitFor(() => { + const dismissButton = screen.getByLabelText("Dismiss"); + expect(dismissButton).toHaveAttribute("aria-label", "Dismiss"); + }); + }); +}); diff --git a/src/components/layout/AppOpenBanner.tsx b/src/components/layout/AppOpenBanner.tsx index abbfe118..c3b814ec 100644 --- a/src/components/layout/AppOpenBanner.tsx +++ b/src/components/layout/AppOpenBanner.tsx @@ -3,17 +3,23 @@ import { DISMISS_KEY, DISMISS_TIME_INTERVAL_MS, PLAY_STORE_URL, + APP_STORE_URL, } from "@/utils/constants"; import { useEffect, useState } from "react"; export default function AppOpenBanner() { const [visible, setVisible] = useState(false); + const [isAppleDevice, setIsAppleDevice] = useState(false); useEffect(() => { // Mobile only const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); if (!isMobile) return; + // Detect Apple devices + const isApple = /iPhone|iPad|iPod/i.test(navigator.userAgent); + setIsAppleDevice(isApple); + const dismissedAt = localStorage.getItem(DISMISS_KEY); if (dismissedAt) { @@ -57,16 +63,9 @@ export default function AppOpenBanner() {
- - Get App diff --git a/src/routes/commons/expandtext/TestExpand.test.tsx b/src/routes/commons/expandtext/TestExpand.test.tsx index 22f4a462..4781256c 100644 --- a/src/routes/commons/expandtext/TestExpand.test.tsx +++ b/src/routes/commons/expandtext/TestExpand.test.tsx @@ -133,4 +133,53 @@ describe("TextExpand", () => { expect(contentDiv?.innerHTML).toBe("
"); }); }); + + it("returns null for non-string children", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("uses default maxLength when maxLength is not provided", () => { + const longText = "A".repeat(300); // Longer than DEFAULT_MAX_LENGTH (250) + setup({ + children: longText, + maxLength: 0, // Should use DEFAULT_MAX_LENGTH + language: "en", + }); + expect(screen.getByText("panel.showmore")).toBeInTheDocument(); + }); + + it("applies correct language class", () => { + const { container } = setup({ + children: "Test content", + maxLength: 50, + language: "bo", + }); + const contentDiv = container.querySelector("div"); + expect(contentDiv).toHaveClass("bo-text"); + }); + + it("handles transformation with null content gracefully", () => { + // Test the transformLineBreaks function edge case + const { container } = setup({ + children: "", + maxLength: 50, + language: "en", + }); + expect(container.firstChild).toBeNull(); + }); + + it("shows exact maxLength characters when truncated", () => { + const text = "12345678901234567890"; // 20 characters + const { container } = setup({ + children: text, + maxLength: 10, + language: "en", + }); + const contentDiv = container.querySelector("div"); + expect(contentDiv?.innerHTML).toBe("1234567890"); + expect(screen.getByText("panel.showmore")).toBeInTheDocument(); + }); }); diff --git a/src/routes/community/CommunityPage.test.tsx b/src/routes/community/CommunityPage.test.tsx index e9c22cd2..a415436f 100644 --- a/src/routes/community/CommunityPage.test.tsx +++ b/src/routes/community/CommunityPage.test.tsx @@ -178,7 +178,12 @@ describe("CommunityPage Component", () => { expect(result).toEqual(mockSheetsData); }); - test("handles Make a Sheet button interaction", () => { + test("handles Make a Sheet button interaction when logged in", () => { + // Mock user as logged in + vi.mock("../../config/AuthContext.tsx", () => ({ + useAuth: () => ({ isLoggedIn: true }), + })); + setup(); const makeSheetButton = screen.getByText( "side_nav.join_conversation.button.make_sheet", @@ -187,4 +192,73 @@ describe("CommunityPage Component", () => { expect(makeSheetButton).not.toBeDisabled(); fireEvent.click(makeSheetButton); }); + + test("handles sort order change", () => { + setup(); + const sortSelect = screen.getByRole("combobox"); + expect(sortSelect).toBeInTheDocument(); + + fireEvent.click(sortSelect); + // The select should be interactive + }); + + test("shows sort dropdown when sheets are available", () => { + setup(); + // Should show sort dropdown because we have sheets data + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + test("hides sort dropdown when no sheets available", () => { + useQuery.mockImplementation(() => ({ + data: { sheets: [] }, + isLoading: false, + })); + + setup(); + // Should not show sort dropdown because no sheets + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + }); + + test("handles pagination correctly", () => { + // Mock localStorage to return pagination data + vi.spyOn(window.localStorage, "getItem").mockReturnValue( + JSON.stringify({ currentPage: 2, limit: 10 }), + ); + + setup(); + // Should render pagination component since we have sheets + expect(screen.getByText("Test Sheet Title")).toBeInTheDocument(); + }); + + test("handles empty community story message", () => { + useQuery.mockImplementation(() => ({ + data: { sheets: [] }, + isLoading: false, + })); + + setup(); + expect(screen.getByText("community_empty_story")).toBeInTheDocument(); + }); + + test("fetches sheet with fallback language when no stored language", async () => { + vi.spyOn(window.localStorage, "getItem").mockReturnValue(null); + axiosInstance.get.mockResolvedValueOnce({ data: mockSheetsData }); + + const result = await fetchsheet(10, 0, "desc"); + + expect(axiosInstance.get).toHaveBeenCalledWith("api/v1/sheets", { + params: { + language: "en", + limit: 10, + skip: 0, + sort_by: "published_date", + sort_order: "desc", + }, + headers: { + Authorization: "Bearer None", + }, + }); + + expect(result).toEqual(mockSheetsData); + }); }); diff --git a/src/routes/forgot-password/ForgotPassword.test.tsx b/src/routes/forgot-password/ForgotPassword.test.tsx index 68f33c3b..2f73fa9c 100644 --- a/src/routes/forgot-password/ForgotPassword.test.tsx +++ b/src/routes/forgot-password/ForgotPassword.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { BrowserRouter as Router } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "react-query"; import { TolgeeProvider } from "@tolgee/react"; @@ -96,4 +96,38 @@ describe("Forgot Password Component", () => { screen.queryByText("user.validation.required"), ).not.toBeInTheDocument(); }); + + it("should show error for invalid email format", async () => { + setup(); + const emailInput = screen.getByRole("textbox"); + fireEvent.change(emailInput, { target: { value: "invalid-email" } }); + + const submitButton = screen.getByRole("button", { + name: "common.button.submit", + }); + fireEvent.click(submitButton); + + expect( + screen.getByText("user.validation.invalid_email"), + ).toBeInTheDocument(); + expect(axiosInstance.post).not.toHaveBeenCalled(); + }); + + it("should call API with correct parameters on successful submission", async () => { + axiosInstance.post.mockResolvedValueOnce({ data: { success: true } }); + + setup(); + const emailInput = screen.getByRole("textbox"); + fireEvent.change(emailInput, { target: { value: "test@gmail.com" } }); + + const submitButton = screen.getByRole("button", { + name: "common.button.submit", + }); + fireEvent.click(submitButton); + + expect(axiosInstance.post).toHaveBeenCalledWith( + "api/v1/auth/request-reset-password", + { email: "test@gmail.com" }, + ); + }); }); diff --git a/src/routes/navbar/NavigationBar.test.tsx b/src/routes/navbar/NavigationBar.test.tsx index 0f70ea7b..e970f8cf 100644 --- a/src/routes/navbar/NavigationBar.test.tsx +++ b/src/routes/navbar/NavigationBar.test.tsx @@ -76,8 +76,7 @@ describe("NavigationBar Component", () => { test("renders navigation links", () => { setup(); expect(screen.getByText("Texts")).toBeInTheDocument(); - // expect(screen.getByText("Topics")).toBeInTheDocument(); - expect(screen.getByText("Community")).toBeInTheDocument(); + // Note: Community link was removed from navigation, only Texts (Collections) link remains }); test("renders search input", () => { diff --git a/src/routes/navbar/NavigationBar.tsx b/src/routes/navbar/NavigationBar.tsx index 2a41e777..af7eb100 100644 --- a/src/routes/navbar/NavigationBar.tsx +++ b/src/routes/navbar/NavigationBar.tsx @@ -1,4 +1,9 @@ -import { Link, useNavigate, useLocation } from "react-router-dom"; +import { + Link, + useNavigate, + useLocation, + useSearchParams, +} from "react-router-dom"; import { FaGlobe, FaSearch } from "react-icons/fa"; import { useAuth } from "../../config/AuthContext.tsx"; import { useAuth0 } from "@auth0/auth0-react"; @@ -70,11 +75,9 @@ const Navigation = () => { const queryClient = useQueryClient(); const { collectionColor } = useCollectionColor(); const [searchTerm, setSearchTerm] = useState(""); - + const [params, setParams] = useSearchParams(); const navItems = [ { to: "/collections", label: t("header.text"), key: "collections" }, - { to: "/note", label: t("header.community"), key: "community" }, - { to: "/ai/new", label: t("header.ai_mode"), key: "ai_mode" }, ]; const currentLanguage = tolgee.getLanguage(); @@ -115,6 +118,10 @@ const Navigation = () => { const handleLangSelect = (lng: string) => { changeLanguage(lng, queryClient, tolgee); + setParams((prev) => { + prev.set("lang", lng); + return prev; + }); }; const renderAuthButtons = (variant: "desktop" | "mobile") => { @@ -197,7 +204,16 @@ const Navigation = () => { }} >
- + { + if (location.pathname === "/") { + e.preventDefault(); + window.location.reload(); + } + }} + > ({ + useTolgee: () => ({ + getLanguage: getLanguageMock, + }), +})); + +import Planviewer from "./Planviewer.tsx"; + +describe("Planviewer", () => { + const localStorageMock = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, "localStorage", { + value: localStorageMock, + writable: true, + }); + }); + + test("renders iframe with plan viewer URL and english lang from tolgee", () => { + getLanguageMock.mockReturnValue("en"); + localStorageMock.getItem.mockReturnValue(null); + + render(); + + const iframe = screen.getByTitle("WeBuddhist plan viewer"); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute( + "src", + "https://plans.webuddhist.com/?lang=en", + ); + expect(iframe).toHaveClass("block", "h-dvh", "w-full", "border-0"); + }); + + test("maps tolgee language codes for plan viewer lang param", () => { + getLanguageMock.mockReturnValue("bo-IN"); + + render(); + + expect(screen.getByTitle("WeBuddhist plan viewer")).toHaveAttribute( + "src", + "https://plans.webuddhist.com/?lang=bo", + ); + }); + + test("uses localStorage language when tolgee has no language", () => { + getLanguageMock.mockReturnValue(undefined); + localStorageMock.getItem.mockImplementation((key: string) => + key === LANGUAGE ? "zh-Hans-CN" : null, + ); + + render(); + + expect(screen.getByTitle("WeBuddhist plan viewer")).toHaveAttribute( + "src", + "https://plans.webuddhist.com/?lang=zh", + ); + }); + + test("defaults to en when no language is stored", () => { + getLanguageMock.mockReturnValue(null); + localStorageMock.getItem.mockReturnValue(null); + + render(); + + expect(screen.getByTitle("WeBuddhist plan viewer")).toHaveAttribute( + "src", + "https://plans.webuddhist.com/?lang=en", + ); + }); +}); diff --git a/src/routes/planviewer/Planviewer.tsx b/src/routes/planviewer/Planviewer.tsx new file mode 100644 index 00000000..9f8b8bb2 --- /dev/null +++ b/src/routes/planviewer/Planviewer.tsx @@ -0,0 +1,25 @@ +import { useTolgee } from "@tolgee/react"; +import { LANGUAGE } from "../../utils/constants.ts"; +import { mapLanguageCode } from "../../utils/helperFunctions.tsx"; + +const PLAN_VIEWER_URL = "https://plans.webuddhist.com"; + +const Planviewer = () => { + const tolgee = useTolgee(["language"]); + + const storedLanguage = + tolgee.getLanguage() || localStorage.getItem(LANGUAGE) || "en"; + const language = mapLanguageCode(storedLanguage); + const planViewerSrc = new URL(PLAN_VIEWER_URL); + planViewerSrc.searchParams.set("lang", language); + + return ( +