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
77 changes: 72 additions & 5 deletions src/routes/planviewer/Planviewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "react-query";
import * as reactQuery from "react-query";
import { mockReactQuery } from "../../test-utils/CommonMocks.ts";
import { LANGUAGE } from "../../utils/constants.ts";

mockReactQuery();

Expand Down Expand Up @@ -290,19 +289,87 @@ describe("Planviewer", () => {
).toBeInTheDocument();
});

test("navigates from series card to today's plan", async () => {
test("navigates from series card Start button to today's plan", async () => {
renderPlanviewer();
const user = userEvent.setup();

await screen.findByText("200-Day Road to the ITCC 2026");
await user.click(
screen.getByRole("button", { name: "200-Day Road to the ITCC 2026" }),
);
await user.click(screen.getByRole("button", { name: /^Start$/i }));

expect(await screen.findByText("Morning reading")).toBeInTheDocument();
expect(screen.queryByText("Enroll")).not.toBeInTheDocument();
});

test("navigates from series card to chapter list", async () => {
renderPlanviewer();
const user = userEvent.setup();

await screen.findByText("200-Day Road to the ITCC 2026");
await user.click(screen.getByRole("button", { name: /View chapters/i }));

expect(await screen.findByText("ITCC: Days 1-6")).toBeInTheDocument();
expect(await screen.findByText("Enroll")).toBeInTheDocument();
});

test("renders the series detail list when view=list", async () => {
renderPlanviewer("/?series=series-1&view=list");

expect(
await screen.findByRole("button", { name: /Enroll/i }),
).toBeInTheDocument();
expect(await screen.findByText("ITCC: Days 1-6")).toBeInTheDocument();
expect(await screen.findByText("ITCC: Days 7-37")).toBeInTheDocument();
});

test("selecting a plan from the detail list opens its daily content", async () => {
renderPlanviewer("/?series=series-1&view=list");
const user = userEvent.setup();

const planRow = await screen.findByText("ITCC: Days 1-6");
await user.click(planRow);

expect(await screen.findByText("Morning reading")).toBeInTheDocument();
});

test("back from a daily plan returns to the series detail list", async () => {
renderPlanviewer("/?series=series-1&plan=plan-1");
const user = userEvent.setup();

expect(await screen.findByText("Morning reading")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Back to series/i }));

expect(
await screen.findByRole("button", { name: /Enroll/i }),
).toBeInTheDocument();
});

test("changing the day updates the daily content", async () => {
renderPlanviewer("/?series=series-1&plan=plan-1");
const user = userEvent.setup();

expect(await screen.findByText("Morning reading")).toBeInTheDocument();

const dayButton = await screen.findByRole("button", {
name: /^Day 2,/i,
});
await user.click(dayButton);

expect(await screen.findByText("Morning reading")).toBeInTheDocument();
});

test("back from the detail list keeps the non-English language param", async () => {
getLanguageMock.mockReturnValue("bo-IN");
renderPlanviewer("/?series=series-1&view=list&lang=bo");
const user = userEvent.setup();

await screen.findByRole("button", { name: /Enroll/i });
await user.click(screen.getByRole("button", { name: /All routines/i }));

expect(
await screen.findByText("200-Day Road to the ITCC 2026"),
).toBeInTheDocument();
});

test("maps tolgee language codes for backend requests", async () => {
getLanguageMock.mockReturnValue("bo-IN");

Expand Down
49 changes: 48 additions & 1 deletion src/routes/planviewer/Planviewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useAuth } from "../../config/AuthContext.tsx";
import { LANGUAGE } from "../../utils/constants.ts";
import { mapLanguageCode } from "../../utils/helperFunctions.tsx";
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 { apiLanguageParam, tolgeeToPlanLanguage } from "./utils/seriesUtils.ts";
Expand All @@ -29,6 +30,7 @@ const Planviewer = () => {
const selectedSeriesId = searchParams.get("series");
const selectedPlanId = searchParams.get("plan");
const selectedDate = searchParams.get("date");
const seriesView = searchParams.get("view");
const isAuthenticatedReady =
!isAuthLoading &&
!isAuth0Loading &&
Expand All @@ -42,13 +44,40 @@ const Planviewer = () => {
[apiLanguage, setSearchParams],
);

const handleViewSeriesPlans = useCallback(
(seriesId: string) => {
setSearchParams({ series: seriesId, view: "list", lang: apiLanguage });
},
[apiLanguage, setSearchParams],
);

const handleSelectPlan = useCallback(
(planId: string) => {
if (!selectedSeriesId) return;
setSearchParams({
series: selectedSeriesId,
plan: planId,
lang: apiLanguage,
});
},
[apiLanguage, selectedSeriesId, setSearchParams],
);

const handleBackToList = useCallback(() => {
setSearchParams(apiLanguage !== "en" ? { lang: apiLanguage } : {});
}, [apiLanguage, setSearchParams]);

const handleBackToSeries = useCallback(() => {
if (selectedSeriesId) {
setSearchParams({
series: selectedSeriesId,
view: "list",
lang: apiLanguage,
});
return;
}
setSearchParams(apiLanguage !== "en" ? { lang: apiLanguage } : {});
}, [apiLanguage, setSearchParams]);
}, [apiLanguage, selectedSeriesId, setSearchParams]);

const handleDateChange = useCallback(
(date: string | null) => {
Expand Down Expand Up @@ -78,6 +107,19 @@ const Planviewer = () => {
);
}

if (selectedSeriesId && seriesView === "list") {
return (
<SeriesDetailView
seriesId={selectedSeriesId}
language={planLanguage}
apiLanguage={tolgeeApiLanguage}
isAuthenticated={isAuthenticatedReady}
onBack={handleBackToList}
onSelectPlan={handleSelectPlan}
/>
);
}

if (selectedSeriesId) {
return (
<SeriesPlanRedirect
Expand All @@ -96,19 +138,24 @@ const Planviewer = () => {
language={planLanguage}
isAuthenticated={isAuthenticatedReady}
onSelectSeries={handleSelectSeries}
onViewSeriesPlans={handleViewSeriesPlans}
/>
);
}, [
selectedSeriesId,
selectedPlanId,
selectedDate,
seriesView,
planLanguage,
tolgeeApiLanguage,
apiLanguage,
isAuthenticatedReady,
handleBackToList,
handleBackToSeries,
handleDateChange,
handleSelectSeries,
handleViewSeriesPlans,
handleSelectPlan,
]);

return <div className="min-h-[calc(100dvh-4rem)] w-full">{content}</div>;
Expand Down
119 changes: 71 additions & 48 deletions src/routes/planviewer/components/SeriesCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ type SeriesCardProps = {
language: PlanLanguageCode;
enrollment?: UserSeriesEnrollmentDTO;
onSelect: (seriesId: string) => void;
onViewPlans: (seriesId: string) => void;
};

const SeriesCard = ({
series,
language,
enrollment,
onSelect,
onViewPlans,
}: SeriesCardProps) => {
const { t } = useTranslate();
const title = getSeriesTitleForLanguage(series.metadata, language);
Expand All @@ -29,62 +31,83 @@ const SeriesCard = ({
);
const imageUrl = resolveImageUrl(series.image);
const contentFontClass = getLanguageClass(language);
const isEnrolled = Boolean(enrollment);

return (
<button
type="button"
onClick={() => onSelect(series.id)}
className="group flex w-full flex-col overflow-hidden rounded-2xl border border-amber-100 bg-white text-left shadow-sm transition hover:border-amber-300 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
aria-label={title}
>
<div className="relative aspect-[16/9] w-full overflow-hidden bg-amber-50">
{imageUrl ? (
<img
src={imageUrl}
alt=""
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
/>
) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-amber-100 to-amber-50 text-amber-700/40">
<span className="text-4xl font-serif">☸</span>
</div>
)}
</div>
<div className="flex flex-1 flex-col gap-2 p-4">
<h2
className={`text-lg font-semibold text-gray-900 ${contentFontClass}`}
>
{title}
</h2>
{description && (
<p
className={`line-clamp-2 text-sm text-gray-600 pt-2 ${contentFontClass}`}
<div className="group flex w-full flex-col overflow-hidden rounded-2xl border border-amber-100 bg-white text-left shadow-sm transition hover:border-amber-300 hover:shadow-md focus-within:ring-2 focus-within:ring-amber-500">
<button
type="button"
onClick={() => onViewPlans(series.id)}
className="flex flex-1 flex-col text-left focus:outline-none"
aria-label={title}
>
<div className="relative aspect-[16/9] w-full overflow-hidden bg-amber-50">
{imageUrl ? (
<img
src={imageUrl}
alt=""
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
/>
) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-amber-100 to-amber-50 text-amber-700/40">
<span className="text-4xl font-serif">☸</span>
</div>
)}
</div>
<div className="flex flex-1 flex-col gap-2 p-4">
<h2
className={`text-lg font-semibold text-gray-900 ${contentFontClass}`}
>
{description}
</p>
)}
<div className="mt-auto flex flex-wrap gap-3 text-xs text-gray-500">
<span>
{series.plan_count}{" "}
{t(
"plans.plan_count_label",
series.plan_count === 1 ? "plan" : "plans",
)}
</span>
{series.total_days > 0 && (
<span>
{series.total_days} {t("plans.days_label", "days")}
</span>
{title}
</h2>
{description && (
<p
className={`line-clamp-2 text-sm text-gray-600 pt-2 ${contentFontClass}`}
>
{description}
</p>
)}
{series.enrolled_count > 0 && (
<div className="mt-auto flex flex-wrap gap-3 text-xs text-gray-500">
<span>
{series.enrolled_count.toLocaleString()}{" "}
{t("plans.enrolled_label", "enrolled")}
{series.plan_count}{" "}
{t(
"plans.plan_count_label",
series.plan_count === 1 ? "plan" : "plans",
)}
</span>
)}
{series.total_days > 0 && (
<span>
{series.total_days} {t("plans.days_label", "days")}
</span>
)}
{series.enrolled_count > 0 && (
<span>
{series.enrolled_count.toLocaleString()}{" "}
{t("plans.enrolled_label", "enrolled")}
</span>
)}
</div>
</div>
</button>
<div className="flex gap-2 px-4 pb-4">
<button
type="button"
onClick={() => onSelect(series.id)}
className="flex-1 rounded-full bg-stone-900 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
>
{isEnrolled
? t("plans.continue", "Continue")
: t("plans.start", "Start")}
</button>
<button
type="button"
onClick={() => onViewPlans(series.id)}
className="flex-1 rounded-full border border-stone-300 py-2.5 text-sm font-semibold text-stone-800 transition hover:bg-stone-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
>
{t("plans.view_chapters", "View chapters")}
</button>
</div>
</button>
</div>
);
};

Expand Down
3 changes: 3 additions & 0 deletions src/routes/planviewer/components/SeriesListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ type SeriesListViewProps = {
language: PlanLanguageCode;
isAuthenticated: boolean;
onSelectSeries: (seriesId: string) => void;
onViewSeriesPlans: (seriesId: string) => void;
};

const SeriesListView = ({
apiLanguage,
language,
isAuthenticated,
onSelectSeries,
onViewSeriesPlans,
}: SeriesListViewProps) => {
const { t } = useTranslate();

Expand Down Expand Up @@ -102,6 +104,7 @@ const SeriesListView = ({
language={language}
enrollment={enrollmentBySeriesId.get(series.id)}
onSelect={onSelectSeries}
onViewPlans={onViewSeriesPlans}
/>
))}
</div>
Expand Down
Loading
Loading