diff --git a/env/.env.development b/env/.env.development index ff9ff95f..936b4cdb 100644 --- a/env/.env.development +++ b/env/.env.development @@ -1,4 +1,4 @@ -VITE_BACKEND_BASE_URL=https://webuddhist-dev-backend.onrender.com +VITE_BACKEND_BASE_URL=https://api.webuddhist.com VITE_DEFAULT_LANGUAGE=en VITE_TOKEN_EXPIRY_TIME_SEC=60000 VITE_WEBUDDHIST_PLAN_VIEWER_URL=https://plans.webuddhist.com diff --git a/public/img/icon.svg b/public/img/icon.svg new file mode 100644 index 00000000..ae792bba --- /dev/null +++ b/public/img/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/DownloadAppModal.tsx b/src/components/DownloadAppModal.tsx new file mode 100644 index 00000000..16f574b8 --- /dev/null +++ b/src/components/DownloadAppModal.tsx @@ -0,0 +1,76 @@ +import { useTranslate } from "@tolgee/react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { APP_STORE_URL, PLAY_STORE_URL } from "@/utils/constants"; + +type DownloadAppModalProps = { + open: boolean; + onClose: () => void; + description?: string; +}; + +const DownloadAppModal = ({ + open, + onClose, + description, +}: DownloadAppModalProps) => { + const { t } = useTranslate(); + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) onClose(); + }; + + return ( + + + + + {t( + "plans.download_app_title", + "Get the app for the full experience", + )} + + + {description ?? + t( + "plans.download_app_qr_body", + "Scan the QR code with your phone or download from your app store.", + )} + + + {t("plans.download_app_qr_alt", +
+ + {t("plans.download_app_store", "App Store")} + + + {t("plans.download_app_play", "Google Play")} + +
+
+
+ ); +}; + +export default DownloadAppModal; diff --git a/src/routes/app-share/AppShare.test.tsx b/src/routes/app-share/AppShare.test.tsx index e96e4357..8b24d4c6 100644 --- a/src/routes/app-share/AppShare.test.tsx +++ b/src/routes/app-share/AppShare.test.tsx @@ -1,10 +1,12 @@ import { render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, test, vi } from "vitest"; import "@testing-library/jest-dom"; +import { TolgeeProvider } from "@tolgee/react"; import AppShare from "./AppShare"; -import { APP_STORE_URL, PLAY_STORE_URL, siteName } from "../../utils/constants"; +import { APP_STORE_URL, PLAY_STORE_URL } from "../../utils/constants"; +import { mockTolgee } from "../../test-utils/CommonMocks"; -const mockReplace = vi.fn(); +const mockAssign = vi.fn(); const mockUserAgent = (userAgent: string) => { Object.defineProperty(navigator, "userAgent", { @@ -14,78 +16,58 @@ const mockUserAgent = (userAgent: string) => { }); }; +const renderAppShare = () => + render( + + + , + ); + describe("AppShare", () => { beforeEach(() => { vi.clearAllMocks(); - mockUserAgent( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - ); Object.defineProperty(window, "location", { - value: { replace: mockReplace }, + value: { assign: mockAssign }, writable: true, configurable: true, }); }); - test("shows download links on desktop", async () => { - render(); - - await waitFor(() => { - expect( - screen.getByRole("link", { - name: `Download ${siteName} on the App Store`, - }), - ).toHaveAttribute("href", APP_STORE_URL); - expect( - screen.getByRole("link", { - name: `Download ${siteName} on Google Play`, - }), - ).toHaveAttribute("href", PLAY_STORE_URL); - }); - - expect(mockReplace).not.toHaveBeenCalled(); - expect(screen.getByText("App Store")).toBeInTheDocument(); - expect(screen.getByText("Google Play")).toBeInTheDocument(); - }); - - test("redirects iPhone users to the App Store", async () => { + test("redirects mobile users to the app open page", () => { mockUserAgent( "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15", ); - render(); + renderAppShare(); - expect( - screen.getByText("Redirecting to the app store…"), - ).toBeInTheDocument(); - expect(mockReplace).toHaveBeenCalledWith(APP_STORE_URL); - expect( - screen.queryByRole("link", { - name: `Download ${siteName} on the App Store`, - }), - ).not.toBeInTheDocument(); + expect(mockAssign).toHaveBeenCalledWith("/open"); + expect(screen.getByText("Opening WeBuddhist…")).toBeInTheDocument(); }); - test("redirects Android users to Google Play", async () => { + test("shows download modal with QR on desktop", async () => { mockUserAgent( - "Mozilla/5.0 (Android 12; Mobile; rv:68.0) Gecko/68.0 Firefox/68.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", ); - render(); - - expect( - screen.getByText("Redirecting to the app store…"), - ).toBeInTheDocument(); - expect(mockReplace).toHaveBeenCalledWith(PLAY_STORE_URL); - }); + renderAppShare(); - test("redirects iPad users to the App Store", () => { - mockUserAgent( - "Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15", - ); - - render(); + await waitFor(() => { + expect( + screen.getByText("Get the app for the full experience"), + ).toBeInTheDocument(); + expect( + screen.getByAltText("QR code to download WeBuddhist"), + ).toHaveAttribute("src", "/img/QR-download.png"); + expect(screen.getByRole("link", { name: "App Store" })).toHaveAttribute( + "href", + APP_STORE_URL, + ); + expect(screen.getByRole("link", { name: "Google Play" })).toHaveAttribute( + "href", + PLAY_STORE_URL, + ); + }); - expect(mockReplace).toHaveBeenCalledWith(APP_STORE_URL); + expect(mockAssign).not.toHaveBeenCalled(); }); }); diff --git a/src/routes/app-share/AppShare.tsx b/src/routes/app-share/AppShare.tsx index 1efd624d..d5382300 100644 --- a/src/routes/app-share/AppShare.tsx +++ b/src/routes/app-share/AppShare.tsx @@ -1,76 +1,34 @@ import { useEffect, useState } from "react"; -import { APP_STORE_URL, PLAY_STORE_URL, siteName } from "../../utils/constants"; -import { FaApple, FaAndroid } from "react-icons/fa"; - -type MobileDeviceType = "android" | "apple" | null; - -const getMobileDeviceType = (): MobileDeviceType => { - if (typeof window === "undefined" || !window.navigator?.userAgent) - return null; - - const userAgent = window.navigator.userAgent.toLowerCase(); - if (/iphone|ipad|ipod/.test(userAgent)) return "apple"; - if (/android/.test(userAgent)) return "android"; - return null; -}; - -const STORE_URLS: Record, string> = { - apple: APP_STORE_URL, - android: PLAY_STORE_URL, -}; +import DownloadAppModal from "../../components/DownloadAppModal.tsx"; +import { + isMobileDevice, + openAppDownloadPage, +} from "../../utils/deviceUtils.ts"; const AppShare = () => { - const [showDownloadLinks, setShowDownloadLinks] = useState(false); + const [showModal, setShowModal] = useState(false); + const mobile = isMobileDevice(); useEffect(() => { - const deviceType = getMobileDeviceType(); - if (deviceType) { - window.location.replace(STORE_URLS[deviceType]); + if (mobile) { + openAppDownloadPage(); return; } - setShowDownloadLinks(true); - }, []); + setShowModal(true); + }, [mobile]); - if (!showDownloadLinks) { + if (mobile) { return ( -
-
- - - - -

- Redirecting to the app store… -

-
+
+

+ Opening WeBuddhist… +

); } return ( - + setShowModal(false)} /> ); }; diff --git a/src/routes/mantras/api/accumulatorApi.ts b/src/routes/mantras/api/accumulatorApi.ts new file mode 100644 index 00000000..2969db55 --- /dev/null +++ b/src/routes/mantras/api/accumulatorApi.ts @@ -0,0 +1,94 @@ +import axiosInstance from "../../../config/axios-config.ts"; +import type { + AuthorGroupMembersListResponse, + GroupAccumulatorDetailDTO, + GroupAccumulatorMembersResponse, + GroupAccumulatorsResponse, + PaginatedMembersResult, + PublicAccumulatorsResponse, + PublicAuthorGroupDetailDTO, + PublicAuthorGroupListResponse, + GroupAccumulatorMemberDTO, + MemberProfileDTO, +} from "../types.ts"; + +const MEMBERS_PAGE_SIZE = 20; + +export async function fetchPresetAccumulators( + language: string, + limit = 50, +): Promise { + const { data } = await axiosInstance.get( + "/api/v1/accumulators/presets", + { params: { language, limit, skip: 0 } }, + ); + return data; +} + +export async function fetchPublicGroups( + language: string, + limit = 50, +): Promise { + const { data } = await axiosInstance.get( + "/api/v1/author/groups", + { params: { language, limit, skip: 0, group_type: "COMMUNITY" } }, + ); + return data; +} + +export async function fetchPublicGroupDetail( + groupId: string, + language: string, +): Promise { + const { data } = await axiosInstance.get( + `/api/v1/author/groups/${groupId}`, + { params: { language } }, + ); + return data; +} + +export async function fetchGroupAccumulators( + groupId: string, + limit = 50, +): Promise { + const { data } = await axiosInstance.get( + `/api/v1/group-accumulators/${groupId}/accumulators`, + { params: { limit, skip: 0 } }, + ); + return data; +} + +export async function fetchGroupAccumulatorDetail( + groupAccumulatorId: string, +): Promise { + const { data } = await axiosInstance.get( + `/api/v1/group-accumulators/${groupAccumulatorId}`, + ); + return data; +} + +export async function fetchGroupMembersPage( + groupId: string, + skip: number, + limit = MEMBERS_PAGE_SIZE, +): Promise> { + const { data } = await axiosInstance.get( + `/api/v1/author/groups/${groupId}/members`, + { params: { skip, limit } }, + ); + return { items: data.list, total: data.total_members }; +} + +export async function fetchGroupAccumulatorMembersPage( + groupAccumulatorId: string, + skip: number, + limit = MEMBERS_PAGE_SIZE, +): Promise> { + const { data } = await axiosInstance.get( + `/api/v1/group-accumulators/${groupAccumulatorId}/members`, + { params: { skip, limit, sort_by: "total" } }, + ); + return { items: data.members, total: data.total }; +} + +export { MEMBERS_PAGE_SIZE }; diff --git a/src/routes/mantras/components/GroupAccumulatorDetailView.tsx b/src/routes/mantras/components/GroupAccumulatorDetailView.tsx new file mode 100644 index 00000000..ee3526f9 --- /dev/null +++ b/src/routes/mantras/components/GroupAccumulatorDetailView.tsx @@ -0,0 +1,138 @@ +import { useQuery } from "react-query"; +import { useTranslate } from "@tolgee/react"; +import { IoArrowBack } from "react-icons/io5"; +import { + fetchGroupAccumulatorDetail, + fetchGroupAccumulatorMembersPage, +} from "../api/accumulatorApi.ts"; +import { useInfiniteMembers } from "../hooks/useInfiniteMembers.ts"; +import { InfiniteMemberList } from "./InfiniteMemberList.tsx"; +import { getEarlyReturn } from "../../../utils/helperFunctions.tsx"; +import { resolveImageUrl } from "../../planviewer/utils/seriesUtils.ts"; + +type GroupAccumulatorDetailViewProps = { + groupAccumulatorId: string; + onBack: () => void; +}; + +const GroupAccumulatorDetailView = ({ + groupAccumulatorId, + onBack, +}: GroupAccumulatorDetailViewProps) => { + const { t } = useTranslate(); + + const { + data: accumulator, + isLoading: isAccumulatorLoading, + error: accumulatorError, + } = useQuery( + ["group-accumulator-detail", groupAccumulatorId], + () => fetchGroupAccumulatorDetail(groupAccumulatorId), + { refetchOnWindowFocus: false }, + ); + + const { + members, + total: membersTotal, + isLoading: isMembersLoading, + isFetchingNextPage, + sentinelRef, + } = useInfiniteMembers({ + queryKey: ["group-accumulator-members", groupAccumulatorId], + fetchPage: (skip, limit) => + fetchGroupAccumulatorMembersPage(groupAccumulatorId, skip, limit), + enabled: Boolean(groupAccumulatorId), + }); + + const earlyReturn = getEarlyReturn({ + isLoading: isAccumulatorLoading, + error: accumulatorError, + t, + }); + if (earlyReturn) return earlyReturn; + if (!accumulator) return null; + + const imageUrl = resolveImageUrl(accumulator.image); + const title = + accumulator.title?.trim() || + t("mantras.untitled_practice", "Group practice"); + + return ( +
+ + +
+
+
+ {imageUrl ? ( + + ) : ( +
+ ☸ +
+ )} +
+
+

{title}

+

+ {accumulator.member_count.toLocaleString()}{" "} + {t("mantras.members_label", "members")} +

+
+ + {t("mantras.total_count", "Total")}:{" "} + {accumulator.total_count.toLocaleString()} + + {accumulator.target_count ? ( + + {t("mantras.target_count", "Target")}:{" "} + {accumulator.target_count.toLocaleString()} + + ) : null} +
+
+
+
+ +
+

+ {t("mantras.practice_members", "Practice Members")} +

+ ({ + fullname: member.fullname, + username: member.username, + avatarUrl: member.avatar_url, + subtitle: member.total_count + ? t("mantras.member_count_subtitle", "{count} recitations", { + count: member.total_count.toLocaleString(), + }) + : null, + }))} + total={membersTotal} + isLoading={isMembersLoading} + isFetchingNextPage={isFetchingNextPage} + sentinelRef={sentinelRef} + emptyMessage={t( + "mantras.no_practice_members_yet", + "No one has joined this practice yet.", + )} + /> +
+
+ ); +}; + +export default GroupAccumulatorDetailView; diff --git a/src/routes/mantras/components/GroupDetailView.tsx b/src/routes/mantras/components/GroupDetailView.tsx new file mode 100644 index 00000000..334d3337 --- /dev/null +++ b/src/routes/mantras/components/GroupDetailView.tsx @@ -0,0 +1,211 @@ +import { useQuery } from "react-query"; +import { useTranslate } from "@tolgee/react"; +import { IoArrowBack } from "react-icons/io5"; +import { + fetchGroupAccumulators, + fetchGroupMembersPage, + fetchPublicGroupDetail, +} from "../api/accumulatorApi.ts"; +import { useInfiniteMembers } from "../hooks/useInfiniteMembers.ts"; +import { InfiniteMemberList } from "./InfiniteMemberList.tsx"; +import { + getGroupDescriptionForLanguage, + getGroupTitleForLanguage, +} from "../utils/groupUtils.ts"; +import { + getEarlyReturn, + getLanguageClass, +} from "../../../utils/helperFunctions.tsx"; +import { resolveImageUrl } from "../../planviewer/utils/seriesUtils.ts"; +import type { PlanLanguageCode } from "../../planviewer/utils/seriesUtils.ts"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { getMemberInitials } from "../utils/groupUtils.ts"; + +type GroupDetailViewProps = { + groupId: string; + apiLanguage: string; + language: PlanLanguageCode; + onBack: () => void; + onSelectAccumulator: (accumulatorId: string) => void; +}; + +const GroupDetailView = ({ + groupId, + apiLanguage, + language, + onBack, + onSelectAccumulator, +}: GroupDetailViewProps) => { + const { t } = useTranslate(); + + const { + data: group, + isLoading: isGroupLoading, + error: groupError, + } = useQuery( + ["public-group-detail", groupId, apiLanguage], + () => fetchPublicGroupDetail(groupId, apiLanguage), + { refetchOnWindowFocus: false }, + ); + + const { + data: accumulatorsData, + isLoading: isAccumulatorsLoading, + error: accumulatorsError, + } = useQuery( + ["group-accumulators", groupId], + () => fetchGroupAccumulators(groupId), + { refetchOnWindowFocus: false }, + ); + + const { + members, + total: membersTotal, + isLoading: isMembersLoading, + isFetchingNextPage, + sentinelRef, + } = useInfiniteMembers({ + queryKey: ["group-members", groupId], + fetchPage: (skip, limit) => fetchGroupMembersPage(groupId, skip, limit), + enabled: Boolean(groupId), + }); + + const isLoading = isGroupLoading || isAccumulatorsLoading; + const error = groupError || accumulatorsError; + const earlyReturn = getEarlyReturn({ isLoading, error, t }); + if (earlyReturn) return earlyReturn; + if (!group) return null; + + const title = getGroupTitleForLanguage(group.metadata, language); + const description = getGroupDescriptionForLanguage(group.metadata, language); + const contentFontClass = getLanguageClass( + language === "BO" ? "bo-IN" : language === "ZH" ? "zh-Hans-CN" : "en", + ); + const bannerUrl = group.banner_url?.trim() || ""; + const accumulators = accumulatorsData?.accumulators ?? []; + + return ( +
+ + +
+ {bannerUrl ? ( +
+ +
+ ) : null} +
+ + {group.avatar_url ? ( + + ) : null} + + {getMemberInitials(title)} + + +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +

+ {group.joiner_count.toLocaleString()}{" "} + {t("mantras.members_label", "members")} +

+
+
+
+ + {accumulators.length > 0 && ( +
+

+ {t("mantras.group_practices", "Group Practices")} +

+
+ {accumulators.map((accumulator) => { + const imageUrl = resolveImageUrl(accumulator.image); + const practiceTitle = + accumulator.title?.trim() || + t("mantras.untitled_practice", "Group practice"); + + return ( + + ); + })} +
+
+ )} + +
+

+ {t("mantras.group_members", "Members")} +

+ ({ + fullname: member.fullname, + username: member.username, + avatarUrl: member.avatar_url, + }))} + total={membersTotal} + isLoading={isMembersLoading} + isFetchingNextPage={isFetchingNextPage} + sentinelRef={sentinelRef} + emptyMessage={t( + "mantras.no_members_yet", + "No members have joined this group yet.", + )} + /> +
+
+ ); +}; + +export default GroupDetailView; diff --git a/src/routes/mantras/components/InfiniteMemberList.tsx b/src/routes/mantras/components/InfiniteMemberList.tsx new file mode 100644 index 00000000..2dd19fa6 --- /dev/null +++ b/src/routes/mantras/components/InfiniteMemberList.tsx @@ -0,0 +1,103 @@ +import { useTranslate } from "@tolgee/react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Skeleton } from "@/components/ui/skeleton"; +import { getMemberInitials } from "../utils/groupUtils.ts"; + +type MemberRowProps = { + fullname: string; + username?: string | null; + avatarUrl?: string | null; + subtitle?: string | null; +}; + +const MemberRow = ({ + fullname, + username, + avatarUrl, + subtitle, +}: MemberRowProps) => { + return ( +
  • + + {avatarUrl ? : null} + + {getMemberInitials(fullname)} + + +
    +

    {fullname}

    + {(username || subtitle) && ( +

    + {subtitle ?? `@${username}`} +

    + )} +
    +
  • + ); +}; + +type InfiniteMemberListProps = { + members: MemberRowProps[]; + total: number; + isLoading: boolean; + isFetchingNextPage: boolean; + sentinelRef: (node?: Element | null) => void; + emptyMessage: string; +}; + +export const InfiniteMemberList = ({ + members, + total, + isLoading, + isFetchingNextPage, + sentinelRef, + emptyMessage, +}: InfiniteMemberListProps) => { + const { t } = useTranslate(); + + if (isLoading) { + return ( +
    + {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
    + ); + } + + if (!members.length) { + return ( +
    + {emptyMessage} +
    + ); + } + + return ( +
    +

    + {t("mantras.showing_members", "Showing {count} of {total} members", { + count: members.length, + total, + })} +

    +
      + {members.map((member, index) => ( + + ))} +
    + + ); +}; + +export default MemberRow; diff --git a/src/routes/mantras/components/JoinableGroupCard.tsx b/src/routes/mantras/components/JoinableGroupCard.tsx new file mode 100644 index 00000000..874af123 --- /dev/null +++ b/src/routes/mantras/components/JoinableGroupCard.tsx @@ -0,0 +1,86 @@ +import { useTranslate } from "@tolgee/react"; +import type { AuthorGroupSummaryDTO } from "../types.ts"; +import { + getGroupDescriptionForLanguage, + getGroupTitleForLanguage, +} from "../utils/groupUtils.ts"; +import { getLanguageClass } from "../../../utils/helperFunctions.tsx"; +import type { PlanLanguageCode } from "../../planviewer/utils/seriesUtils.ts"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { getMemberInitials } from "../utils/groupUtils.ts"; + +type JoinableGroupCardProps = { + group: AuthorGroupSummaryDTO; + language: PlanLanguageCode; + onOpenApp: () => void; +}; + +const JoinableGroupCard = ({ + group, + language, + onOpenApp, +}: JoinableGroupCardProps) => { + const { t } = useTranslate(); + const title = getGroupTitleForLanguage(group.metadata, language); + const description = getGroupDescriptionForLanguage(group.metadata, language); + const contentFontClass = getLanguageClass( + language === "BO" ? "bo-IN" : language === "ZH" ? "zh-Hans-CN" : "en", + ); + + const handleClick = () => { + onOpenApp(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleClick(); + } + }; + + return ( + + ); +}; + +export default JoinableGroupCard; diff --git a/src/routes/mantras/components/JoinableGroupsSection.tsx b/src/routes/mantras/components/JoinableGroupsSection.tsx new file mode 100644 index 00000000..769a85b0 --- /dev/null +++ b/src/routes/mantras/components/JoinableGroupsSection.tsx @@ -0,0 +1,71 @@ +import { useQuery } from "react-query"; +import { useTranslate } from "@tolgee/react"; +import { fetchPublicGroups } from "../api/accumulatorApi.ts"; +import JoinableGroupCard from "./JoinableGroupCard.tsx"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { PlanLanguageCode } from "../../planviewer/utils/seriesUtils.ts"; + +type JoinableGroupsSectionProps = { + apiLanguage: string; + language: PlanLanguageCode; + onOpenApp: () => void; +}; + +const JoinableGroupsSection = ({ + apiLanguage, + language, + onOpenApp, +}: JoinableGroupsSectionProps) => { + const { t } = useTranslate(); + + const { data, isLoading } = useQuery( + ["public-groups", apiLanguage], + () => fetchPublicGroups(apiLanguage), + { refetchOnWindowFocus: false }, + ); + + const groups = data?.groups ?? []; + + if (isLoading) { + return ( +
    + +
    + {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
    +
    + ); + } + + if (!groups.length) return null; + + return ( +
    +
    +

    + {t("mantras.joinable_groups", "Groups to Join")} +

    +

    + {t( + "mantras.joinable_groups_description", + "Tap a group to download the app and join.", + )} +

    +
    +
    + {groups.map((group) => ( + + ))} +
    +
    + ); +}; + +export default JoinableGroupsSection; diff --git a/src/routes/mantras/components/PresetMantraCard.tsx b/src/routes/mantras/components/PresetMantraCard.tsx new file mode 100644 index 00000000..0fb7f278 --- /dev/null +++ b/src/routes/mantras/components/PresetMantraCard.tsx @@ -0,0 +1,102 @@ +import { useTranslate } from "@tolgee/react"; +import type { PublicAccumulatorDTO } from "../types.ts"; +import { + getPresetDisplayDescription, + getPresetDisplayTitle, +} from "../utils/groupUtils.ts"; +import { getLanguageClass } from "../../../utils/helperFunctions.tsx"; +import type { PlanLanguageCode } from "../../planviewer/utils/seriesUtils.ts"; + +type PresetMantraCardProps = { + preset: PublicAccumulatorDTO; + language: PlanLanguageCode; + onOpenApp: () => void; +}; + +const PresetMantraCard = ({ + preset, + language, + onOpenApp, +}: PresetMantraCardProps) => { + const { t } = useTranslate(); + const title = getPresetDisplayTitle(preset, language); + const description = getPresetDisplayDescription(preset, language); + const mantraText = preset.mantra?.mantra?.trim() ?? ""; + const imageUrl = + preset.mantra?.mala_image_url?.trim() || + preset.mala_image_url?.trim() || + ""; + const contentFontClass = getLanguageClass( + language === "BO" ? "bo-IN" : language === "ZH" ? "zh-Hans-CN" : "en", + ); + + const handleClick = () => { + onOpenApp(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleClick(); + } + }; + + return ( +
    +
    +
    + {imageUrl ? ( + + ) : ( +
    + + ཨོཾ + +
    + )} +
    +
    +
    +

    + {title} +

    + {mantraText && ( +

    + {mantraText} +

    + )} + {description && ( +

    + {description} +

    + )} + {preset.target_count != null && preset.target_count > 0 && ( +

    + {t("mantras.target_count", "Target")}:{" "} + {preset.target_count.toLocaleString()} +

    + )} +
    +
    + ); +}; + +export default PresetMantraCard; diff --git a/src/routes/mantras/components/PresetMantrasSection.tsx b/src/routes/mantras/components/PresetMantrasSection.tsx new file mode 100644 index 00000000..4d74bab8 --- /dev/null +++ b/src/routes/mantras/components/PresetMantrasSection.tsx @@ -0,0 +1,71 @@ +import { useQuery } from "react-query"; +import { useTranslate } from "@tolgee/react"; +import { fetchPresetAccumulators } from "../api/accumulatorApi.ts"; +import PresetMantraCard from "./PresetMantraCard.tsx"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { PlanLanguageCode } from "../../planviewer/utils/seriesUtils.ts"; + +type PresetMantrasSectionProps = { + apiLanguage: string; + language: PlanLanguageCode; + onOpenApp: () => void; +}; + +const PresetMantrasSection = ({ + apiLanguage, + language, + onOpenApp, +}: PresetMantrasSectionProps) => { + const { t } = useTranslate(); + + const { data, isLoading } = useQuery( + ["preset-accumulators", apiLanguage], + () => fetchPresetAccumulators(apiLanguage), + { refetchOnWindowFocus: false }, + ); + + const presets = data?.accumulators ?? []; + + if (isLoading) { + return ( +
    + +
    + {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
    +
    + ); + } + + if (!presets.length) return null; + + return ( +
    +
    +

    + {t("mantras.preset_mantras", "Preset Mantras")} +

    +

    + {t( + "mantras.preset_mantras_description", + "Tap a mantra to download the app and start counting.", + )} +

    +
    +
    + {presets.map((preset) => ( + + ))} +
    +
    + ); +}; + +export default PresetMantrasSection; diff --git a/src/routes/mantras/hooks/useInfiniteMembers.ts b/src/routes/mantras/hooks/useInfiniteMembers.ts new file mode 100644 index 00000000..35c70451 --- /dev/null +++ b/src/routes/mantras/hooks/useInfiniteMembers.ts @@ -0,0 +1,62 @@ +import { useEffect } from "react"; +import { useInfiniteQuery } from "react-query"; +import { useInView } from "react-intersection-observer"; +import type { PaginatedMembersResult } from "../types.ts"; +import { MEMBERS_PAGE_SIZE } from "../api/accumulatorApi.ts"; + +type UseInfiniteMembersOptions = { + queryKey: (string | number)[]; + fetchPage: ( + skip: number, + limit: number, + ) => Promise>; + enabled?: boolean; +}; + +export const useInfiniteMembers = ({ + queryKey, + fetchPage, + enabled = true, +}: UseInfiniteMembersOptions) => { + const membersQuery = useInfiniteQuery( + queryKey, + ({ pageParam = 0 }) => fetchPage(pageParam, MEMBERS_PAGE_SIZE), + { + enabled, + refetchOnWindowFocus: false, + getNextPageParam: (lastPage, allPages) => { + const totalFetched = allPages.reduce( + (sum, page) => sum + page.items.length, + 0, + ); + return totalFetched < lastPage.total ? totalFetched : undefined; + }, + }, + ); + + const members = membersQuery.data?.pages.flatMap((page) => page.items) ?? []; + const total = membersQuery.data?.pages[0]?.total ?? 0; + const { hasNextPage, isFetchingNextPage, fetchNextPage, isLoading, error } = + membersQuery; + + const { ref: sentinelRef, inView: isBottomSentinelVisible } = useInView({ + threshold: 0.1, + rootMargin: "80px", + }); + + useEffect(() => { + if (isBottomSentinelVisible && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [isBottomSentinelVisible, hasNextPage, isFetchingNextPage, fetchNextPage]); + + return { + members, + total, + isLoading, + isFetchingNextPage, + hasNextPage, + error, + sentinelRef, + }; +}; diff --git a/src/routes/mantras/types.ts b/src/routes/mantras/types.ts new file mode 100644 index 00000000..4c43e9a9 --- /dev/null +++ b/src/routes/mantras/types.ts @@ -0,0 +1,129 @@ +import type { ImageUrlModel } from "../planviewer/types.ts"; + +export type AccumulatorMetadataDTO = { + language: string; + name: string; + description?: string | null; +}; + +export type PresetMantraDTO = { + id: string; + mantra: string; + title?: string | null; + pronunciation?: string | null; + audio_url?: string | null; + mala_image_id?: string | null; + mala_image_url?: string | null; +}; + +export type PublicAccumulatorDTO = { + id: string; + group_id?: string | null; + type: string; + target_count?: number | null; + current_count: number; + mantra?: PresetMantraDTO | null; + mala_image_url?: string | null; + metadata: AccumulatorMetadataDTO[]; + created_at: string; + updated_at?: string | null; +}; + +export type PublicAccumulatorsResponse = { + accumulators: PublicAccumulatorDTO[]; + total: number; + skip: number; + limit: number; +}; + +export type GroupMetadataDTO = { + id: string; + title: string; + sub_title?: string | null; + description?: string | null; + description_long?: string | null; + language: string; +}; + +export type AuthorGroupSummaryDTO = { + id: string; + slug: string; + group_type: string; + is_public: boolean; + avatar_url?: string | null; + banner_url?: string | null; + metadata: GroupMetadataDTO[] | GroupMetadataDTO | null; + tags: string[]; + follower_count: number; + joiner_count: number; + member_count: number; +}; + +export type PublicAuthorGroupListResponse = { + groups: AuthorGroupSummaryDTO[]; + skip: number; + limit: number; + total: number; +}; + +export type PublicAuthorGroupDetailDTO = AuthorGroupSummaryDTO & { + banner_key?: string | null; + avatar_key?: string | null; +}; + +export type GroupAccumulatorDTO = { + id: string; + preset_accumulator_id?: string | null; + group_id: string; + title?: string | null; + image?: ImageUrlModel | null; + target_count?: number | null; + member_count: number; + is_joined?: boolean | null; + created_at: string; +}; + +export type GroupAccumulatorsResponse = { + accumulators: GroupAccumulatorDTO[]; + total: number; + skip: number; + limit: number; +}; + +export type GroupAccumulatorDetailDTO = GroupAccumulatorDTO & { + total_count: number; + total_today_count: number; +}; + +export type MemberProfileDTO = { + username?: string | null; + fullname: string; + avatar_url?: string | null; +}; + +export type AuthorGroupMembersListResponse = { + total_members: number; + list: MemberProfileDTO[]; + skip: number; + limit: number; +}; + +export type GroupAccumulatorMemberDTO = MemberProfileDTO & { + user_id: string; + joined_at: string; + total_count: number; + today_count: number; +}; + +export type GroupAccumulatorMembersResponse = { + members: GroupAccumulatorMemberDTO[]; + member_count: number; + total: number; + skip: number; + limit: number; +}; + +export type PaginatedMembersResult = { + items: T[]; + total: number; +}; diff --git a/src/routes/mantras/utils/groupUtils.ts b/src/routes/mantras/utils/groupUtils.ts new file mode 100644 index 00000000..53d558a1 --- /dev/null +++ b/src/routes/mantras/utils/groupUtils.ts @@ -0,0 +1,91 @@ +import type { + AccumulatorMetadataDTO, + GroupMetadataDTO, + PublicAccumulatorDTO, +} from "../types.ts"; +import type { PlanLanguageCode } from "../../planviewer/utils/seriesUtils.ts"; +import { normalizeMetadata, normalizeLang } from "./metadataUtils.ts"; + +export function getGroupTitleForLanguage( + metadata: GroupMetadataDTO[] | GroupMetadataDTO | null | undefined, + language: PlanLanguageCode, + fallback = "Untitled group", +): string { + const rows = normalizeMetadata(metadata); + if (!rows.length) return fallback; + + const preferred = rows.find( + (row) => normalizeLang(String(row.language)) === language, + ); + if (preferred?.title?.trim()) return preferred.title.trim(); + + const english = rows.find( + (row) => normalizeLang(String(row.language)) === "EN", + ); + if (english?.title?.trim()) return english.title.trim(); + + return rows[0]?.title?.trim() || fallback; +} + +export function getGroupDescriptionForLanguage( + metadata: GroupMetadataDTO[] | GroupMetadataDTO | null | undefined, + language: PlanLanguageCode, +): string { + const rows = normalizeMetadata(metadata); + const preferred = rows.find( + (row) => normalizeLang(String(row.language)) === language, + ); + if (preferred?.description?.trim()) return preferred.description.trim(); + + const english = rows.find( + (row) => normalizeLang(String(row.language)) === "EN", + ); + return english?.description?.trim() || rows[0]?.description?.trim() || ""; +} + +export function getPresetDisplayTitle( + preset: PublicAccumulatorDTO, + language: PlanLanguageCode, +): string { + if (preset.mantra?.title?.trim()) return preset.mantra.title.trim(); + + const rows = normalizeMetadata( + preset.metadata as AccumulatorMetadataDTO[] | null, + ); + const preferred = rows.find( + (row) => normalizeLang(String(row.language)) === language, + ); + if (preferred?.name?.trim()) return preferred.name.trim(); + + const english = rows.find( + (row) => normalizeLang(String(row.language)) === "EN", + ); + if (english?.name?.trim()) return english.name.trim(); + + return rows[0]?.name?.trim() || preset.mantra?.mantra?.trim() || "Mantra"; +} + +export function getPresetDisplayDescription( + preset: PublicAccumulatorDTO, + language: PlanLanguageCode, +): string { + const rows = normalizeMetadata( + preset.metadata as AccumulatorMetadataDTO[] | null, + ); + const preferred = rows.find( + (row) => normalizeLang(String(row.language)) === language, + ); + if (preferred?.description?.trim()) return preferred.description.trim(); + + const english = rows.find( + (row) => normalizeLang(String(row.language)) === "EN", + ); + return english?.description?.trim() || rows[0]?.description?.trim() || ""; +} + +export function getMemberInitials(fullname: string): string { + const parts = fullname.trim().split(/\s+/).filter(Boolean); + if (!parts.length) return "?"; + if (parts.length === 1) return parts[0].charAt(0).toUpperCase(); + return `${parts[0].charAt(0)}${parts.at(-1)?.charAt(0) ?? ""}`.toUpperCase(); +} diff --git a/src/routes/mantras/utils/metadataUtils.ts b/src/routes/mantras/utils/metadataUtils.ts new file mode 100644 index 00000000..54279aef --- /dev/null +++ b/src/routes/mantras/utils/metadataUtils.ts @@ -0,0 +1,14 @@ +import type { PlanLanguageCode } from "../../planviewer/utils/seriesUtils.ts"; + +export function normalizeMetadata( + metadata: T[] | T | null | undefined, +): T[] { + if (!metadata) return []; + return Array.isArray(metadata) ? metadata : [metadata]; +} + +export function normalizeLang(raw: string): PlanLanguageCode | null { + const upper = raw.trim().toUpperCase(); + if (upper === "EN" || upper === "BO" || upper === "ZH") return upper; + return null; +} diff --git a/src/routes/planviewer/Planviewer.tsx b/src/routes/planviewer/Planviewer.tsx index 2930393a..425d4d65 100644 --- a/src/routes/planviewer/Planviewer.tsx +++ b/src/routes/planviewer/Planviewer.tsx @@ -9,6 +9,8 @@ import SeriesListView from "./components/SeriesListView.tsx"; import SeriesDetailView from "./components/SeriesDetailView.tsx"; import SeriesPlanRedirect from "./components/SeriesPlanRedirect.tsx"; import DailyPlanView from "./components/DailyPlanView.tsx"; +import GroupDetailView from "../mantras/components/GroupDetailView.tsx"; +import GroupAccumulatorDetailView from "../mantras/components/GroupAccumulatorDetailView.tsx"; import { apiLanguageParam, tolgeeToPlanLanguage } from "./utils/seriesUtils.ts"; const Planviewer = () => { @@ -30,6 +32,8 @@ const Planviewer = () => { const selectedSeriesId = searchParams.get("series"); const selectedPlanId = searchParams.get("plan"); const selectedDate = searchParams.get("date"); + const selectedGroupId = searchParams.get("group"); + const selectedAccumulatorId = searchParams.get("accumulator"); const seriesView = searchParams.get("view"); const isAuthenticatedReady = !isAuthLoading && @@ -44,6 +48,28 @@ const Planviewer = () => { [apiLanguage, setSearchParams], ); + const handleSelectAccumulator = useCallback( + (accumulatorId: string) => { + if (!selectedGroupId) return; + const params: Record = { + group: selectedGroupId, + accumulator: accumulatorId, + lang: apiLanguage, + }; + setSearchParams(params); + }, + [apiLanguage, selectedGroupId, setSearchParams], + ); + + const handleBackToGroup = useCallback(() => { + if (!selectedGroupId) return; + setSearchParams( + apiLanguage !== "en" + ? { group: selectedGroupId, lang: apiLanguage } + : { group: selectedGroupId }, + ); + }, [apiLanguage, selectedGroupId, setSearchParams]); + const handleViewSeriesPlans = useCallback( (seriesId: string) => { setSearchParams({ series: seriesId, view: "list", lang: apiLanguage }); @@ -96,6 +122,27 @@ const Planviewer = () => { ); const content = useMemo(() => { + if (selectedGroupId && selectedAccumulatorId) { + return ( + + ); + } + + if (selectedGroupId) { + return ( + + ); + } + if (selectedSeriesId && selectedPlanId) { return ( { /> ); }, [ + selectedGroupId, + selectedAccumulatorId, selectedSeriesId, selectedPlanId, selectedDate, @@ -153,9 +202,11 @@ const Planviewer = () => { apiLanguage, isAuthenticatedReady, handleBackToList, + handleBackToGroup, handleBackToSeries, handleDateChange, handleSelectSeries, + handleSelectAccumulator, handleViewSeriesPlans, handleSelectPlan, ]); diff --git a/src/routes/planviewer/components/SeriesListView.tsx b/src/routes/planviewer/components/SeriesListView.tsx index 49585eb5..e1bb50bd 100644 --- a/src/routes/planviewer/components/SeriesListView.tsx +++ b/src/routes/planviewer/components/SeriesListView.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useQuery } from "react-query"; import { useTranslate } from "@tolgee/react"; import { @@ -7,9 +7,16 @@ import { } from "../api/plansApi.ts"; import SeriesCard from "./SeriesCard.tsx"; import VerseOfDayCard from "./VerseOfDayCard.tsx"; +import PresetMantrasSection from "../../mantras/components/PresetMantrasSection.tsx"; +import JoinableGroupsSection from "../../mantras/components/JoinableGroupsSection.tsx"; import { getEarlyReturn } from "../../../utils/helperFunctions.tsx"; import { siteDescription, siteName } from "../../../utils/constants.ts"; import Seo from "../../commons/seo/Seo.tsx"; +import DownloadAppModal from "../../../components/DownloadAppModal.tsx"; +import { + isMobileDevice, + openAppDownloadPage, +} from "../../../utils/deviceUtils.ts"; import type { PlanLanguageCode } from "../utils/seriesUtils.ts"; type SeriesListViewProps = { @@ -28,6 +35,15 @@ const SeriesListView = ({ onViewSeriesPlans, }: SeriesListViewProps) => { const { t } = useTranslate(); + const [downloadModalOpen, setDownloadModalOpen] = useState(false); + + const handleOpenApp = useCallback(() => { + if (isMobileDevice()) { + openAppDownloadPage(); + return; + } + setDownloadModalOpen(true); + }, []); const { data: seriesData, @@ -75,9 +91,8 @@ const SeriesListView = ({ description={siteDescription} canonical={`${window.location.origin}/`} /> +
    - -

    {t("plans.practice_routines", "Practice Routines")} @@ -106,7 +121,23 @@ const SeriesListView = ({ ))}

    )} + + + +
    + setDownloadModalOpen(false)} + /> ); }; diff --git a/src/routes/planviewer/components/VerseOfDayCard.tsx b/src/routes/planviewer/components/VerseOfDayCard.tsx index c3ad0f72..aa3adb15 100644 --- a/src/routes/planviewer/components/VerseOfDayCard.tsx +++ b/src/routes/planviewer/components/VerseOfDayCard.tsx @@ -1,7 +1,7 @@ import { useQuery } from "react-query"; import { useTranslate } from "@tolgee/react"; import { fetchVerseOfDayToday } from "../api/plansApi.ts"; -import { getVerseText } from "../utils/seriesUtils.ts"; +import { getVerseAttribution, getVerseText } from "../utils/seriesUtils.ts"; import { getLanguageClass } from "../../../utils/helperFunctions.tsx"; import { useState } from "react"; @@ -36,6 +36,7 @@ const VerseOfDayCard = ({ apiLanguage }: VerseOfDayCardProps) => { const verseText = getVerseText(verse.verses, verse.verse, apiLanguage); if (!verseText) return null; + const verseAttribution = getVerseAttribution(verse.group_info, apiLanguage); const isTibetan = getLanguageClass(apiLanguage) === "bo-text"; const handleCopy = async () => { @@ -50,7 +51,7 @@ const VerseOfDayCard = ({ apiLanguage }: VerseOfDayCardProps) => { return (
    { }} role="button" > - {verse.image_url && ( -
    + {verse.image_url ? ( +
    - +
    +
    +
    + {verseText} +
    + {verseAttribution && ( +
    + - {verseAttribution} +
    + )} +
    +
    - )} -
    -
    -

    - {copied && ( - - {t("plans.copied", "Copied!")} - + ) : ( +

    +
    +

    + {copied && ( + + {t("plans.copied", "Copied!")} + + )} +

    +
    + {verseText} +
    + {verseAttribution && ( +
    + {verseAttribution} +
    )} -

    -
    - " {verseText} " -
    +
    -
    + )}
    ); }; diff --git a/src/routes/planviewer/types.ts b/src/routes/planviewer/types.ts index 2623e9b2..4e34338c 100644 --- a/src/routes/planviewer/types.ts +++ b/src/routes/planviewer/types.ts @@ -115,14 +115,25 @@ export type UserSeriesEnrollmentsResponse = { total: number; }; +export type GroupInfoDTO = { + id: string; + title: string; + sub_title?: string | null; + description?: string | null; + language: string; +}; + export type VerseOfDayPublicDTO = { id: string; verses?: Record | null; verse?: string | null; + attributions?: Record | null; + attribution?: string | null; image_url?: string | null; ref_id?: string | null; ref_type?: string | null; date: string; + group_info?: GroupInfoDTO[] | null; }; export type VerseOfDayPublicResponse = { diff --git a/src/routes/planviewer/utils/seriesUtils.ts b/src/routes/planviewer/utils/seriesUtils.ts index 8494d1f0..4891aac4 100644 --- a/src/routes/planviewer/utils/seriesUtils.ts +++ b/src/routes/planviewer/utils/seriesUtils.ts @@ -179,6 +179,18 @@ export function getVerseText( return first?.trim() ?? ""; } +export function getVerseAttribution( + attributions: Record | null | undefined, + lang: string, +): string { + const attribution = attributions?.find( + (attribution) => + String(attribution.language).toLowerCase() === String(lang).toLowerCase(), + ); + if (!attribution) return ""; + return attribution.sub_title?.trim() ?? ""; +} + export function formatDisplayDate(dateStr: string): string { const date = new Date(`${dateStr}T12:00:00`); return date.toLocaleDateString(undefined, { diff --git a/src/utils/deviceUtils.ts b/src/utils/deviceUtils.ts new file mode 100644 index 00000000..0e742107 --- /dev/null +++ b/src/utils/deviceUtils.ts @@ -0,0 +1,8 @@ +export const isMobileDevice = (): boolean => + /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); + +export const APP_OPEN_PATH = "/open"; + +export const openAppDownloadPage = (): void => { + window.location.assign(APP_OPEN_PATH); +};