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
2 changes: 1 addition & 1 deletion env/.env.development
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions public/img/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
76 changes: 76 additions & 0 deletions src/components/DownloadAppModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="border border-stone-200 bg-[#FAF9F6] sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-stone-900">
{t(
"plans.download_app_title",
"Get the app for the full experience",
)}
</DialogTitle>
<DialogDescription className="text-stone-600">
{description ??
t(
"plans.download_app_qr_body",
"Scan the QR code with your phone or download from your app store.",
)}
</DialogDescription>
</DialogHeader>
<img
src="/img/QR-download.png"
alt={t("plans.download_app_qr_alt", "QR code to download WeBuddhist")}
className="mx-auto w-full max-w-[220px] rounded-2xl"
width={220}
height={220}
/>
<div className="flex flex-col gap-2 sm:flex-row">
<a
href={APP_STORE_URL}
target="_blank"
rel="noopener noreferrer"
className="flex flex-1 items-center justify-center rounded-full bg-stone-900 px-4 py-3 text-sm font-semibold text-white transition hover:bg-stone-800"
>
{t("plans.download_app_store", "App Store")}
</a>
<a
href={PLAY_STORE_URL}
target="_blank"
rel="noopener noreferrer"
className="flex flex-1 items-center justify-center rounded-full border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-900 transition hover:bg-stone-50"
>
{t("plans.download_app_play", "Google Play")}
</a>
</div>
</DialogContent>
</Dialog>
);
};

export default DownloadAppModal;
90 changes: 36 additions & 54 deletions src/routes/app-share/AppShare.test.tsx
Original file line number Diff line number Diff line change
@@ -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", {
Expand All @@ -14,78 +16,58 @@ const mockUserAgent = (userAgent: string) => {
});
};

const renderAppShare = () =>
render(
<TolgeeProvider tolgee={mockTolgee}>
<AppShare />
</TolgeeProvider>,
);

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

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

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(<AppShare />);
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();
});
});
76 changes: 17 additions & 59 deletions src/routes/app-share/AppShare.tsx
Original file line number Diff line number Diff line change
@@ -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<NonNullable<MobileDeviceType>, 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 (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-amber-50 to-gray-200 px-4">
<div className="flex flex-col items-center">
<span className="mb-4 text-3xl animate-bounce text-amber-600">
<FaApple aria-label="Apple logo" className="inline mr-2" />
<FaAndroid aria-label="Android logo" className="inline ml-2" />
</span>
<p className="text-base font-medium text-gray-700">
Redirecting to the app store…
</p>
</div>
<div className="flex min-h-screen items-center justify-center bg-[#faf8f5] px-6">
<p className="text-base font-medium text-gray-700">
Opening WeBuddhist…
</p>
</div>
);
}

return (
<div className="mt-9 flex gap-4 flex-col">
<a
href={APP_STORE_URL}
target="_blank"
rel="noopener noreferrer"
aria-label={`Download ${siteName} on the App Store`}
className="flex flex-1 flex-col items-center justify-center gap-2 rounded-lg bg-black px-5 py-5 text-base font-bold text-white transition hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-amber-500"
>
<FaApple className="text-4xl" aria-hidden="true" />
<span className="text-center text-sm">App Store</span>
</a>

<a
href={PLAY_STORE_URL}
target="_blank"
rel="noopener noreferrer"
aria-label={`Download ${siteName} on Google Play`}
className="flex flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-5 py-5 text-base font-bold text-gray-900 shadow-sm transition hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-green-400"
>
<FaAndroid className="text-4xl text-green-500" aria-hidden="true" />
<span className="text-center text-sm">Google Play</span>
</a>
</div>
<DownloadAppModal open={showModal} onClose={() => setShowModal(false)} />
);
};

Expand Down
94 changes: 94 additions & 0 deletions src/routes/mantras/api/accumulatorApi.ts
Original file line number Diff line number Diff line change
@@ -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<PublicAccumulatorsResponse> {
const { data } = await axiosInstance.get<PublicAccumulatorsResponse>(
"/api/v1/accumulators/presets",
{ params: { language, limit, skip: 0 } },
);
return data;
}

export async function fetchPublicGroups(
language: string,
limit = 50,
): Promise<PublicAuthorGroupListResponse> {
const { data } = await axiosInstance.get<PublicAuthorGroupListResponse>(
"/api/v1/author/groups",
{ params: { language, limit, skip: 0, group_type: "COMMUNITY" } },
);
return data;
}

export async function fetchPublicGroupDetail(
groupId: string,
language: string,
): Promise<PublicAuthorGroupDetailDTO> {
const { data } = await axiosInstance.get<PublicAuthorGroupDetailDTO>(
`/api/v1/author/groups/${groupId}`,
{ params: { language } },
);
return data;
}

export async function fetchGroupAccumulators(
groupId: string,
limit = 50,
): Promise<GroupAccumulatorsResponse> {
const { data } = await axiosInstance.get<GroupAccumulatorsResponse>(
`/api/v1/group-accumulators/${groupId}/accumulators`,
{ params: { limit, skip: 0 } },
);
return data;
}

export async function fetchGroupAccumulatorDetail(
groupAccumulatorId: string,
): Promise<GroupAccumulatorDetailDTO> {
const { data } = await axiosInstance.get<GroupAccumulatorDetailDTO>(
`/api/v1/group-accumulators/${groupAccumulatorId}`,
);
return data;
}

export async function fetchGroupMembersPage(
groupId: string,
skip: number,
limit = MEMBERS_PAGE_SIZE,
): Promise<PaginatedMembersResult<MemberProfileDTO>> {
const { data } = await axiosInstance.get<AuthorGroupMembersListResponse>(
`/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<PaginatedMembersResult<GroupAccumulatorMemberDTO>> {
const { data } = await axiosInstance.get<GroupAccumulatorMembersResponse>(
`/api/v1/group-accumulators/${groupAccumulatorId}/members`,
{ params: { skip, limit, sort_by: "total" } },
);
return { items: data.members, total: data.total };
}

export { MEMBERS_PAGE_SIZE };
Loading
Loading