-
-
-
-
}
- description={tTryouts("products.snbt.description")}
- title={tTryouts("products.snbt.title")}
+
+
+
+
-
+
+
+
-
-
+
+ {tTryouts("products.snbt.title")}
+
+
+
+ {tTryouts("product-page-description")}
+
+
+
+
}
+ description={tTryouts("products.snbt.description")}
+ title={tTryouts("products.snbt.title")}
+ >
+
+
-
+
);
}
diff --git a/apps/www/app/[locale]/(app)/(shared)/try-out/layout.tsx b/apps/www/app/[locale]/(app)/(shared)/try-out/layout.tsx
new file mode 100644
index 000000000..5e61a6629
--- /dev/null
+++ b/apps/www/app/[locale]/(app)/(shared)/try-out/layout.tsx
@@ -0,0 +1,6 @@
+import { TryoutShell } from "@/components/tryout/shell";
+
+/** Renders the shared tryout shell for every route in the tryout subtree. */
+export default function Layout({ children }: LayoutProps<"/[locale]/try-out">) {
+ return
{children};
+}
diff --git a/apps/www/app/[locale]/(app)/(shared)/try-out/loading.tsx b/apps/www/app/[locale]/(app)/(shared)/try-out/loading.tsx
new file mode 100644
index 000000000..877aab12d
--- /dev/null
+++ b/apps/www/app/[locale]/(app)/(shared)/try-out/loading.tsx
@@ -0,0 +1,4 @@
+/** Renders the tryout route fallback inside the shared tryout shell. */
+export default function Loading() {
+ return null;
+}
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/try-out/page.tsx b/apps/www/app/[locale]/(app)/(shared)/try-out/page.tsx
similarity index 86%
rename from apps/www/app/[locale]/(app)/(dynamic)/try-out/page.tsx
rename to apps/www/app/[locale]/(app)/(shared)/try-out/page.tsx
index 0aedeb3f4..0b3fead5a 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/try-out/page.tsx
+++ b/apps/www/app/[locale]/(app)/(shared)/try-out/page.tsx
@@ -3,9 +3,8 @@ import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { hasLocale, useTranslations } from "next-intl";
-import { getTranslations, setRequestLocale } from "next-intl/server";
+import { getTranslations } from "next-intl/server";
import { use } from "react";
-import { AppShell } from "@/components/sidebar/app-shell";
import { TryoutHubPage } from "@/components/tryout/hub-page";
export async function generateMetadata({
@@ -53,15 +52,11 @@ export default function Page(props: PageProps<"/[locale]/try-out">) {
notFound();
}
- setRequestLocale(locale);
-
return (
-
+ <>
-
-
-
-
+
+ >
);
}
diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/articles/page.tsx b/apps/www/app/[locale]/(app)/(static)/(learn)/articles/page.tsx
deleted file mode 100644
index 729d6e72e..000000000
--- a/apps/www/app/[locale]/(app)/(static)/(learn)/articles/page.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { notFound } from "next/navigation";
-import { setRequestLocale } from "next-intl/server";
-import { use } from "react";
-import { getLocaleOrThrow } from "@/lib/i18n/params";
-
-export default function Page(props: PageProps<"/[locale]/articles">) {
- const { params } = props;
- const { locale: rawLocale } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- // Enable static rendering
- setRequestLocale(locale);
-
- // Return 404 for empty articles index page
- // This prevents soft 404s and tells Google this page doesn't exist
- // Source: https://developers.google.com/search/docs/crawling-indexing/soft-404s
- notFound();
-}
diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/analytics.tsx b/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/analytics.tsx
deleted file mode 100644
index b4358931b..000000000
--- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/analytics.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-"use client";
-
-import { useIntersection, useInterval } from "@mantine/hooks";
-import type { ReactNode } from "react";
-import { useEffect, useEffectEvent, useRef } from "react";
-import { useAttempt } from "@/lib/context/use-attempt";
-import { useExercise } from "@/lib/context/use-exercise";
-
-export function QuestionAnalytics({
- exerciseNumber,
- children,
-}: {
- exerciseNumber: number;
- children: ReactNode;
-}) {
- const attempt = useAttempt((state) => state.attempt);
- const isInputLocked = useAttempt((state) => state.isInputLocked);
-
- const ref = useIntersection({ threshold: 0.75 });
- const isActive = ref.entry?.isIntersecting ?? false;
- const timeSpent = useExercise(
- (state) => state.timeSpent[exerciseNumber] ?? 0
- );
- const timeCounterRef = useRef(timeSpent);
-
- const setTimeSpent = useExercise((state) => state.setTimeSpent);
-
- const hasActiveAttempt = attempt?.status === "in-progress" && !isInputLocked;
-
- const handleTick = useEffectEvent(() => {
- if (isActive && hasActiveAttempt) {
- timeCounterRef.current += 1;
- setTimeSpent(exerciseNumber, timeCounterRef.current);
- }
- });
-
- const interval = useInterval(() => {
- handleTick();
- }, 1000);
-
- useEffect(() => {
- if (isActive && hasActiveAttempt) {
- interval.start();
- } else {
- interval.stop();
- }
- }, [isActive, hasActiveAttempt, interval]);
-
- return
{children}
;
-}
diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/article.tsx b/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/article.tsx
deleted file mode 100644
index bb158803f..000000000
--- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/article.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import type { Exercise } from "@repo/contents/_types/exercises/shared";
-import type { Locale } from "next-intl";
-import { ExerciseAnswerAction } from "./actions";
-import { ExerciseAnswer } from "./answer";
-import { ExerciseChoices } from "./choices";
-
-interface Props {
- exercise: Exercise;
- id: string;
- locale: Locale;
- srLabel: string;
-}
-
-export function ExerciseArticle({ exercise, locale, id, srLabel }: Props) {
- return (
-
-
-
- {exercise.question.default}
-
-
-
-
- {exercise.answer.default}
-
-
- );
-}
diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/page.tsx b/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/page.tsx
deleted file mode 100644
index 7460c5347..000000000
--- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/page.tsx
+++ /dev/null
@@ -1,665 +0,0 @@
-import {
- getExerciseByNumber,
- getExercisesContent,
-} from "@repo/contents/_lib/exercises";
-import { parseExercisesCategory } from "@repo/contents/_lib/exercises/category";
-import {
- getCurrentMaterial,
- getMaterialPath,
- getMaterials,
- parseExercisesMaterial,
-} from "@repo/contents/_lib/exercises/material";
-import {
- getExerciseNumberPagination,
- getExercisesPagination,
- getSlugPath,
- hasInvalidTryOutYearSlug,
- isTryOutCollectionSlug,
- isYearlessTryOutCollectionSlug,
- LEGACY_YEARLESS_TRY_OUT_REDIRECT_YEAR,
-} from "@repo/contents/_lib/exercises/slug";
-import { parseExercisesType } from "@repo/contents/_lib/exercises/type";
-import { formatContentDateISO } from "@repo/contents/_shared/date";
-import type { ExercisesCategory } from "@repo/contents/_types/exercises/category";
-import type {
- ExercisesMaterial,
- ExercisesMaterialList,
-} from "@repo/contents/_types/exercises/material";
-import type { Exercise } from "@repo/contents/_types/exercises/shared";
-import type { ExercisesType } from "@repo/contents/_types/exercises/type";
-import type { ParsedHeading } from "@repo/contents/_types/toc";
-import { slugify } from "@repo/design-system/lib/utils";
-import { ArticleJsonLd } from "@repo/seo/json-ld/article";
-import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb";
-import { CollectionPageJsonLd } from "@repo/seo/json-ld/collection-page";
-import { FOUNDER } from "@repo/seo/json-ld/constants";
-import { LearningResourceJsonLd } from "@repo/seo/json-ld/learning-resource";
-import { Effect, Option } from "effect";
-import type { Metadata } from "next";
-import { notFound, permanentRedirect } from "next/navigation";
-import type { Locale } from "next-intl";
-import { getTranslations, setRequestLocale } from "next-intl/server";
-import { use } from "react";
-import { AiSheetOpen } from "@/components/ai/sheet-open";
-import { Comments } from "@/components/comments";
-import { CardMaterial } from "@/components/shared/card-material";
-import { ComingSoon } from "@/components/shared/coming-soon";
-import { ContainerList } from "@/components/shared/container-list";
-import {
- LayoutMaterial,
- LayoutMaterialContent,
- LayoutMaterialFooter,
- LayoutMaterialHeader,
- LayoutMaterialMain,
- LayoutMaterialPagination,
- LayoutMaterialToc,
-} from "@/components/shared/layout-material";
-import { RefContent } from "@/components/shared/ref-content";
-import { getLocaleOrThrow } from "@/lib/i18n/params";
-import { getGithubUrl } from "@/lib/utils/github";
-import { getOgUrl } from "@/lib/utils/metadata";
-import { isNumber } from "@/lib/utils/number";
-import {
- fetchExerciseContext,
- fetchExerciseMetadataContext,
-} from "@/lib/utils/pages/exercises";
-import { generateSEOMetadata } from "@/lib/utils/seo/generator";
-import type { SEOContext } from "@/lib/utils/seo/types";
-import { getStaticParams } from "@/lib/utils/system";
-import { QuestionAnalytics } from "./analytics";
-import { ExerciseArticle } from "./article";
-import { ExerciseAttempt } from "./attempt";
-
-export const revalidate = false;
-
-async function getResolvedParams(
- params: PageProps<"/[locale]/exercises/[category]/[type]/[material]/[...slug]">["params"]
-) {
- const {
- locale: rawLocale,
- category: rawCategory,
- type: rawType,
- material: rawMaterial,
- slug,
- } = await params;
- const locale = getLocaleOrThrow(rawLocale);
- const category = parseExercisesCategory(rawCategory);
- const type = parseExercisesType(rawType);
- const material = parseExercisesMaterial(rawMaterial);
-
- if (!(category && type && material)) {
- notFound();
- }
-
- return { category, locale, material, slug, type };
-}
-
-export async function generateMetadata({
- params,
-}: {
- params: PageProps<"/[locale]/exercises/[category]/[type]/[material]/[...slug]">["params"];
-}): Promise
{
- const { locale, category, type, material, slug } =
- await getResolvedParams(params);
-
- const {
- isSpecificExercise,
- exerciseTitle,
- exerciseCount,
- FilePath,
- currentMaterial,
- currentMaterialItem,
- } = await Effect.runPromise(
- Effect.match(
- fetchExerciseMetadataContext({ locale, category, type, material, slug }),
- {
- onFailure: () => ({
- isSpecificExercise: false,
- exerciseTitle: undefined,
- exerciseCount: 0,
- FilePath: getSlugPath(category, type, material, slug),
- currentMaterial: undefined,
- currentMaterialItem: undefined,
- }),
- onSuccess: (data) => data,
- }
- )
- );
-
- const urlPath = `/${locale}${FilePath}`;
- const image = {
- url: getOgUrl(locale, FilePath),
- width: 1200,
- height: 630,
- };
-
- // Evidence: Use ICU-based SEO generator for type-safe, locale-aware metadata
- // Source: https://developers.google.com/search/docs/appearance/title-link
- // Get exercise type and set names from material data (e.g., "Try Out", "Set 1")
- const group = currentMaterial?.title;
- const set = currentMaterialItem?.title;
-
- // Extract exercise number from slug for specific exercises (e.g., /set-2/1)
- const lastSlug = slug.at(-1);
- const exerciseNumber =
- isSpecificExercise && lastSlug && isNumber(lastSlug)
- ? Number.parseInt(lastSlug, 10)
- : undefined;
-
- const seoContext: SEOContext = {
- type: "exercise",
- category,
- exam: type,
- material,
- group,
- set,
- number: exerciseNumber,
- questionCount: exerciseCount, // Total count from filesystem
- data: {
- title:
- exerciseTitle ?? currentMaterialItem?.title ?? currentMaterial?.title,
- description: undefined,
- subject: material,
- },
- };
-
- const {
- title: finalTitle,
- description,
- keywords,
- } = await generateSEOMetadata(seoContext, locale);
-
- return {
- title: {
- absolute: finalTitle,
- },
- description,
- keywords,
- alternates: {
- canonical: urlPath,
- },
- openGraph: {
- title: finalTitle,
- url: urlPath,
- siteName: "Nakafa",
- locale,
- type: "website",
- images: [image],
- },
- twitter: {
- images: [image],
- },
- };
-}
-
-export function generateStaticParams() {
- return getStaticParams({
- basePath: "exercises",
- paramNames: ["category", "type", "material", "slug"],
- slugParam: "slug",
- isDeep: true,
- }).filter((params) => {
- const slug = params.slug;
- return (
- Array.isArray(slug) &&
- slug.length <= 3 &&
- !isYearlessTryOutCollectionSlug(slug)
- );
- });
-}
-
-export default function Page(
- props: PageProps<"/[locale]/exercises/[category]/[type]/[material]/[...slug]">
-) {
- const { params } = props;
- const {
- locale: rawLocale,
- category: rawCategory,
- type: rawType,
- material: rawMaterial,
- slug,
- } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
- const category = parseExercisesCategory(rawCategory);
- const type = parseExercisesType(rawType);
- const material = parseExercisesMaterial(rawMaterial);
-
- if (!(category && type && material)) {
- notFound();
- }
-
- // Enable static rendering
- setRequestLocale(locale);
-
- if (hasInvalidTryOutYearSlug(slug)) {
- const tryOutSuffixIndex = 1;
- const legacyTryOutSuffix = slug.slice(tryOutSuffixIndex);
-
- // Legacy yearless try-out URLs were already indexed before the year segment
- // migration, so keep forwarding them to their yearful 2026 successor.
- permanentRedirect(
- getSlugPath(category, type, material, [
- "try-out",
- LEGACY_YEARLESS_TRY_OUT_REDIRECT_YEAR,
- ...legacyTryOutSuffix,
- ])
- );
- }
-
- const lastSlug = slug.at(-1);
- // Try-out collection routes like `try-out/2026` end in a number but should
- // still render the collection page, not a single exercise page.
- if (!isTryOutCollectionSlug(slug) && lastSlug && isNumber(lastSlug)) {
- const exerciseNumber = Number.parseInt(lastSlug, 10);
- const baseSlug = slug.slice(0, -1);
- return (
-
- );
- }
-
- return (
-
- );
-}
-
-async function YearGroupContent({
- currentMaterial,
- FilePath,
- locale,
- material,
- materialPath,
- type,
-}: {
- currentMaterial: ExercisesMaterialList[number];
- FilePath: string;
- locale: Locale;
- material: ExercisesMaterial;
- materialPath: string;
- type: ExercisesType;
-}) {
- const t = await getTranslations({ locale, namespace: "Exercises" });
- const headingId = slugify(currentMaterial.title);
-
- return (
- <>
- ({
- "@type": "ListItem",
- "@id": `https://nakafa.com/${locale}${item.href}`,
- position: index + 1,
- name: item.title,
- item: `https://nakafa.com/${locale}${item.href}`,
- }))}
- />
- ({
- url: `https://nakafa.com/${locale}${item.href}`,
- name: item.title,
- }))}
- name={`${t(material)} - ${currentMaterial.title}`}
- url={`https://nakafa.com/${locale}${FilePath}`}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
-
-async function PageContent({
- locale,
- category,
- type,
- material,
- slug,
-}: {
- locale: Locale;
- category: ExercisesCategory;
- type: ExercisesType;
- material: ExercisesMaterial;
- slug: string[];
-}) {
- const t = await getTranslations({ locale, namespace: "Exercises" });
-
- const materialPath = getMaterialPath(category, type, material);
- const FilePath = getSlugPath(category, type, material, slug);
- const materialGroups = await getMaterials(materialPath, locale);
- const { currentMaterial: matchedMaterial, currentMaterialItem: matchedItem } =
- getCurrentMaterial(FilePath, materialGroups);
-
- if (matchedMaterial && !matchedItem) {
- return (
-
- );
- }
-
- const exercises = await Effect.runPromise(
- Effect.match(getExercisesContent({ locale, filePath: FilePath }), {
- onFailure: () => [],
- onSuccess: (data) => data,
- })
- );
-
- if (exercises.length === 0 || !matchedMaterial || !matchedItem) {
- return (
-
-
-
-
-
-
-
- );
- }
-
- const currentMaterial = matchedMaterial;
- const currentMaterialItem = matchedItem;
- const materials = materialGroups;
-
- const pagination = getExercisesPagination(FilePath, materials);
-
- const headings: ParsedHeading[] = exercises.map((exercise) => ({
- label: t("number-count", { count: exercise.number }),
- href: `#${slugify(t("number-count", { count: exercise.number }))}`,
- children: [],
- }));
-
- const description = `${t("exercises")} - ${currentMaterialItem.title} - ${currentMaterial.title}`;
- const educationalLevel = `${t(type)} - ${t(category)}`;
- const publishedAt =
- formatContentDateISO(exercises[0].question.metadata.date) ??
- exercises[0].question.metadata.date;
-
- return (
- <>
- ({
- "@type": "ListItem",
- "@id": `https://nakafa.com/${locale}${FilePath}${heading.href}`,
- position: index + 1,
- name: heading.label,
- item: `https://nakafa.com/${locale}${FilePath}${heading.href}`,
- }))}
- />
-
-
-
-
-
-
-
-
-
- {exercises.map((exercise) => {
- const id = slugify(t("number-count", { count: exercise.number }));
-
- return (
-
-
-
- );
- })}
-
-
-
-
-
-
-
-
-
- >
- );
-}
-
-async function SingleExerciseContent({
- locale,
- category,
- type,
- material,
- slug,
- exerciseNumber,
-}: {
- locale: Locale;
- category: ExercisesCategory;
- type: ExercisesType;
- material: ExercisesMaterial;
- slug: string[];
- exerciseNumber: number;
-}) {
- const t = await getTranslations({ locale, namespace: "Exercises" });
-
- const FilePath = getSlugPath(category, type, material, slug);
- const exerciseFilePath = `${FilePath}/${exerciseNumber}`;
-
- const singleExerciseEffect = Effect.all([
- getExerciseByNumber(locale, FilePath, exerciseNumber),
- fetchExerciseContext({ locale, category, type, material, slug }),
- getExercisesContent({ locale, filePath: FilePath }),
- ]);
-
- const [exerciseOption, exerciseContext, exercises] = await Effect.runPromise(
- Effect.match(singleExerciseEffect, {
- onFailure: () => {
- const emptyResult: [Option.Option, null, null] = [
- Option.none(),
- null,
- null,
- ];
- return emptyResult;
- },
- onSuccess: (data) => data,
- })
- );
-
- if (Option.isNone(exerciseOption) || !exerciseContext) {
- return (
-
-
-
-
-
-
-
- );
- }
-
- const exercise = exerciseOption.value;
-
- const { currentMaterialItem } = exerciseContext;
-
- const totalExercises = exercises?.length ?? 0;
- const pagination = getExerciseNumberPagination(
- FilePath,
- exerciseNumber,
- totalExercises,
- (number) => t("number-count", { count: number })
- );
-
- const id = slugify(t("number-count", { count: exercise.number }));
-
- const description = `${t("exercises")} - ${exercise.question.metadata.title} - ${currentMaterialItem.title}`;
- const educationalLevel = `${t(type)} - ${t(category)}`;
- const publishedAt =
- formatContentDateISO(exercise.question.metadata.date) ??
- exercise.question.metadata.date;
-
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/layout.tsx b/apps/www/app/[locale]/(app)/(static)/(learn)/layout.tsx
deleted file mode 100644
index c6f527a28..000000000
--- a/apps/www/app/[locale]/(app)/(static)/(learn)/layout.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { routing } from "@repo/internationalization/src/routing";
-import { notFound } from "next/navigation";
-import { hasLocale } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
-import { use } from "react";
-import { AppShell } from "@/components/sidebar/app-shell";
-
-/** Renders the static learning subtree inside the default app shell. */
-export default function Layout(props: LayoutProps<"/[locale]">) {
- const { children, params } = props;
- const { locale } = use(params);
-
- if (!hasLocale(routing.locales, locale)) {
- notFound();
- }
-
- setRequestLocale(locale);
-
- return {children};
-}
diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/quran/[surah]/page.tsx b/apps/www/app/[locale]/(app)/(static)/(learn)/quran/[surah]/page.tsx
deleted file mode 100644
index 554306e68..000000000
--- a/apps/www/app/[locale]/(app)/(static)/(learn)/quran/[surah]/page.tsx
+++ /dev/null
@@ -1,308 +0,0 @@
-import { AllahIcon } from "@hugeicons/core-free-icons";
-import { getSurahName } from "@repo/contents/_lib/quran";
-import { cn, slugify } from "@repo/design-system/lib/utils";
-import { BookJsonLd } from "@repo/seo/json-ld/book";
-import { Effect } from "effect";
-import type { Metadata } from "next";
-import { notFound } from "next/navigation";
-import { type Locale, useTranslations } from "next-intl";
-import { getTranslations, setRequestLocale } from "next-intl/server";
-import { use } from "react";
-import { AiSheetOpen } from "@/components/ai/sheet-open";
-import {
- LayoutMaterial,
- LayoutMaterialContent,
- LayoutMaterialFooter,
- LayoutMaterialHeader,
- LayoutMaterialMain,
- LayoutMaterialPagination,
- LayoutMaterialToc,
-} from "@/components/shared/layout-material";
-import { QuranAudio } from "@/components/shared/quran-audio";
-import { QuranInterpretation } from "@/components/shared/quran-interpretation";
-import { QuranText } from "@/components/shared/quran-text";
-import { RefContent } from "@/components/shared/ref-content";
-import { WindowVirtualized } from "@/components/shared/window-virtualized";
-import { getLocaleOrThrow } from "@/lib/i18n/params";
-import {
- fetchSurahContext,
- fetchSurahMetadataContext,
- getQuranPagination,
-} from "@/lib/utils/pages/quran";
-import { generateSEOMetadata } from "@/lib/utils/seo/generator";
-import type { SEOContext } from "@/lib/utils/seo/types";
-
-export const revalidate = false;
-
-export async function generateMetadata({
- params,
-}: {
- params: PageProps<"/[locale]/quran/[surah]">["params"];
-}): Promise {
- const { locale: rawLocale, surah } = await params;
- const locale = getLocaleOrThrow(rawLocale);
-
- const t = await getTranslations({ locale, namespace: "Holy" });
-
- const path = `/${locale}/quran/${surah}`;
-
- const alternates = {
- canonical: path,
- };
- const image = {
- url: "/quran.png",
- width: 1200,
- height: 630,
- };
- const twitter: Metadata["twitter"] = {
- images: [image],
- };
- const openGraph: Metadata["openGraph"] = {
- url: path,
- images: [image],
- type: "book",
- siteName: "Nakafa",
- locale,
- };
-
- const surahNumber = Number(surah);
-
- if (Number.isNaN(surahNumber)) {
- return {
- alternates,
- };
- }
-
- const surahMetadataContext = await Effect.runPromise(
- Effect.match(fetchSurahMetadataContext({ surah: surahNumber }), {
- onFailure: () => null,
- onSuccess: (data) => data,
- })
- );
-
- const surahData = surahMetadataContext?.surahData ?? null;
- if (!surahData) {
- return {
- alternates,
- };
- }
-
- // Evidence: Use ICU-based SEO generator for type-safe, locale-aware metadata
- // Source: https://developers.google.com/search/docs/appearance/title-link
- // Evidence: Arabic name is universal, locale-specific for transliteration/translation
- const seoContext: SEOContext = {
- type: "quran",
- surah: surahData,
- };
-
- const { title, description, keywords } = await generateSEOMetadata(
- seoContext,
- locale
- );
-
- return {
- title: { absolute: title },
- alternates,
- category: t("quran"),
- description,
- keywords,
- twitter,
- openGraph,
- };
-}
-
-export function generateStaticParams() {
- // surah 1-114
- return Array.from({ length: 114 }, (_, i) => ({
- surah: (i + 1).toString(),
- }));
-}
-
-export default function Page(props: PageProps<"/[locale]/quran/[surah]">) {
- const { params } = props;
- const { locale: rawLocale, surah } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- // Enable static rendering
- setRequestLocale(locale);
-
- return ;
-}
-
-function PageContent({ locale, surah }: { locale: Locale; surah: string }) {
- const t = useTranslations("Holy");
-
- const surahNumber = Number(surah);
-
- if (Number.isNaN(surahNumber)) {
- notFound();
- }
-
- const result = use(
- Effect.runPromise(
- Effect.match(fetchSurahContext({ surah: surahNumber }), {
- onFailure: () => ({
- surahData: null,
- prevSurah: null,
- nextSurah: null,
- }),
- onSuccess: (data) => data,
- })
- )
- );
-
- const { surahData, prevSurah, nextSurah } = result;
-
- if (!surahData) {
- notFound();
- }
-
- const translation = surahData.name.translation[locale];
-
- const preBismillah = surahData.preBismillah;
-
- const title = getSurahName({ locale, name: surahData.name });
-
- const headings = surahData.verses.map((verse, index) => ({
- label: t("verse-count", { count: verse.number.inSurah }),
- index,
- href: `/quran/${surah}#${slugify(t("verse-count", { count: verse.number.inSurah }))}`,
- children: [],
- }));
-
- const pagination = getQuranPagination({
- prevSurah,
- nextSurah,
- });
-
- const prevTitle = prevSurah
- ? getSurahName({ locale, name: prevSurah.name })
- : "";
- const nextTitle = nextSurah
- ? getSurahName({ locale, name: nextSurah.name })
- : "";
-
- const paginationWithLocalizedTitles = {
- prev: {
- href: pagination.prev.href,
- title: prevTitle,
- },
- next: {
- href: pagination.next.href,
- title: nextTitle,
- },
- };
-
- return (
- <>
-
-
-
-
-
- {!!preBismillah && (
-
-
{preBismillah.text.arab}
-
- {preBismillah.translation[locale] ??
- preBismillah.translation.en}
-
-
- )}
-
-
- {surahData.verses.map((verse, index) => {
- const transliteration = verse.text.transliteration.en;
- const translate =
- verse.translation[locale] ?? verse.translation.en;
-
- const id = slugify(
- t("verse-count", { count: verse.number.inSurah })
- );
-
- return (
-
-
-
{verse.text.arab}
-
-
- {transliteration}
-
-
{translate}
-
-
- );
- })}
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/ask/[slug]/page.tsx b/apps/www/app/[locale]/(app)/(static)/(site)/ask/[slug]/page.tsx
deleted file mode 100644
index 8ed724295..000000000
--- a/apps/www/app/[locale]/(app)/(static)/(site)/ask/[slug]/page.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import { askSeo } from "@repo/seo/ask";
-import { FAQPageJsonLd } from "@repo/seo/json-ld/faq-page";
-import type { Metadata } from "next";
-import { setRequestLocale } from "next-intl/server";
-import { use } from "react";
-import { AskCta } from "@/components/ask/cta";
-import { AskListItems } from "@/components/ask/results";
-import {
- LayoutMaterial,
- LayoutMaterialContent,
- LayoutMaterialMain,
-} from "@/components/shared/layout-material";
-import { getLocaleOrThrow } from "@/lib/i18n/params";
-import { convertSlugToTitle } from "@/lib/utils/helper";
-
-export const dynamic = "force-static";
-export const revalidate = false;
-
-const askData = askSeo();
-
-export async function generateMetadata({
- params,
-}: {
- params: PageProps<"/[locale]/ask/[slug]">["params"];
-}): Promise {
- const { locale: rawLocale, slug } = await params;
- const locale = getLocaleOrThrow(rawLocale);
-
- const seoData = askData.find((data) => data.slug === slug);
-
- if (!seoData) {
- return {};
- }
-
- const title = seoData.locales[locale].title;
- const description = seoData.locales[locale].description;
-
- return {
- title: {
- absolute: title,
- },
- description,
- alternates: {
- canonical: `/${locale}/ask/${slug}`,
- },
- keywords: title
- .split(" ")
- .concat(description.split(" "))
- .filter((keyword) => keyword.length > 0),
- };
-}
-
-export function generateStaticParams() {
- return askData.map((data) => ({
- slug: data.slug,
- }));
-}
-
-export default function Page(props: PageProps<"/[locale]/ask/[slug]">) {
- const { params } = props;
- const { locale: rawLocale, slug } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- // Enable static rendering
- setRequestLocale(locale);
-
- const seoData = askData.find((data) => data.slug === slug);
-
- const title = seoData?.locales[locale].title ?? convertSlugToTitle(slug);
- const description = seoData?.locales[locale].description ?? "";
-
- return (
- <>
-
-
-
-
-
-
-
- {title}
-
-
- {!!description && (
-
- {description}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
diff --git a/apps/www/app/[locale]/(app)/(static)/layout.tsx b/apps/www/app/[locale]/(app)/(static)/layout.tsx
deleted file mode 100644
index f02a3b66e..000000000
--- a/apps/www/app/[locale]/(app)/(static)/layout.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { routing } from "@repo/internationalization/src/routing";
-import { notFound } from "next/navigation";
-import { hasLocale } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
-import { ConvexAppProviders } from "@/components/providers";
-
-/**
- * Mounts the non-seeded Convex subtree for routes that can stay statically
- * rendered.
- *
- * Static pages still benefit from the ambient user context after hydration, but
- * avoid request-time token reads at the layout boundary.
- *
- * @see https://nextjs.org/docs/app/guides/streaming
- * @see https://labs.convex.dev/better-auth/migrations/migrate-to-0-10#pass-initial-token-to-convexbetterauthprovider
- */
-export default async function Layout(props: LayoutProps<"/[locale]">) {
- const { children, params } = props;
- const { locale } = await params;
-
- if (!hasLocale(routing.locales, locale)) {
- notFound();
- }
-
- setRequestLocale(locale);
-
- return {children};
-}
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/auth/page.tsx b/apps/www/app/[locale]/(app)/auth/page.tsx
similarity index 83%
rename from apps/www/app/[locale]/(app)/(dynamic)/auth/page.tsx
rename to apps/www/app/[locale]/(app)/auth/page.tsx
index 2427b6908..108bb2491 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/auth/page.tsx
+++ b/apps/www/app/[locale]/(app)/auth/page.tsx
@@ -1,25 +1,16 @@
import { Button } from "@repo/design-system/components/ui/button";
import { type Locale, useTranslations } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
-import { use } from "react";
+import { getLocale } from "next-intl/server";
import { Auth } from "@/components/auth";
import { FeaturesDithering } from "@/components/marketing/about/features.client";
import { Theme } from "@/components/marketing/shared/footer-action";
import { BackButton } from "@/components/shared/back-button";
-import { getLocaleOrThrow } from "@/lib/i18n/params";
-export const revalidate = false;
-
-export default function Page(props: PageProps<"/[locale]/auth">) {
- const { params } = props;
- const { locale: rawLocale } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- // Enable static rendering
- setRequestLocale(locale);
+export default async function Page() {
+ const locale = await getLocale();
return (
-
+
diff --git a/apps/www/app/[locale]/(app)/error.tsx b/apps/www/app/[locale]/(app)/error.tsx
index 056120ad0..545c58fd1 100644
--- a/apps/www/app/[locale]/(app)/error.tsx
+++ b/apps/www/app/[locale]/(app)/error.tsx
@@ -22,10 +22,7 @@ export default function ErrorPage({
}, [error]);
return (
-
+
diff --git a/apps/www/app/[locale]/(app)/layout.tsx b/apps/www/app/[locale]/(app)/layout.tsx
index 47905b189..40608855c 100644
--- a/apps/www/app/[locale]/(app)/layout.tsx
+++ b/apps/www/app/[locale]/(app)/layout.tsx
@@ -1,14 +1,24 @@
import { routing } from "@repo/internationalization/src/routing";
import { notFound } from "next/navigation";
import { hasLocale } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
+import { Suspense } from "react";
+import { AppProviders } from "@/components/providers/app";
+import { getToken } from "@/lib/auth/server";
/**
- * Binds the validated locale to the full authenticated app subtree.
+ * Binds the validated locale to the shared authenticated app subtree.
*
- * Keeping this locale setup at the shared `(app)` segment lets nested route
- * groups focus on shell and provider ownership instead of repeating the same
- * request setup in every child layout.
+ * Every route in the `(app)` group relies on `useUser()` through the shared app
+ * shell, so this layout resolves the Better Auth token once and mounts a single
+ * provider tree behind a real Suspense boundary. The fallback stays intentionally
+ * small so we do not duplicate the full app subtree while the request token
+ * resolves.
+ *
+ * References:
+ * - Convex App Router SSR:
+ * https://docs.convex.dev/client/nextjs/app-router/server-rendering
+ * - Next.js Cache Components / mixed static-dynamic routes:
+ * @.agents/skills/next-cache-components/SKILL.md
*/
export default async function Layout(props: LayoutProps<"/[locale]">) {
const { children, params } = props;
@@ -18,7 +28,20 @@ export default async function Layout(props: LayoutProps<"/[locale]">) {
notFound();
}
- setRequestLocale(locale);
+ return (
+
}>
+
{children}
+
+ );
+}
+
+/** Resolves the request token before mounting the authenticated app providers. */
+async function AuthenticatedAppProviders({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const token = await getToken();
- return children;
+ return
{children};
}
diff --git a/apps/www/app/[locale]/(app)/loading.tsx b/apps/www/app/[locale]/(app)/loading.tsx
new file mode 100644
index 000000000..ce6fa3c53
--- /dev/null
+++ b/apps/www/app/[locale]/(app)/loading.tsx
@@ -0,0 +1,4 @@
+/** Renders the route-group fallback while the shared app auth token resolves. */
+export default function Loading() {
+ return
;
+}
diff --git a/apps/www/app/[locale]/(app)/not-found.tsx b/apps/www/app/[locale]/(app)/not-found.tsx
index b2e49b2e0..3ae80da73 100644
--- a/apps/www/app/[locale]/(app)/not-found.tsx
+++ b/apps/www/app/[locale]/(app)/not-found.tsx
@@ -9,10 +9,7 @@ export default async function NotFound() {
const t = await getTranslations("NotFound");
return (
-
+
diff --git a/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/forum/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/forum/page.tsx
new file mode 100644
index 000000000..e688b1a7c
--- /dev/null
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/forum/page.tsx
@@ -0,0 +1,12 @@
+import { SchoolClassesForumHeader } from "@/components/school/classes/forum/header";
+import { SchoolClassesForumList } from "@/components/school/classes/forum/list";
+import { SchoolLayoutContent } from "@/components/school/layout-content";
+
+export default function Page() {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/layout.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/layout.tsx
similarity index 93%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/layout.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/layout.tsx
index b0646e20a..1282407b5 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/layout.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/layout.tsx
@@ -3,7 +3,7 @@ import { ErrorBoundary } from "@repo/design-system/components/ui/error-boundary"
import { routing } from "@repo/internationalization/src/routing";
import { notFound } from "next/navigation";
import { hasLocale } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
+
import { use } from "react";
import { SchoolClassesForumPostSheet } from "@/components/school/classes/forum/post-sheet";
import { SchoolClassesHeaderInfo } from "@/components/school/classes/info";
@@ -23,9 +23,6 @@ export default function Layout(
notFound();
}
- // Enable static rendering
- setRequestLocale(locale);
-
const classId = id as Id<"schoolClasses">;
return (
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/materials/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/materials/page.tsx
similarity index 51%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/materials/page.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/materials/page.tsx
index fdac1fe4d..b79eec939 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/materials/page.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/materials/page.tsx
@@ -1,19 +1,8 @@
-import { setRequestLocale } from "next-intl/server";
-import { use } from "react";
import { SchoolClassesMaterialsHeader } from "@/components/school/classes/materials/header";
import { SchoolClassesMaterialsList } from "@/components/school/classes/materials/list";
import { SchoolLayoutContent } from "@/components/school/layout-content";
-import { getLocaleOrThrow } from "@/lib/i18n/params";
-
-export default function Page(
- props: PageProps<"/[locale]/school/[slug]/classes/[id]/materials">
-) {
- const { params } = props;
- const { locale: rawLocale } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- setRequestLocale(locale);
+export default function Page() {
return (
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/page.tsx
similarity index 84%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/page.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/page.tsx
index 21477f429..b6d2296ed 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/page.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/page.tsx
@@ -1,7 +1,6 @@
import { routing } from "@repo/internationalization/src/routing";
import { notFound, redirect } from "next/navigation";
import { hasLocale } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
export default async function Page(
props: PageProps<"/[locale]/school/[slug]/classes/[id]">
@@ -13,7 +12,5 @@ export default async function Page(
notFound();
}
- setRequestLocale(locale);
-
redirect(`/${locale}/school/${slug}/classes/${id}/forum`);
}
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/people/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/people/page.tsx
similarity index 50%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/people/page.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/people/page.tsx
index c4830ccfb..b9070ba65 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/people/page.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/people/page.tsx
@@ -1,19 +1,8 @@
-import { setRequestLocale } from "next-intl/server";
-import { use } from "react";
import { SchoolClassesPeopleHeader } from "@/components/school/classes/people/header";
import { SchoolClassesPeopleList } from "@/components/school/classes/people/list";
import { SchoolLayoutContent } from "@/components/school/layout-content";
-import { getLocaleOrThrow } from "@/lib/i18n/params";
-
-export default function Page(
- props: PageProps<"/[locale]/school/[slug]/classes/[id]/people">
-) {
- const { params } = props;
- const { locale: rawLocale } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- setRequestLocale(locale);
+export default function Page() {
return (
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/page.tsx
similarity index 70%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/page.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/page.tsx
index 29b3f2a0b..409566dca 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/page.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/page.tsx
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
-import { getTranslations, setRequestLocale } from "next-intl/server";
-import { use } from "react";
+import { getTranslations } from "next-intl/server";
import { SchoolClassesHeader } from "@/components/school/classes/header";
import { SchoolClassesList } from "@/components/school/classes/list";
import { SchoolLayoutContent } from "@/components/school/layout-content";
@@ -19,15 +18,7 @@ export async function generateMetadata({
};
}
-export default function Page(
- props: PageProps<"/[locale]/school/[slug]/classes">
-) {
- const { params } = props;
- const { locale: rawLocale } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- setRequestLocale(locale);
-
+export default function Page() {
return (
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/home/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/home/page.tsx
similarity index 61%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/home/page.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/home/page.tsx
index 52ea97cb1..734bc8211 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/home/page.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/home/page.tsx
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
-import { getTranslations, setRequestLocale } from "next-intl/server";
-import { use } from "react";
+import { getTranslations } from "next-intl/server";
import { SchoolLayoutContent } from "@/components/school/layout-content";
import { getLocaleOrThrow } from "@/lib/i18n/params";
@@ -17,12 +16,6 @@ export async function generateMetadata({
};
}
-export default function Page(props: PageProps<"/[locale]/school/[slug]/home">) {
- const { params } = props;
- const { locale: rawLocale } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- setRequestLocale(locale);
-
+export default function Page() {
return
;
}
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/layout.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/layout.tsx
similarity index 86%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/layout.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/layout.tsx
index c9253ea1f..b62fa1ba2 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/layout.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/layout.tsx
@@ -5,7 +5,7 @@ import {
import { routing } from "@repo/internationalization/src/routing";
import { notFound } from "next/navigation";
import { hasLocale } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
+
import { use } from "react";
import { SchoolSidebar } from "@/components/school/sidebar";
@@ -17,9 +17,6 @@ export default function Layout(props: LayoutProps<"/[locale]/school/[slug]">) {
notFound();
}
- // Enable static rendering
- setRequestLocale(locale);
-
return (
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/notifications/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/notifications/page.tsx
similarity index 61%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/notifications/page.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/notifications/page.tsx
index ccbcf0404..856ef7d2b 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/notifications/page.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/notifications/page.tsx
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
-import { getTranslations, setRequestLocale } from "next-intl/server";
-import { use } from "react";
+import { getTranslations } from "next-intl/server";
import { SchoolLayoutContent } from "@/components/school/layout-content";
import { getLocaleOrThrow } from "@/lib/i18n/params";
@@ -17,14 +16,6 @@ export async function generateMetadata({
};
}
-export default function Page(
- props: PageProps<"/[locale]/school/[slug]/notifications">
-) {
- const { params } = props;
- const { locale: rawLocale } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- setRequestLocale(locale);
-
+export default function Page() {
return ;
}
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/layout.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/layout.tsx
similarity index 93%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/layout.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/layout.tsx
index 8a3601ee3..9c7160a57 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/layout.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/layout.tsx
@@ -5,7 +5,7 @@ import { fetchQuery } from "convex/nextjs";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { hasLocale } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
+
import { cache, use } from "react";
import { SchoolNotFound } from "@/components/school/not-found";
import { SchoolContextProvider } from "@/lib/context/use-school";
@@ -45,9 +45,6 @@ export default function Layout(props: LayoutProps<"/[locale]/school/[slug]">) {
notFound();
}
- // Enable static rendering
- setRequestLocale(locale);
-
return (
}>
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/page.tsx
similarity index 83%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/page.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/page.tsx
index a3233873c..9f7c48ed9 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/page.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/page.tsx
@@ -1,7 +1,6 @@
import { routing } from "@repo/internationalization/src/routing";
import { notFound, redirect } from "next/navigation";
import { hasLocale } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
export default async function Page(
props: PageProps<"/[locale]/school/[slug]">
@@ -13,7 +12,5 @@ export default async function Page(
notFound();
}
- setRequestLocale(locale);
-
redirect(`/${locale}/school/${slug}/home`);
}
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/auth.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/auth.tsx
similarity index 100%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/auth.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/auth.tsx
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/layout.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/layout.tsx
similarity index 82%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/layout.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/layout.tsx
index a978e62a9..416e2555a 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/layout.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/layout.tsx
@@ -1,7 +1,7 @@
import { routing } from "@repo/internationalization/src/routing";
import { notFound } from "next/navigation";
import { hasLocale } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
+
import { use } from "react";
import { LayoutAuth } from "./auth";
@@ -13,8 +13,5 @@ export default function Layout(props: LayoutProps<"/[locale]/school">) {
notFound();
}
- // Enable static rendering
- setRequestLocale(locale);
-
return {children};
}
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/create/form.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/create/form.tsx
similarity index 100%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/create/form.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/create/form.tsx
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/create/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/create/page.tsx
similarity index 72%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/create/page.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/create/page.tsx
index 299b6f5a3..be21922f5 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/create/page.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/create/page.tsx
@@ -2,20 +2,9 @@ import { ArrowLeft02Icon } from "@hugeicons/core-free-icons";
import { HugeIcons } from "@repo/design-system/components/ui/huge-icons";
import NavigationLink from "@repo/design-system/components/ui/navigation-link";
import { useTranslations } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
-import { use } from "react";
-import { getLocaleOrThrow } from "@/lib/i18n/params";
import { SchoolOnboardingCreateForm } from "./form";
-export default function Page(
- props: PageProps<"/[locale]/school/onboarding/create">
-) {
- const { params } = props;
- const { locale: rawLocale } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- setRequestLocale(locale);
-
+export default function Page() {
const t = useTranslations("School.Onboarding");
return (
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/join/form.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/join/form.tsx
similarity index 100%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/join/form.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/join/form.tsx
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/join/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/join/page.tsx
similarity index 72%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/join/page.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/join/page.tsx
index 072ebb6e4..eed54babd 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/join/page.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/join/page.tsx
@@ -2,20 +2,9 @@ import { ArrowLeft02Icon } from "@hugeicons/core-free-icons";
import { HugeIcons } from "@repo/design-system/components/ui/huge-icons";
import NavigationLink from "@repo/design-system/components/ui/navigation-link";
import { useTranslations } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
-import { use } from "react";
-import { getLocaleOrThrow } from "@/lib/i18n/params";
import { SchoolOnboardingJoinForm } from "./form";
-export default function Page(
- props: PageProps<"/[locale]/school/onboarding/join">
-) {
- const { params } = props;
- const { locale: rawLocale } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- setRequestLocale(locale);
-
+export default function Page() {
const t = useTranslations("School.Onboarding");
return (
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/layout.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/layout.tsx
similarity index 86%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/layout.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/layout.tsx
index ed920c434..a25e5e028 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/layout.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/layout.tsx
@@ -2,7 +2,7 @@ import { Particles } from "@repo/design-system/components/ui/particles";
import { routing } from "@repo/internationalization/src/routing";
import { notFound } from "next/navigation";
import { hasLocale } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
+
import { use } from "react";
export default function Layout(
@@ -15,9 +15,6 @@ export default function Layout(
notFound();
}
- // Enable static rendering
- setRequestLocale(locale);
-
return (
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/page.tsx
similarity index 89%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/page.tsx
rename to apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/page.tsx
index 076d32912..188e1c59e 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/page.tsx
+++ b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/page.tsx
@@ -1,22 +1,13 @@
import NavigationLink from "@repo/design-system/components/ui/navigation-link";
import Image from "next/image";
import { useTranslations } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
-import { use } from "react";
-import { getLocaleOrThrow } from "@/lib/i18n/params";
import SchoolCreateImage from "@/public/school-create.png";
import SchoolJoinImage from "@/public/school-join.png";
const IMAGE_WIDTH = 238;
const IMAGE_HEIGHT = 134;
-export default function Page(props: PageProps<"/[locale]/school/onboarding">) {
- const { params } = props;
- const { locale: rawLocale } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- setRequestLocale(locale);
-
+export default function Page() {
const t = useTranslations("School.Onboarding");
return (
diff --git a/apps/www/app/[locale]/(app)/school/auth/page.tsx b/apps/www/app/[locale]/(app)/school/auth/page.tsx
new file mode 100644
index 000000000..56efe2e08
--- /dev/null
+++ b/apps/www/app/[locale]/(app)/school/auth/page.tsx
@@ -0,0 +1,13 @@
+import { Particles } from "@repo/design-system/components/ui/particles";
+import { ComingSoon } from "@/components/shared/coming-soon";
+
+export default function Page() {
+ return (
+
+ );
+}
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/layout.tsx b/apps/www/app/[locale]/(app)/school/layout.tsx
similarity index 93%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/layout.tsx
rename to apps/www/app/[locale]/(app)/school/layout.tsx
index 0cd2f9266..46d305910 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/layout.tsx
+++ b/apps/www/app/[locale]/(app)/school/layout.tsx
@@ -2,7 +2,7 @@ import { routing } from "@repo/internationalization/src/routing";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { hasLocale } from "next-intl";
-import { getTranslations, setRequestLocale } from "next-intl/server";
+import { getTranslations } from "next-intl/server";
import { use } from "react";
export async function generateMetadata({
@@ -65,7 +65,5 @@ export default function Layout(props: LayoutProps<"/[locale]/school">) {
notFound();
}
- setRequestLocale(locale);
-
return children;
}
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/page.tsx b/apps/www/app/[locale]/(app)/school/page.tsx
similarity index 63%
rename from apps/www/app/[locale]/(app)/(dynamic)/school/page.tsx
rename to apps/www/app/[locale]/(app)/school/page.tsx
index 0fe1e83d5..e08c295f0 100644
--- a/apps/www/app/[locale]/(app)/(dynamic)/school/page.tsx
+++ b/apps/www/app/[locale]/(app)/school/page.tsx
@@ -1,18 +1,11 @@
import { Particles } from "@repo/design-system/components/ui/particles";
-import { setRequestLocale } from "next-intl/server";
-import { Suspense, use } from "react";
+
+import { Suspense } from "react";
import { School } from "@/components/school";
import { SchoolLoader } from "@/components/school/loader";
import { ComingSoon } from "@/components/shared/coming-soon";
-import { getLocaleOrThrow } from "@/lib/i18n/params";
-
-export default function Page(props: PageProps<"/[locale]/school">) {
- const { params } = props;
- const { locale: rawLocale } = use(params);
- const locale = getLocaleOrThrow(rawLocale);
-
- setRequestLocale(locale);
+export default function Page() {
return (
}>
diff --git a/apps/www/app/[locale]/[...rest]/page.tsx b/apps/www/app/[locale]/[...rest]/page.tsx
index 064276c26..3b1d244b6 100644
--- a/apps/www/app/[locale]/[...rest]/page.tsx
+++ b/apps/www/app/[locale]/[...rest]/page.tsx
@@ -1,17 +1,4 @@
-import { routing } from "@repo/internationalization/src/routing";
import { notFound } from "next/navigation";
-import { hasLocale } from "next-intl";
-import { setRequestLocale } from "next-intl/server";
-
-export default async function Page(props: PageProps<"/[locale]/[...rest]">) {
- const { params } = props;
- const { locale } = await params;
-
- if (!hasLocale(routing.locales, locale)) {
- notFound();
- }
-
- setRequestLocale(locale);
-
+export default function Page() {
notFound();
}
diff --git a/apps/www/app/[locale]/layout.tsx b/apps/www/app/[locale]/layout.tsx
index 2cc7db747..0afc5a2ef 100644
--- a/apps/www/app/[locale]/layout.tsx
+++ b/apps/www/app/[locale]/layout.tsx
@@ -11,25 +11,16 @@ import { WebsiteJsonLd } from "@repo/seo/json-ld/website";
import type { Metadata, Viewport } from "next";
import { notFound } from "next/navigation";
import { hasLocale, NextIntlClientProvider } from "next-intl";
-import { getTranslations, setRequestLocale } from "next-intl/server";
-import { use } from "react";
-import { AppProviders } from "@/components/providers";
+import { getLocale, getMessages, getTranslations } from "next-intl/server";
-export async function generateMetadata({
- params,
-}: {
- params: LayoutProps<"/[locale]">["params"];
-}): Promise {
- const { locale } = await params;
+export async function generateMetadata(): Promise {
+ const locale = await getLocale();
if (!hasLocale(routing.locales, locale)) {
notFound();
}
- const t = await getTranslations({
- locale,
- namespace: "Metadata",
- });
+ const t = await getTranslations("Metadata");
return {
title: {
@@ -146,20 +137,18 @@ export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
-export default function Layout(props: LayoutProps<"/[locale]">) {
- const { children, params } = props;
- const { locale } = use(params);
- // Ensure that the incoming `locale` is valid
+export default async function Layout({ children }: LayoutProps<"/[locale]">) {
+ const locale = await getLocale();
+
if (!hasLocale(routing.locales, locale)) {
notFound();
}
- // Enable static rendering
- setRequestLocale(locale);
+ const messages = await getMessages();
return (
-
+
{/* Add JSON-LD structured data using the JsonLd component */}
@@ -168,9 +157,7 @@ export default function Layout(props: LayoutProps<"/[locale]">) {
-
- {children}
-
+
{children}
diff --git a/apps/www/app/[locale]/og/[...slug]/route.tsx b/apps/www/app/[locale]/og/[...slug]/route.tsx
index d9f40c2c2..88e8a9c2a 100644
--- a/apps/www/app/[locale]/og/[...slug]/route.tsx
+++ b/apps/www/app/[locale]/og/[...slug]/route.tsx
@@ -1,16 +1,8 @@
import { routing } from "@repo/internationalization/src/routing";
-import { Effect } from "effect";
import type { NextRequest } from "next/server";
import { hasLocale, type Locale } from "next-intl";
-import { getMetadataFromSlug } from "@/lib/utils/system";
-import { generateOGImage } from "./og";
-
-export const dynamic = "force-static";
-export const revalidate = false;
-
-export function generateStaticParams() {
- return [];
-}
+import { generateOGImage } from "@/lib/og";
+import { getCachedMetadataFromSlug } from "@/lib/utils/system";
export async function GET(
_req: NextRequest,
@@ -24,11 +16,12 @@ export async function GET(
const contentSlug = slug.at(-1) === "image.png" ? slug.slice(0, -1) : slug;
- const { title, description } = await Effect.runPromise(
- getMetadataFromSlug(cleanedLocale, contentSlug)
+ const { title, description } = await getCachedMetadataFromSlug(
+ cleanedLocale,
+ contentSlug
);
- return generateOGImage({
+ return await generateOGImage({
title,
description,
});
diff --git a/apps/www/app/api/chat/route.ts b/apps/www/app/api/chat/route.ts
index d738ca2c0..2232228f9 100644
--- a/apps/www/app/api/chat/route.ts
+++ b/apps/www/app/api/chat/route.ts
@@ -196,7 +196,7 @@ export async function POST(req: Request) {
sessionLogger.info("Chat session started");
- const t = await getTranslations("Ai");
+ const t = await getTranslations({ locale, namespace: "Ai" });
const stream = createUIMessageStream({
onError: (error) => {
diff --git a/apps/www/app/global-error.tsx b/apps/www/app/global-error.tsx
index bf7c16dad..a7595a377 100644
--- a/apps/www/app/global-error.tsx
+++ b/apps/www/app/global-error.tsx
@@ -28,10 +28,7 @@ export default function GlobalError({
-
+
diff --git a/apps/www/app/not-found.tsx b/apps/www/app/global-not-found.tsx
similarity index 88%
rename from apps/www/app/not-found.tsx
rename to apps/www/app/global-not-found.tsx
index 0a9754e6a..c34af917f 100644
--- a/apps/www/app/not-found.tsx
+++ b/apps/www/app/global-not-found.tsx
@@ -6,9 +6,15 @@ import { Particles } from "@repo/design-system/components/ui/particles";
import { buttonVariants } from "@repo/design-system/lib/button";
import { fonts } from "@repo/design-system/lib/fonts";
import { cn } from "@repo/design-system/lib/utils";
+import type { Metadata } from "next";
import Link from "next/link";
-export default function NotFound() {
+export const metadata: Metadata = {
+ title: "404 - Page Not Found",
+ description: "The page you are looking for does not exist.",
+};
+
+export default function GlobalNotFound() {
return (
@@ -18,10 +24,7 @@ export default function NotFound() {
disableTransitionOnChange
enableSystem
>
-
+
diff --git a/apps/www/app/layout.tsx b/apps/www/app/layout.tsx
deleted file mode 100644
index ca8429e4f..000000000
--- a/apps/www/app/layout.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-// Since we have a root `not-found.tsx` page, a layout file
-
-import type { ReactNode } from "react";
-
-// is required, even if it's just passing children through.
-export default function RootLayout({ children }: { children: ReactNode }) {
- return children;
-}
diff --git a/apps/www/app/llms.mdx/[...slug]/route.ts b/apps/www/app/llms.mdx/[...slug]/route.ts
index 1d41fe3fb..8654c5a1c 100644
--- a/apps/www/app/llms.mdx/[...slug]/route.ts
+++ b/apps/www/app/llms.mdx/[...slug]/route.ts
@@ -1,390 +1,48 @@
-import { getExercisesContent } from "@repo/contents/_lib/exercises";
-import {
- getCurrentMaterial,
- getMaterialPath,
- getMaterials,
-} from "@repo/contents/_lib/exercises/material";
-import { getContentMetadataWithRaw } from "@repo/contents/_lib/metadata";
-import { getAllSurah, getSurah, getSurahName } from "@repo/contents/_lib/quran";
-import { ExercisesCategorySchema } from "@repo/contents/_types/exercises/category";
-import { ExercisesMaterialSchema } from "@repo/contents/_types/exercises/material";
-import { ExercisesTypeSchema } from "@repo/contents/_types/exercises/type";
+import { generateSlugOnlyParams } from "@repo/contents/_lib/static-params";
import { routing } from "@repo/internationalization/src/routing";
-import { Effect, Option } from "effect";
-import ky from "ky";
import type { NextRequest } from "next/server";
import { hasLocale, type Locale } from "next-intl";
-import { getRawGithubUrl } from "@/lib/utils/github";
-
-const BASE_URL = "https://nakafa.com";
+import {
+ getCachedLlmsExerciseText,
+ getCachedLlmsIndexText,
+ getCachedLlmsMdxText,
+ getQuranLlmsText,
+} from "@/lib/llms";
-export const dynamic = "force-static";
-export const revalidate = false;
+export function generateStaticParams() {
+ return generateSlugOnlyParams({
+ includeExerciseNumbers: true,
+ includeExerciseSets: true,
+ includeQuran: true,
+ });
+}
export async function GET(
_req: NextRequest,
- { params }: { params: Promise<{ slug: string[] }> }
+ ctx: RouteContext<"/llms.mdx/[...slug]">
) {
- const slug = (await params).slug;
-
- // Parse locale from slug
+ const { slug } = await ctx.params;
const locale: Locale = hasLocale(routing.locales, slug[0])
? slug[0]
: routing.defaultLocale;
- let cleanSlug = hasLocale(routing.locales, slug[0])
+ const cleanSlug = hasLocale(routing.locales, slug[0])
? slug.slice(1).join("/")
: slug.join("/");
- // if last element is /llms.txt, we must remove it
- if (cleanSlug.endsWith("/llms.txt")) {
- cleanSlug = cleanSlug.slice(0, -"/llms.txt".length);
- }
-
- // Handle Quran content
- if (cleanSlug.startsWith("quran")) {
- const quranResponse = handleQuranContent({
- cleanSlug,
- locale,
- });
- if (quranResponse) {
- return quranResponse;
- }
- }
-
- // Handle Exercises content
- if (cleanSlug.startsWith("exercises")) {
- const exercisesResponse = await handleExercisesContent({
- cleanSlug,
- locale,
- });
- if (exercisesResponse) {
- return exercisesResponse;
- }
- }
-
- // Handle MDX content
- const content = await Effect.runPromise(
- Effect.match(getContentMetadataWithRaw(locale, cleanSlug), {
- onFailure: () => null,
- onSuccess: (data) => data,
- })
- );
- if (content) {
- return buildMdxResponse({ content, locale, cleanSlug });
- }
-
- // Fallback to /llms.txt for everything not found
- const fallbackResponse = await ky
- .get(`${BASE_URL}/llms.txt`, {
- cache: "force-cache",
- })
- .text()
- .catch(() => "");
- return new Response(fallbackResponse);
-}
-
-function buildHeader({
- url,
- description,
- source,
-}: {
- url: string;
- description: string;
- source?: string;
-}): string[] {
- const header = ["# Nakafa Framework: LLM", "", `URL: ${url}`];
-
- if (source) {
- header.push(`Source: ${source}`);
- }
-
- header.push("", description, "", "---", "");
-
- return header;
-}
-
-function getTranslation(
- translations: Record
,
- locale: Locale
-): string {
- return translations[locale] || translations.en;
-}
-
-function handleQuranContent({
- cleanSlug,
- locale,
-}: {
- cleanSlug: string;
- locale: Locale;
-}): Response | null {
- const parts = cleanSlug.split("/");
- const scanned: string[] = [];
-
- // List all surahs
- if (parts.length === 1) {
- const surahs = getAllSurah();
- const url = `${BASE_URL}/${locale}/quran`;
- scanned.push(
- ...buildHeader({
- url,
- description: "Al-Quran - List of all 114 Surahs in the Holy Quran.",
- })
- );
-
- for (const surah of surahs) {
- const title = getSurahName({ locale, name: surah.name });
- const translation = getTranslation(surah.name.translation, locale);
- scanned.push(`## ${surah.number}. ${title}`);
- scanned.push("");
- scanned.push(`**Translation:** ${translation}`);
- scanned.push("");
- scanned.push(`**Revelation:** ${surah.revelation.en}`);
- scanned.push("");
- scanned.push(`**Number of Verses:** ${surah.numberOfVerses}`);
- scanned.push("");
- }
-
- return new Response(scanned.join("\n"));
- }
-
- // Show specific surah
- if (parts.length === 2) {
- const surahNumber = Number(parts[1]);
- const surahData = Option.fromNullable(
- Effect.runSync(
- Effect.match(getSurah(surahNumber), {
- onFailure: () => null,
- onSuccess: (data) => data,
- })
- )
- );
-
- if (Option.isSome(surahData)) {
- const surahValue = Option.getOrThrow(surahData);
- const title = getSurahName({ locale, name: surahValue.name });
- const translation = getTranslation(surahValue.name.translation, locale);
- const url = `${BASE_URL}/${locale}/quran/${surahNumber}`;
-
- scanned.push(
- ...buildHeader({
- url,
- description: `Al-Quran - Surah ${title} (${translation})`,
- })
- );
-
- scanned.push(`## ${title}`);
- scanned.push("");
- scanned.push(`**Translation:** ${translation}`);
- scanned.push(`**Revelation:** ${surahValue.revelation.en}`);
- scanned.push(`**Number of Verses:** ${surahValue.numberOfVerses}`);
- scanned.push("");
-
- // Add pre-bismillah if exists
- if (surahValue.preBismillah) {
- scanned.push("### Pre-Bismillah");
- scanned.push("");
- scanned.push(surahValue.preBismillah.text.arab);
- scanned.push("");
- const preBismillahTranslation = getTranslation(
- surahValue.preBismillah.translation,
- locale
- );
- scanned.push(`*${preBismillahTranslation}*`);
- scanned.push("");
- }
-
- // Add all verses
- scanned.push("### Verses");
- scanned.push("");
-
- for (const verse of surahValue.verses) {
- scanned.push(`#### Verse ${verse.number.inSurah}`);
- scanned.push("");
- scanned.push(verse.text.arab);
- scanned.push("");
- scanned.push(`**Transliteration:** ${verse.text.transliteration.en}`);
- scanned.push("");
- scanned.push(
- `**Translation:** ${getTranslation(verse.translation, locale)}`
- );
- scanned.push("");
- }
-
- return new Response(scanned.join("\n"));
- }
- }
-
- return null;
-}
-
-async function handleExercisesContent({
- cleanSlug,
- locale,
-}: {
- cleanSlug: string;
- locale: Locale;
-}): Promise {
- const parts = cleanSlug.split("/");
- let exerciseNumber: number | null = null;
- let path = cleanSlug;
-
- // Check if last part is a number (specific exercise request)
- const lastPart = parts.at(-1);
- if (lastPart) {
- const parsedNumber = Number.parseInt(lastPart, 10);
- if (!Number.isNaN(parsedNumber)) {
- exerciseNumber = parsedNumber;
- path = parts.slice(0, -1).join("/");
- }
- }
-
- // Fetch exercises without MDX components (raw content only for LLM output)
- const exercises = await Effect.runPromise(
- Effect.match(
- getExercisesContent({ locale, filePath: path, includeMDX: false }),
- {
- onFailure: () => [],
- onSuccess: (data) => data,
- }
- )
- );
-
- if (exercises.length === 0) {
- return null;
- }
-
- // If specific exercise requested, filter it
- let targetExercises = exercises;
- if (exerciseNumber !== null) {
- targetExercises = exercises.filter((ex) => ex.number === exerciseNumber);
- if (targetExercises.length === 0) {
- return null;
- }
- }
-
- const url = `${BASE_URL}/${locale}/${cleanSlug}`;
-
- let description = "Exercises Content";
- const pathParts = path.split("/");
- const category = pathParts.at(1);
- const type = pathParts.at(2);
- const material = pathParts.at(3);
-
- const parsedCategory = ExercisesCategorySchema.safeParse(category);
- const parsedType = ExercisesTypeSchema.safeParse(type);
- const parsedMaterial = ExercisesMaterialSchema.safeParse(material);
-
- if (parsedCategory.success && parsedType.success && parsedMaterial.success) {
- const materialPath = getMaterialPath(
- parsedCategory.data,
- parsedType.data,
- parsedMaterial.data
- );
- const materialsList = await getMaterials(materialPath, locale);
- const { currentMaterial, currentMaterialItem } = getCurrentMaterial(
- `/${path}`,
- materialsList
- );
-
- if (currentMaterial && currentMaterialItem) {
- description = currentMaterial.description
- ? `Exercises: ${currentMaterial.title} - ${currentMaterialItem.title}: ${currentMaterial.description}`
- : `Exercises: ${currentMaterial.title} - ${currentMaterialItem.title}`;
- }
+ const quranText = getQuranLlmsText({ cleanSlug, locale });
+ if (quranText) {
+ return new Response(quranText);
}
- // If specific exercise, append its info
- if (exerciseNumber !== null) {
- const exerciseTitle = targetExercises[0]?.question.metadata.title;
- if (exerciseTitle) {
- description = `${description} - ${exerciseTitle}`;
- } else {
- description = `${description} - Question ${exerciseNumber}`;
- }
+ const exerciseText = await getCachedLlmsExerciseText({ cleanSlug, locale });
+ if (exerciseText) {
+ return new Response(exerciseText);
}
- const scanned: string[] = [];
- scanned.push(
- ...buildHeader({
- url,
- description,
- })
- );
-
- for (const exercise of targetExercises) {
- scanned.push(`## Exercise ${exercise.number}`);
- scanned.push("");
-
- // Question
- scanned.push("### Question");
- scanned.push("");
- scanned.push(exercise.question.raw);
- scanned.push("");
-
- // Choices
- scanned.push("### Choices");
- scanned.push("");
- // Fallback to English if locale specific choices are missing (though schema requires them)
- const choices =
- (locale === "id" ? exercise.choices.id : exercise.choices.en) ||
- exercise.choices.en;
-
- if (choices) {
- for (const choice of choices) {
- const mark = choice.value ? "x" : " ";
- scanned.push(`- [${mark}] ${choice.label}`);
- }
- }
- scanned.push("");
-
- // Answer (Explanation)
- scanned.push("### Answer & Explanation");
- scanned.push("");
- scanned.push(exercise.answer.raw);
- scanned.push("");
- scanned.push("---");
- scanned.push("");
+ const mdxText = await getCachedLlmsMdxText({ cleanSlug, locale });
+ if (mdxText) {
+ return new Response(mdxText);
}
- return new Response(scanned.join("\n"));
-}
-
-function buildMdxResponse({
- content,
- locale,
- cleanSlug,
-}: {
- content: {
- metadata: {
- title: string;
- authors: { name: string }[];
- date: string;
- description?: string;
- subject?: string;
- };
- default?: React.ReactElement;
- raw: string;
- };
- locale: Locale;
- cleanSlug: string;
-}): Response {
- const url = `${BASE_URL}/${locale}/${cleanSlug}`;
- const source = getRawGithubUrl(
- `/packages/contents/${cleanSlug}/${locale}.mdx`
- );
-
- const scanned = [
- ...buildHeader({
- url,
- description: "Output docs content for large language models.",
- source,
- }),
- content.raw,
- ];
-
- return new Response(scanned.join("\n"));
-}
-
-export function generateStaticParams() {
- return [];
+ return new Response(await getCachedLlmsIndexText());
}
diff --git a/apps/www/app/llms.txt/route.ts b/apps/www/app/llms.txt/route.ts
index 998dab3c6..7f12eb4ab 100644
--- a/apps/www/app/llms.txt/route.ts
+++ b/apps/www/app/llms.txt/route.ts
@@ -1,182 +1,5 @@
-import {
- getMaterialPath,
- getMaterials,
-} from "@repo/contents/_lib/exercises/material";
-import { getFolderChildNames } from "@repo/contents/_lib/fs";
-import { getContentsMetadata } from "@repo/contents/_lib/metadata";
-import { getAllSurah, getSurahName } from "@repo/contents/_lib/quran";
-import {
- type ExercisesCategory,
- ExercisesCategorySchema,
-} from "@repo/contents/_types/exercises/category";
-import {
- type ExercisesMaterial,
- ExercisesMaterialSchema,
-} from "@repo/contents/_types/exercises/material";
-import {
- type ExercisesType,
- ExercisesTypeSchema,
-} from "@repo/contents/_types/exercises/type";
-import { routing } from "@repo/internationalization/src/routing";
-import { Effect } from "effect";
-
-const BASE_URL = "https://nakafa.com";
-
-export const revalidate = false;
+import { getCachedLlmsIndexText } from "@/lib/llms";
export async function GET() {
- const locales = routing.locales;
- const scanned: string[] = [];
-
- scanned.push("# Nakafa Framework: LLM");
- scanned.push(`URL: ${BASE_URL}/llms.txt`);
- scanned.push("Complete list of all content available on Nakafa.");
- scanned.push("---");
-
- // Fetch all articles and subjects for all locales in parallel
- const contentPromises = locales.flatMap((locale) => [
- Effect.runPromise(
- getContentsMetadata({ locale, basePath: "articles" })
- ).then((contents) => ({
- section: "Articles",
- locale,
- contents,
- })),
- Effect.runPromise(
- getContentsMetadata({ locale, basePath: "subject" })
- ).then((contents) => ({
- section: "Subjects",
- locale,
- contents,
- })),
- ]);
-
- const results = await Promise.all(contentPromises);
-
- // Group results by section
- const map = new Map();
-
- for (const result of results) {
- for (const content of result.contents) {
- if (!content) {
- continue;
- }
-
- const entry = `- [${content.metadata.title}](${content.url}): ${
- content.metadata.description ?? content.metadata.title
- }`;
-
- const list = map.get(result.section) ?? [];
- list.push(entry);
- map.set(result.section, list);
- }
- }
-
- // Handle Exercises
- const exercisesList: string[] = [];
- const exerciseMaterials = getAllExerciseMaterials();
-
- for (const locale of locales) {
- for (const { category, type, material } of exerciseMaterials) {
- const materialPath = getMaterialPath(category, type, material);
- const materialsList = await getMaterials(materialPath, locale);
-
- const context = `${category}/${type}/${material}`;
-
- for (const group of materialsList) {
- for (const item of group.items) {
- const href = item.href.startsWith("/") ? item.href : `/${item.href}`;
- const url = `${BASE_URL}/${locale}${href}`;
- const title = `${group.title} - ${item.title} (${context})`;
- const description = group.description ?? group.title;
- exercisesList.push(`- [${title}](${url}): ${description}`);
- }
- }
- }
- }
-
- if (exercisesList.length > 0) {
- map.set("Exercises", exercisesList);
- }
-
- // Build final output
- for (const [key, value] of map) {
- scanned.push(`## ${key}`);
- scanned.push(value.join("\n"));
- }
-
- // Add Quran section
- scanned.push("## Quran");
- const surahs = getAllSurah();
- const quranEntries: string[] = [];
-
- for (const locale of locales) {
- for (const surah of surahs) {
- const title = getSurahName({ locale, name: surah.name });
- const translation =
- surah.name.translation[locale] || surah.name.translation.en;
- quranEntries.push(
- `- [${surah.number}. ${title}](${BASE_URL}/${locale}/quran/${surah.number}): ${translation}`
- );
- }
- }
-
- scanned.push(quranEntries.join("\n"));
-
- return new Response(scanned.join("\n\n"));
-}
-
-function getAllExerciseMaterials(): {
- category: ExercisesCategory;
- type: ExercisesType;
- material: ExercisesMaterial;
-}[] {
- const root = "exercises";
- const categories = Effect.runSync(
- Effect.match(getFolderChildNames(root), {
- onFailure: () => [],
- onSuccess: (names) => names,
- })
- );
- const result: {
- category: ExercisesCategory;
- type: ExercisesType;
- material: ExercisesMaterial;
- }[] = [];
-
- for (const category of categories) {
- const types = Effect.runSync(
- Effect.match(getFolderChildNames(`${root}/${category}`), {
- onFailure: () => [],
- onSuccess: (names) => names,
- })
- );
- for (const type of types) {
- const materials = Effect.runSync(
- Effect.match(getFolderChildNames(`${root}/${category}/${type}`), {
- onFailure: () => [],
- onSuccess: (names) => names,
- })
- );
- for (const material of materials) {
- const parsedCategory = ExercisesCategorySchema.safeParse(category);
- const parsedType = ExercisesTypeSchema.safeParse(type);
- const parsedMaterial = ExercisesMaterialSchema.safeParse(material);
-
- if (
- parsedCategory.success &&
- parsedType.success &&
- parsedMaterial.success
- ) {
- result.push({
- category: parsedCategory.data,
- type: parsedType.data,
- material: parsedMaterial.data,
- });
- }
- }
- }
- }
-
- return result;
+ return new Response(await getCachedLlmsIndexText());
}
diff --git a/apps/www/app/og/[...slug]/route.tsx b/apps/www/app/og/[...slug]/route.tsx
index bcba2ba6d..97e027b91 100644
--- a/apps/www/app/og/[...slug]/route.tsx
+++ b/apps/www/app/og/[...slug]/route.tsx
@@ -1,16 +1,8 @@
import { routing } from "@repo/internationalization/src/routing";
-import { Effect } from "effect";
import type { NextRequest } from "next/server";
import { hasLocale, type Locale } from "next-intl";
-import { generateOGImage } from "@/app/[locale]/og/[...slug]/og";
-import { getMetadataFromSlug } from "@/lib/utils/system";
-
-export const dynamic = "force-static";
-export const revalidate = false;
-
-export function generateStaticParams() {
- return [];
-}
+import { generateOGImage } from "@/lib/og";
+import { getCachedMetadataFromSlug } from "@/lib/utils/system";
export async function GET(
_req: NextRequest,
@@ -28,11 +20,12 @@ export async function GET(
const contentSlug =
cleanSlug.at(-1) === "image.png" ? cleanSlug.slice(0, -1) : cleanSlug;
- const { title, description } = await Effect.runPromise(
- getMetadataFromSlug(locale, contentSlug)
+ const { title, description } = await getCachedMetadataFromSlug(
+ locale,
+ contentSlug
);
- return generateOGImage({
+ return await generateOGImage({
title,
description,
});
diff --git a/apps/www/app/rss.xml/route.ts b/apps/www/app/rss.xml/route.ts
index 80798a06b..9cfd0f2dc 100644
--- a/apps/www/app/rss.xml/route.ts
+++ b/apps/www/app/rss.xml/route.ts
@@ -7,8 +7,6 @@ import { Feed, type Item } from "feed";
import { NextResponse } from "next/server";
import { getTranslations } from "next-intl/server";
-export const revalidate = false;
-
const baseUrl = "https://nakafa.com";
export async function GET() {
diff --git a/apps/www/app/sitemap.ts b/apps/www/app/sitemap.ts
index c410d9467..4e0507f0a 100644
--- a/apps/www/app/sitemap.ts
+++ b/apps/www/app/sitemap.ts
@@ -5,7 +5,6 @@ import { parseContentDate } from "@repo/contents/_shared/date";
import { getPathname } from "@repo/internationalization/src/navigation";
import { routing } from "@repo/internationalization/src/routing";
import { MAIN_DOMAIN } from "@repo/next-config/domains";
-import { askSeo } from "@repo/seo/ask";
import { Effect } from "effect";
import type { MetadataRoute } from "next";
import type { Locale } from "next-intl";
@@ -119,10 +118,6 @@ export function getQuranRoutes(): string[] {
return Array.from({ length: 114 }, (_, index) => `/quran/${index + 1}`);
}
-export function getAskRoutes(): string[] {
- return askSeo().map((data) => `/ask/${data.slug}`);
-}
-
// Function to recursively get all directories
export function getContentRoutes(currentPath = ""): string[] {
const children = Effect.runSync(
@@ -159,8 +154,7 @@ export async function getEntries(
if (
routeString !== "/" &&
!baseRoutes.includes(routeString) &&
- !routeString.startsWith("/quran") &&
- !routeString.startsWith("/ask/")
+ !routeString.startsWith("/quran")
) {
try {
// This is likely educational content, get actual modification date
@@ -180,9 +174,6 @@ export async function getEntries(
} else if (routeString.startsWith("/quran")) {
// Quran content is very stable, set to founding date
lastModified = new Date("2025-01-01");
- } else if (routeString.startsWith("/ask/")) {
- // Ask content is very stable, set to founding date
- lastModified = new Date("2025-01-01");
} else if (routeString.startsWith("/about")) {
// About page is very stable, set to founding date
lastModified = new Date("2025-01-01");
@@ -245,14 +236,12 @@ export default async function sitemap(): Promise {
return !isInvalidYearlessTryOutRoute;
});
const quranRoutes = getQuranRoutes();
- const askRoutes = getAskRoutes();
// Deduplicate all base routes (contentRoutes might include "/" which is also in baseRoutes)
const allBaseRoutesSet = new Set([
...baseRoutes,
...contentRoutes,
...quranRoutes,
- ...askRoutes,
]);
const allBaseRoutes = Array.from(allBaseRoutesSet);
diff --git a/apps/www/components/ai/chat-sidebar.tsx b/apps/www/components/ai/chat-sidebar.tsx
index 916539ec5..5660bc35f 100644
--- a/apps/www/components/ai/chat-sidebar.tsx
+++ b/apps/www/components/ai/chat-sidebar.tsx
@@ -38,7 +38,7 @@ type Props = ComponentProps;
export function AiChatSidebar({ ...props }: Props) {
return (
-
+
+ import("@/components/ai/sheet-open").then((module) => module.AiSheetOpen),
+ {
+ ssr: false,
+ loading: () => null,
+ }
+);
diff --git a/apps/www/components/ask/cta.tsx b/apps/www/components/ask/cta.tsx
deleted file mode 100644
index 4ce53989a..000000000
--- a/apps/www/components/ask/cta.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-"use client";
-
-import { StarsIcon } from "@hugeicons/core-free-icons";
-import { Button } from "@repo/design-system/components/ui/button";
-import { HugeIcons } from "@repo/design-system/components/ui/huge-icons";
-import { useRouter } from "@repo/internationalization/src/navigation";
-import { useTranslations } from "next-intl";
-import { authClient } from "@/lib/auth/client";
-import { useAi } from "@/lib/context/use-ai";
-import { useUser } from "@/lib/context/use-user";
-
-interface Props {
- title: string;
-}
-
-export function AskCta({ title }: Props) {
- const t = useTranslations("Ai");
- const user = useUser((s) => s.user);
- const router = useRouter();
-
- const setText = useAi((state) => state.setText);
-
- const handleGoogleSignIn = () => {
- authClient.signIn.social({
- provider: "google",
- });
- };
-
- const handleAsk = () => {
- setText(title);
-
- if (!user) {
- handleGoogleSignIn();
- return;
- }
-
- router.push("/chat");
- };
-
- return (
-
- );
-}
diff --git a/apps/www/components/ask/results.tsx b/apps/www/components/ask/results.tsx
deleted file mode 100644
index 9fc4a240b..000000000
--- a/apps/www/components/ask/results.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-"use client";
-
-import { getErrorMessage, usePagefind } from "@/lib/context/use-pagefind";
-import { useSearchQuery } from "@/lib/react-query/use-search";
-import { SearchResults } from "../shared/search-results";
-
-interface Props {
- query: string;
-}
-
-export function AskListItems({ query }: Props) {
- const pagefindError = usePagefind((context) => context.error);
-
- const {
- data: results = [],
- isError,
- error,
- isLoading,
- isPlaceholderData,
- } = useSearchQuery({
- query,
- enabled: Boolean(query),
- });
-
- const hasError = isError || Boolean(pagefindError);
- const displayError = pagefindError || (error ? getErrorMessage(error) : "");
- const queryLoading = isLoading && !hasError && !isPlaceholderData;
-
- return (
-
- );
-}
diff --git a/apps/www/components/comments/deferred.tsx b/apps/www/components/comments/deferred.tsx
new file mode 100644
index 000000000..a18f16b57
--- /dev/null
+++ b/apps/www/components/comments/deferred.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import dynamic from "next/dynamic";
+
+export const DeferredComments = dynamic(
+ () => import("@/components/comments").then((module) => module.Comments),
+ {
+ ssr: false,
+ loading: () => null,
+ }
+);
diff --git a/apps/www/components/exercise/entry.tsx b/apps/www/components/exercise/entry.tsx
new file mode 100644
index 000000000..72295e9b0
--- /dev/null
+++ b/apps/www/components/exercise/entry.tsx
@@ -0,0 +1,141 @@
+import { importContentModule } from "@repo/contents/_lib/module";
+import type { ExerciseWithoutDefaults } from "@repo/contents/_types/exercises/shared";
+import { slugify } from "@repo/design-system/lib/utils";
+import type { Locale } from "next-intl";
+import { Suspense } from "react";
+import { QuestionAnalytics } from "@/components/exercise/item/analytics";
+import { ExerciseArticle } from "@/components/exercise/item/article";
+
+/** Loads the compiled question module for one exercise entry. */
+async function QuestionContent({
+ exerciseNumber,
+ locale,
+ setPath,
+}: {
+ exerciseNumber: number;
+ locale: Locale;
+ setPath: string;
+}) {
+ const question = await importContentModule(
+ `${setPath}/${exerciseNumber}/_question`,
+ locale
+ ).catch(() => null);
+ const Question = question?.default;
+
+ return Question ? : null;
+}
+
+/** Loads the compiled answer module for one exercise entry. */
+async function AnswerContent({
+ exerciseNumber,
+ locale,
+ setPath,
+}: {
+ exerciseNumber: number;
+ locale: Locale;
+ setPath: string;
+}) {
+ const answer = await importContentModule(
+ `${setPath}/${exerciseNumber}/_answer`,
+ locale
+ ).catch(() => null);
+ const Answer = answer?.default;
+
+ return Answer ? : null;
+}
+
+/** Builds the shared exercise article body used by learn and try-out routes. */
+function ExerciseEntryBody({
+ exercise,
+ id,
+ locale,
+ setPath,
+ srLabel,
+}: {
+ exercise: ExerciseWithoutDefaults;
+ id: string;
+ locale: Locale;
+ setPath: string;
+ srLabel: string;
+}) {
+ return (
+
+
+
+ }
+ choices={exercise.choices[locale]}
+ exerciseNumber={exercise.number}
+ id={id}
+ questionContent={
+
+ }
+ srLabel={srLabel}
+ />
+ );
+}
+
+/** Renders one exercise entry without analytics tracking. */
+export function ExerciseEntry({
+ exercise,
+ locale,
+ setPath,
+ srLabel,
+}: {
+ exercise: ExerciseWithoutDefaults;
+ locale: Locale;
+ setPath: string;
+ srLabel: string;
+}) {
+ const id = slugify(srLabel);
+
+ return (
+
+ );
+}
+
+/** Renders one exercise entry inside viewport analytics tracking. */
+export function ExerciseTrackedEntry({
+ exercise,
+ locale,
+ setPath,
+ srLabel,
+}: {
+ exercise: ExerciseWithoutDefaults;
+ locale: Locale;
+ setPath: string;
+ srLabel: string;
+}) {
+ const id = slugify(srLabel);
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/actions.tsx b/apps/www/components/exercise/item/action.tsx
similarity index 86%
rename from apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/actions.tsx
rename to apps/www/components/exercise/item/action.tsx
index 474df684b..74b59115b 100644
--- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/actions.tsx
+++ b/apps/www/components/exercise/item/action.tsx
@@ -8,22 +8,19 @@ import { useTranslations } from "next-intl";
import { useAttempt } from "@/lib/context/use-attempt";
import { useExercise } from "@/lib/context/use-exercise";
-interface Props {
+/** Toggles one exercise explanation and keeps the hash anchored to the active section. */
+export function ExerciseAnswerAction({
+ exerciseNumber,
+}: {
exerciseNumber: number;
-}
-
-export function ExerciseAnswerAction({ exerciseNumber }: Props) {
+}) {
const t = useTranslations("Exercises");
const toggleAnswer = useExercise((state) => state.toggleAnswer);
const showAnswer = useExercise(
(state) => state.visibleExplanations[exerciseNumber] ?? false
);
- const mustDisable = useAttempt(
- (state) =>
- state.attempt?.status === "in-progress" &&
- state.attempt?.mode === "simulation"
- );
+ const mustDisable = useAttempt((state) => state.isSimulationInProgress);
return (
diff --git a/apps/www/components/exercise/item/analytics.tsx b/apps/www/components/exercise/item/analytics.tsx
new file mode 100644
index 000000000..32de707e6
--- /dev/null
+++ b/apps/www/components/exercise/item/analytics.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useInterval } from "@mantine/hooks";
+import { useEffect, useEffectEvent, useRef, useState } from "react";
+import { useAttempt } from "@/lib/context/use-attempt";
+import { useExercise } from "@/lib/context/use-exercise";
+
+/** Tracks visible time for one rendered exercise while the active attempt is running. */
+export function QuestionAnalytics({
+ articleId,
+ exerciseNumber,
+}: {
+ articleId: string;
+ exerciseNumber: number;
+}) {
+ const isInputLocked = useAttempt((state) => state.isInputLocked);
+ const isAttemptInProgress = useAttempt((state) => state.isAttemptInProgress);
+ const [isActive, setIsActive] = useState(false);
+ const timeSpent = useExercise(
+ (state) => state.timeSpent[exerciseNumber] ?? 0
+ );
+ const timeCounterRef = useRef(timeSpent);
+ const setTimeSpent = useExercise((state) => state.setTimeSpent);
+ const hasActiveAttempt = isAttemptInProgress && !isInputLocked;
+
+ useEffect(() => {
+ timeCounterRef.current = timeSpent;
+ }, [timeSpent]);
+
+ useEffect(() => {
+ const target = document.getElementById(articleId);
+
+ if (!target) {
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ setIsActive(entry?.isIntersecting ?? false);
+ },
+ { threshold: 0.75 }
+ );
+
+ observer.observe(target);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [articleId]);
+
+ const handleTick = useEffectEvent(() => {
+ if (isActive && hasActiveAttempt) {
+ timeCounterRef.current += 1;
+ setTimeSpent(exerciseNumber, timeCounterRef.current);
+ }
+ });
+
+ const interval = useInterval(() => {
+ handleTick();
+ }, 1000);
+
+ useEffect(() => {
+ if (isActive && hasActiveAttempt) {
+ interval.start();
+ return () => {
+ interval.stop();
+ };
+ }
+
+ interval.stop();
+ }, [hasActiveAttempt, interval, isActive]);
+
+ return null;
+}
diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/answer.tsx b/apps/www/components/exercise/item/answer.tsx
similarity index 75%
rename from apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/answer.tsx
rename to apps/www/components/exercise/item/answer.tsx
index 67a573186..769a6859c 100644
--- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/answer.tsx
+++ b/apps/www/components/exercise/item/answer.tsx
@@ -3,38 +3,35 @@
import { Link05Icon } from "@hugeicons/core-free-icons";
import { HugeIcons } from "@repo/design-system/components/ui/huge-icons";
import { Separator } from "@repo/design-system/components/ui/separator";
-import { cn, slugify } from "@repo/design-system/lib/utils";
+import { slugify } from "@repo/design-system/lib/utils";
import { useTranslations } from "next-intl";
+import type { ReactNode } from "react";
import { useAttempt } from "@/lib/context/use-attempt";
import { useExercise } from "@/lib/context/use-exercise";
-interface Props {
- children: React.ReactNode;
+/** Renders the explanation section for one exercise when the active attempt allows it. */
+export function ExerciseAnswer({
+ children,
+ exerciseNumber,
+}: {
+ children: ReactNode;
exerciseNumber: number;
-}
-
-export function ExerciseAnswer({ children, exerciseNumber }: Props) {
+}) {
const t = useTranslations("Exercises");
const showAnswer = useExercise(
(state) => state.visibleExplanations[exerciseNumber] ?? false
);
- const mustHide = useAttempt(
- (state) =>
- state.attempt?.status === "in-progress" &&
- state.attempt?.mode === "simulation"
- );
+ const mustHide = useAttempt((state) => state.isSimulationInProgress);
+
+ if (mustHide || !showAnswer) {
+ return null;
+ }
const id = slugify(`${t("explanation")}-${exerciseNumber}`);
return (
-
+
diff --git a/apps/www/components/exercise/item/article.tsx b/apps/www/components/exercise/item/article.tsx
new file mode 100644
index 000000000..d120957aa
--- /dev/null
+++ b/apps/www/components/exercise/item/article.tsx
@@ -0,0 +1,61 @@
+import type { ExercisesChoices } from "@repo/contents/_types/exercises/choices";
+import type { Locale } from "next-intl";
+import type { ReactNode } from "react";
+import { ExerciseAnswerAction } from "@/components/exercise/item/action";
+import { ExerciseAnswer } from "@/components/exercise/item/answer";
+import { ExerciseChoices } from "@/components/exercise/item/choices";
+
+/** Renders one exercise article with question, answer controls, choices, and explanation. */
+export function ExerciseArticle({
+ answerContent,
+ choices,
+ exerciseNumber,
+ id,
+ questionContent,
+ srLabel,
+}: {
+ answerContent: ReactNode;
+ choices: ExercisesChoices[Locale];
+ exerciseNumber: number;
+ id: string;
+ questionContent: ReactNode;
+ srLabel: string;
+}) {
+ const articleId = `exercise-${id}`;
+
+ return (
+
+
+
+
+
+
+
+
+ {answerContent}
+
+
+ );
+}
diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/choices.tsx b/apps/www/components/exercise/item/choices.tsx
similarity index 84%
rename from apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/choices.tsx
rename to apps/www/components/exercise/item/choices.tsx
index 553cc59b1..9455da6c1 100644
--- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/choices.tsx
+++ b/apps/www/components/exercise/item/choices.tsx
@@ -16,37 +16,38 @@ import { toast } from "sonner";
import { useAttempt } from "@/lib/context/use-attempt";
import { useExercise } from "@/lib/context/use-exercise";
-interface Props {
+/** Renders the selectable choices for one exercise and submits answers to Convex. */
+export function ExerciseChoices({
+ choices,
+ exerciseNumber,
+ id,
+}: {
choices: ExercisesChoices[keyof ExercisesChoices];
exerciseNumber: number;
id: string;
-}
-
-export function ExerciseChoices({ id, exerciseNumber, choices }: Props) {
+}) {
const t = useTranslations("Exercises");
-
const [isPending, startTransition] = useTransition();
- const attempt = useAttempt((state) => state.attempt);
- const answers = useAttempt((state) => state.answers);
- const answerSheet = useAttempt((state) => state.answerSheet);
+ const attemptId = useAttempt((state) => state.attemptId);
+ const attemptMode = useAttempt((state) => state.attemptMode);
+ const attemptStatus = useAttempt((state) => state.attemptStatus);
+ const currentAnswer = useAttempt(
+ (state) => state.answerByExercise.get(exerciseNumber) ?? null
+ );
+ const answerSheetEntry = useAttempt(
+ (state) => state.answerSheetByExercise.get(exerciseNumber) ?? null
+ );
const isInputLocked = useAttempt((state) => state.isInputLocked);
const isReviewMode = useAttempt((state) => state.isReviewMode);
-
const submitAttempt = useMutation(api.exercises.mutations.submitAnswer);
const timeSpent = useExercise(
(state) => state.timeSpent[exerciseNumber] ?? 0
);
- const currentAnswer = answers.find(
- (a) => a.exerciseNumber === exerciseNumber
- );
- const answerSheetEntry = answerSheet.find(
- (entry) => entry.exerciseNumber === exerciseNumber
- );
-
- function handleSubmit({ index }: { index: number }) {
- if (!attempt) {
+ /** Submits one selected option for the current exercise. */
+ function handleSubmit(index: number) {
+ if (!attemptId) {
toast.info(t("attempt-not-found"), { position: "bottom-center" });
return;
}
@@ -58,8 +59,7 @@ export function ExerciseChoices({ id, exerciseNumber, choices }: Props) {
return;
}
- // If the attempt is not in progress, tell user to start new attempt
- if (attempt.status !== "in-progress") {
+ if (attemptStatus !== "in-progress") {
toast.info(t("attempt-not-in-progress"), { position: "bottom-center" });
return;
}
@@ -76,7 +76,7 @@ export function ExerciseChoices({ id, exerciseNumber, choices }: Props) {
startTransition(async () => {
try {
await submitAttempt({
- attemptId: attempt._id,
+ attemptId,
exerciseNumber,
questionId: answerSheetEntry.questionId,
selectedOptionId: option.optionKey,
@@ -139,9 +139,9 @@ export function ExerciseChoices({ id, exerciseNumber, choices }: Props) {
const shouldShowReviewState =
isReviewMode ||
- attempt?.mode === "practice" ||
- attempt?.status === "completed" ||
- attempt?.status === "expired";
+ attemptMode === "practice" ||
+ attemptStatus === "completed" ||
+ attemptStatus === "expired";
if (shouldShowReviewState) {
if (checked && currentAnswer && !currentAnswer.isCorrect) {
@@ -167,7 +167,7 @@ export function ExerciseChoices({ id, exerciseNumber, choices }: Props) {
disabled={isInputLocked || isPending}
onCheckedChange={(checked) => {
if (checked) {
- handleSubmit({ index });
+ handleSubmit(index);
}
}}
/>
diff --git a/apps/www/components/providers/app.tsx b/apps/www/components/providers/app.tsx
new file mode 100644
index 000000000..338746e8f
--- /dev/null
+++ b/apps/www/components/providers/app.tsx
@@ -0,0 +1,34 @@
+import { NuqsAdapter } from "nuqs/adapters/next/app";
+import type { ComponentProps, ReactNode } from "react";
+import { UserContextProvider } from "@/lib/context/use-user";
+import { ConvexProvider } from "./convex";
+import { ReactQueryProviders } from "./react-query";
+
+/**
+ * Mounts the app-wide client runtime providers for the localized app subtree.
+ *
+ * `NuqsAdapter` and `ReactQueryProviders` are global router/query config, while
+ * the Convex and current-user contexts are seeded once per request at the
+ * shared `(app)` boundary.
+ *
+ * @see https://github.com/47ng/nuqs#readme
+ * @see https://docs.convex.dev/client/nextjs/app-router/server-rendering
+ * @see https://labs.convex.dev/better-auth/migrations/migrate-to-0-10#pass-initial-token-to-convexbetterauthprovider
+ */
+export function AppProviders({
+ children,
+ initialToken,
+}: {
+ children: ReactNode;
+ initialToken?: ComponentProps["initialToken"];
+}) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/www/components/providers/index.tsx b/apps/www/components/providers/index.tsx
deleted file mode 100644
index 294526cb9..000000000
--- a/apps/www/components/providers/index.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { NuqsAdapter } from "nuqs/adapters/next/app";
-import type { ComponentProps, ReactNode } from "react";
-import { AiContextProvider } from "@/lib/context/use-ai";
-import { ContentViewsProvider } from "@/lib/context/use-content-views";
-import { PagefindProvider } from "@/lib/context/use-pagefind";
-import { SearchContextProvider } from "@/lib/context/use-search";
-import { UserContextProvider } from "@/lib/context/use-user";
-import { ConvexProvider } from "./convex";
-import { ReactQueryProviders } from "./react-query";
-
-/**
- * Mounts request-agnostic client providers at the root layout.
- *
- * This layer stays free of request-time auth so static routes, including the
- * contents subtree, keep their existing prerendering behavior.
- *
- * @see https://nextjs.org/docs/app/api-reference/file-conventions/layout
- * @see https://nextjs.org/docs/app/guides/streaming
- */
-export function AppProviders({ children }: { children: ReactNode }) {
- return (
-
-
-
-
-
- {children}
-
-
-
-
-
- );
-}
-
-/**
- * Mounts the authenticated Convex and current-user contexts for one route
- * subtree.
- *
- * Keeping this boundary route-scoped lets us seed auth only where SSR preloads
- * need it, without broadening dynamic rendering for static layouts.
- *
- * @see https://docs.convex.dev/client/nextjs/app-router/server-rendering
- * @see https://labs.convex.dev/better-auth/migrations/migrate-to-0-10#pass-initial-token-to-convexbetterauthprovider
- */
-export function ConvexAppProviders({
- children,
- initialToken,
-}: {
- children: ReactNode;
- initialToken?: ComponentProps["initialToken"];
-}) {
- return (
-
- {children}
-
- );
-}
diff --git a/apps/www/components/providers/shared.tsx b/apps/www/components/providers/shared.tsx
new file mode 100644
index 000000000..ccbe8adfd
--- /dev/null
+++ b/apps/www/components/providers/shared.tsx
@@ -0,0 +1,28 @@
+import type { ReactNode } from "react";
+import { AiContextProvider } from "@/lib/context/use-ai";
+import { ContentViewsProvider } from "@/lib/context/use-content-views";
+import { PagefindProvider } from "@/lib/context/use-pagefind";
+import { SearchContextProvider } from "@/lib/context/use-search";
+
+/**
+ * Mounts shared feature-state providers for the marketing, main, and tryout
+ * route groups.
+ *
+ * These stores are intentionally scoped below the app-wide runtime providers so
+ * `auth` and `school` stay free of unrelated search, Pagefind, AI, and content
+ * view state.
+ *
+ * @see https://nextjs.org/docs/app/api-reference/file-conventions/layout
+ * @see https://nextjs.org/docs/app/guides/streaming
+ */
+export function SharedProviders({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/www/components/shared/card-material.tsx b/apps/www/components/shared/card-material.tsx
index aca71569d..a1e0db061 100644
--- a/apps/www/components/shared/card-material.tsx
+++ b/apps/www/components/shared/card-material.tsx
@@ -5,6 +5,7 @@ import {
ArrowRight02Icon,
Link04Icon,
} from "@hugeicons/core-free-icons";
+import { useDisclosure } from "@mantine/hooks";
import type { MaterialList } from "@repo/contents/_types/subject/material";
import { Button } from "@repo/design-system/components/ui/button";
import {
@@ -21,14 +22,43 @@ import {
import { HugeIcons } from "@repo/design-system/components/ui/huge-icons";
import { cn, slugify } from "@repo/design-system/lib/utils";
import { Link } from "@repo/internationalization/src/navigation";
-import { useState } from "react";
+import { useLayoutEffect, useState } from "react";
interface Props {
material: MaterialList[number];
}
+/**
+ * Renders one expandable material card on the subject material index page.
+ *
+ * The card defaults to open, and resets back to open when Next.js hides the
+ * page through Cache Components state preservation. This keeps the material list
+ * behaving like a transient disclosure instead of persisting a collapsed state
+ * across route transitions.
+ *
+ * We also remount the Base UI collapsible root on hide. Its panel measures and
+ * caches `scrollHeight` for height transitions, and under preserved hidden DOM a
+ * stale zero-height measurement can survive the route transition even after
+ * `open` is reset to `true`.
+ *
+ * References:
+ * - Next.js preserving UI state with Cache Components:
+ * `apps/www/node_modules/next/dist/docs/01-app/02-guides/preserving-ui-state.md`
+ * - Base UI collapsible component docs:
+ * https://base-ui.com/react/components/collapsible
+ * - Installed Base UI panel measurement logic:
+ * `packages/design-system/node_modules/@base-ui/react/esm/collapsible/panel/useCollapsiblePanel.js`
+ */
export function CardMaterial({ material }: Props) {
- const [isOpen, setIsOpen] = useState(true);
+ const [isOpen, { open, set, toggle }] = useDisclosure(true);
+ const [panelKey, setPanelKey] = useState(0);
+
+ useLayoutEffect(() => {
+ return () => {
+ open();
+ setPanelKey((key) => key + 1);
+ };
+ }, [open]);
const id = slugify(material.title);
@@ -63,7 +93,7 @@ export function CardMaterial({ material }: Props) {
-
+
diff --git a/apps/www/components/shared/coming-soon.tsx b/apps/www/components/shared/coming-soon.tsx
index b887bc93a..77221fc88 100644
--- a/apps/www/components/shared/coming-soon.tsx
+++ b/apps/www/components/shared/coming-soon.tsx
@@ -11,7 +11,7 @@ export function ComingSoon({ className }: { className?: string }) {
const t = useTranslations("ComingSoon");
return (
-
+
{t("title")}
diff --git a/apps/www/components/shared/footer-content.tsx b/apps/www/components/shared/footer-content.tsx
index 5a85003f7..fe3f2dc69 100644
--- a/apps/www/components/shared/footer-content.tsx
+++ b/apps/www/components/shared/footer-content.tsx
@@ -13,7 +13,7 @@ export function FooterContent({
childrenClassName,
}: Props) {
return (
-