Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions env/.env.development
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ div::-webkit-scrollbar {
}
}

@layer utilities {
.h-dvh-nav {
height: calc(100dvh - 60px);
}
}

@keyframes fadeInUp {
from {
opacity: 0;
Expand Down
29 changes: 23 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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<any>;
Expand All @@ -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) {
Expand Down Expand Up @@ -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 (
<Suspense>
<Routes>
Expand All @@ -165,12 +182,12 @@ function App() {
</Route>

<Route element={<NoFooterLayout />}>
<Route path="/" element={<Planviewer />} />
<Route path="/sheets/:id" element={<Sheets />} />
<Route path="/chapter" element={<ChaptersV2 />} />
</Route>

<Route element={<MainLayout />}>
<Route path="/" element={<Collections />} />
<Route path="/collections" element={<Collections />} />
<Route
path="/profile"
Expand Down
204 changes: 204 additions & 0 deletions src/components/layout/AppOpenBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import "@testing-library/jest-dom";
import AppOpenBanner from "./AppOpenBanner";
import {
APP_SCHEME_URL,
DISMISS_KEY,
DISMISS_TIME_INTERVAL_MS,
PLAY_STORE_URL,
APP_STORE_URL,
} from "../../utils/constants";

// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
writable: true,
});

// Mock navigator.userAgent
const mockUserAgent = (userAgent: string) => {
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(<AppOpenBanner />);

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(<AppOpenBanner />);

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(<AppOpenBanner />);

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(<AppOpenBanner />);

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(<AppOpenBanner />);

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(<AppOpenBanner />);

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(<AppOpenBanner />);

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(<AppOpenBanner />);

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(<AppOpenBanner />);

await waitFor(() => {
const dismissButton = screen.getByLabelText("Dismiss");
expect(dismissButton).toHaveAttribute("aria-label", "Dismiss");
});
});
});
17 changes: 8 additions & 9 deletions src/components/layout/AppOpenBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -57,16 +63,9 @@ export default function AppOpenBanner() {
</div>

<div className="flex items-center gap-2">
<button
onClick={handleOpenApp}
className="px-3 py-1.5 text-sm rounded-md bg-amber-600 text-white hover:bg-amber-700"
>
Open
</button>

<a
href={PLAY_STORE_URL}
className="px-3 py-1.5 text-sm rounded-md border border-gray-300"
href={isAppleDevice ? APP_STORE_URL : PLAY_STORE_URL}
className="px-3 py-1.5 text-sm rounded-md bg-amber-600 text-white hover:bg-amber-700"
>
Get App
</a>
Expand Down
49 changes: 49 additions & 0 deletions src/routes/commons/expandtext/TestExpand.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,53 @@ describe("TextExpand", () => {
expect(contentDiv?.innerHTML).toBe("<br>");
});
});

it("returns null for non-string children", () => {
const { container } = render(
<TextExpand children={123 as any} maxLength={50} language="en" />,
);
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();
});
});
Loading
Loading