diff --git a/AGENTS.md b/AGENTS.md index ee493d0db..3a0b46b1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,8 @@ - Command for running script in a workspace: `pnpm --filter `. - Command for running tests: `pnpm test`. - The project uses shadcn for building UI, so stick to its conventions and design. -- In `apps/web` workspace, create a string first in `apps/web/config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings. +- In `apps/web` workspace, create a string first in `apps/web/ui-config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings. +- `apps/web/config/strings.ts` is for strings used by backend and `apps/web/ui-config/strings.ts` is for strings used by frontend. - For admin/dashboard empty states in `apps/web`, prefer reusing `apps/web/components/admin/empty-state.tsx` instead of creating one-off placeholder UIs. - When working with forms, always use refs to keep the current state of the form's data and use it to enable/disable the form submit button. - Check the name field inside each package's package.json to confirm the right name—skip the top-level one. diff --git a/apps/web/app/(with-contexts)/(with-layout)/checkout/__tests__/product.test.tsx b/apps/web/app/(with-contexts)/(with-layout)/checkout/__tests__/product.test.tsx new file mode 100644 index 000000000..4043712c8 --- /dev/null +++ b/apps/web/app/(with-contexts)/(with-layout)/checkout/__tests__/product.test.tsx @@ -0,0 +1,88 @@ +import { render, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; + +const payloads: any[] = []; +const mockExec = jest.fn(); + +jest.mock("@components/contexts", () => { + const React = require("react"); + + return { + AddressContext: React.createContext({ + backend: "http://localhost:3000", + frontend: "http://localhost:3000", + }), + ThemeContext: React.createContext({ + theme: { + id: "test", + name: "Test", + theme: {}, + }, + setTheme: jest.fn(), + }), + }; +}); + +jest.mock("@components/public/payments/checkout", () => ({ + __esModule: true, + default: ({ product }: { product: { name: string } }) => ( +
{product.name}
+ ), +})); + +jest.mock("@courselit/components-library", () => ({ + useToast: () => ({ + toast: jest.fn(), + }), +})); + +jest.mock("@courselit/page-primitives", () => ({ + Header1: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +jest.mock("@courselit/utils", () => ({ + FetchBuilder: jest.fn().mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn(function (payload) { + payloads.push(payload); + return this; + }), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })), +})); + +jest.mock("next/navigation", () => ({ + notFound: jest.fn(), + useSearchParams: () => new URLSearchParams("type=course&id=course-1"), +})); + +import ProductCheckout from "../product"; + +describe("ProductCheckout", () => { + beforeEach(() => { + payloads.length = 0; + jest.clearAllMocks(); + }); + + it("uses public course visibility and shows 404 when checkout course is unavailable", async () => { + const { notFound } = jest.requireMock("next/navigation"); + mockExec.mockResolvedValueOnce({ + course: null, + loginProviders: [], + }); + + render(); + + await waitFor(() => { + expect(payloads[0].query).toContain( + "course: getCourse(id: $id, asGuest: true)", + ); + }); + + await waitFor(() => { + expect(notFound).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/app/(with-contexts)/(with-layout)/checkout/page.tsx b/apps/web/app/(with-contexts)/(with-layout)/checkout/page.tsx index 10816ebf9..f62852405 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/checkout/page.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/checkout/page.tsx @@ -1,7 +1,7 @@ "use client"; import { ThemeContext } from "@components/contexts"; -import { Header1, Section } from "@courselit/page-primitives"; +import { Section } from "@courselit/page-primitives"; import { useContext } from "react"; import ProductCheckout from "./product"; @@ -10,9 +10,6 @@ export default function CheckoutPage() { return (
- - Checkout -
diff --git a/apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx b/apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx index 7c218198c..d24db66d3 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx @@ -7,14 +7,17 @@ import type { MembershipEntityType } from "@courselit/common-models"; import { useToast } from "@courselit/components-library"; import { FetchBuilder } from "@courselit/utils"; import { TOAST_TITLE_ERROR } from "@ui-config/strings"; -import { useSearchParams } from "next/navigation"; +import { notFound, useSearchParams } from "next/navigation"; import { useCallback, useContext, useEffect, useState } from "react"; import type { RuntimeLoginProvider } from "@/lib/login-providers"; +import { Header1 } from "@courselit/page-primitives"; +import { ThemeContext } from "@components/contexts"; const { MembershipEntityType } = Constants; export default function ProductCheckout() { const address = useContext(AddressContext); + const { theme } = useContext(ThemeContext); const searchParams = useSearchParams(); const entityId = searchParams?.get("id"); const entityType = searchParams?.get("type"); @@ -23,6 +26,7 @@ export default function ProductCheckout() { const [product, setProduct] = useState(null); const [paymentPlans, setPaymentPlans] = useState([]); const [includedProducts, setIncludedProducts] = useState([]); + const [productNotFound, setProductNotFound] = useState(false); const [loginProviders, setLoginProviders] = useState< RuntimeLoginProvider[] >([]); @@ -60,10 +64,7 @@ export default function ProductCheckout() { if (response.includedProducts) { setIncludedProducts([...response.includedProducts]); } else { - toast({ - title: TOAST_TITLE_ERROR, - description: "Course not found", - }); + setProductNotFound(true); } } catch (err: any) { toast({ @@ -77,7 +78,7 @@ export default function ProductCheckout() { const getProduct = useCallback(async () => { const query = ` query ($id: String!) { - course: getCourse(id: $id) { + course: getCourse(id: $id, asGuest: true) { courseId title slug @@ -126,10 +127,7 @@ export default function ProductCheckout() { }); setPaymentPlans([...response.course.paymentPlans]); } else { - toast({ - title: TOAST_TITLE_ERROR, - description: "Course not found", - }); + setProductNotFound(true); } setLoginProviders(response.loginProviders || []); } catch (err: any) { @@ -195,10 +193,7 @@ export default function ProductCheckout() { }); setPaymentPlans([...response.community.paymentPlans]); } else { - toast({ - title: TOAST_TITLE_ERROR, - description: "Community not found", - }); + setProductNotFound(true); } setLoginProviders(response.loginProviders || []); } catch (err: any) { @@ -226,18 +221,27 @@ export default function ProductCheckout() { } }, [paymentPlans, getIncludedProducts]); + if (productNotFound) { + notFound(); + } + if (!product) { return null; } return ( - + <> + + Checkout + + + ); } diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts index a3aa6fb53..77a9f579c 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts @@ -64,4 +64,8 @@ describe("course helpers formatCourse", () => { formatted.groups[0].lessons.map((lesson) => lesson.lessonId), ).toEqual(["lesson-3", "lesson-2"]); }); + + it("throws item_not_found instead of reading properties from a null course", () => { + expect(() => formatCourse(null as any)).toThrow("Item not found"); + }); }); diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx index 1e5d76831..39984cc31 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx @@ -25,6 +25,7 @@ jest.mock("@components/ui/sidebar", () => ({ SidebarMenuItem: ({ children }: any) => children, SidebarProvider: ({ children }: any) => children, SidebarTrigger: () => null, + useSidebar: () => ({ openMobile: false }), })); jest.mock("@components/ui/tooltip", () => ({ @@ -50,6 +51,15 @@ jest.mock("@courselit/components-library", () => ({ Image: () => null, })); +jest.mock("@courselit/page-blocks", () => ({ + TextRenderer: () => null, +})); + +jest.mock("@courselit/text-editor", () => ({ + Editor: () => null, + emptyDoc: {}, +})); + jest.mock("@courselit/icons", () => ({ CheckCircled: () => null, Circle: () => null, @@ -57,9 +67,12 @@ jest.mock("@courselit/icons", () => ({ })); jest.mock("lucide-react", () => ({ + BookOpen: () => null, ChevronRight: () => null, Clock: () => null, + Folder: () => null, LogOutIcon: () => null, + MessageSquare: () => null, })); jest.mock("@courselit/page-primitives", () => ({ @@ -725,4 +738,153 @@ describe("generateSideBarItems", () => { "Text 3", ]); }); + + it("shows dripped enrollment-gated lessons as unlocked for course admins", () => { + const course = { + title: "Course", + description: "", + featuredImage: undefined, + updatedAt: new Date().toISOString(), + creatorId: "creator-1", + slug: "test-course", + cost: 0, + courseId: "course-1", + tags: [], + paymentPlans: [], + defaultPaymentPlan: "", + firstLesson: "lesson-1", + isManager: true, + groups: [ + { + id: "group-1", + name: "Admin Section", + lessons: [ + { + lessonId: "lesson-1", + title: "Gated Lesson", + requiresEnrollment: true, + }, + ], + drip: { + status: true, + type: Constants.dripType[0].split("-")[0].toUpperCase(), + delayInMillis: 2, + }, + }, + ], + } as unknown as CourseFrontend; + + const profile = { + userId: "admin-1", + purchases: [], + } as unknown as Profile; + + const items = generateSideBarItems( + course, + profile, + "/course/test-course/course-1", + ); + + expect(items[1].badge).toBeUndefined(); + expect(items[1].items?.[0].icon).toBeUndefined(); + }); + + it("uses profile permissions to unlock admin sidebar when server viewer flag is absent", () => { + const course = { + title: "Course", + description: "", + featuredImage: undefined, + updatedAt: new Date().toISOString(), + creatorId: "creator-1", + slug: "test-course", + cost: 0, + courseId: "course-1", + tags: [], + paymentPlans: [], + defaultPaymentPlan: "", + firstLesson: "lesson-1", + isManager: false, + groups: [ + { + id: "group-1", + name: "Admin Section", + lessons: [ + { + lessonId: "lesson-1", + title: "Gated Lesson", + requiresEnrollment: true, + }, + ], + drip: { + status: true, + type: Constants.dripType[0].split("-")[0].toUpperCase(), + delayInMillis: 2, + }, + }, + ], + } as unknown as CourseFrontend; + + const profile = { + userId: "creator-1", + permissions: ["course:manage"], + purchases: [], + } as unknown as Profile; + + const items = generateSideBarItems( + course, + profile, + "/course/test-course/course-1", + ); + + expect(items[1].badge).toBeUndefined(); + expect(items[1].items?.[0].icon).toBeUndefined(); + }); + + it("shows the discussions sidebar item only when discussions are enabled", () => { + const course = { + title: "Course", + description: "", + featuredImage: undefined, + updatedAt: new Date().toISOString(), + creatorId: "creator-1", + slug: "test-course", + cost: 0, + courseId: "course-1", + tags: [], + paymentPlans: [], + defaultPaymentPlan: "", + firstLesson: "lesson-1", + groups: [], + discussions: false, + } as unknown as CourseFrontend; + + const profile = { + userId: "user-1", + purchases: [ + { + courseId: "course-1", + accessibleGroups: [], + }, + ], + } as unknown as Profile; + + expect( + generateSideBarItems( + course, + profile, + "/course/test-course/course-1", + ).some((item) => item.title === "Discussions"), + ).toBe(false); + + expect( + generateSideBarItems( + { ...course, discussions: true } as CourseFrontend, + profile, + "/course/test-course/course-1/discussions", + ).find((item) => item.title === "Discussions"), + ).toMatchObject({ + href: "/course/test-course/course-1/discussions", + isActive: true, + }); + }); }); diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/__tests__/page.test.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/__tests__/page.test.tsx new file mode 100644 index 000000000..29e7c8ba9 --- /dev/null +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/__tests__/page.test.tsx @@ -0,0 +1,196 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import CourseDiscussionsPage from "../page"; +import { AddressContext, ThemeContext } from "@components/contexts"; +import { FetchBuilder } from "@courselit/utils"; + +const mockExec = jest.fn(); +const payloads: Record[] = []; + +jest.mock("next/link", () => { + function MockNextLink({ + children, + href, + className, + }: { + children: React.ReactNode; + href: string; + className?: string; + }) { + return ( + + {children} + + ); + } + + return MockNextLink; +}); + +jest.mock("@components/contexts", () => { + const React = require("react"); + return { + AddressContext: React.createContext({ + backend: "", + frontend: "", + }), + ThemeContext: React.createContext({ + theme: {}, + }), + }; +}); + +jest.mock("../../helpers", () => ({ + getProduct: jest.fn().mockResolvedValue({ + title: "Course with discussions", + groups: [ + { + lessons: [ + { + lessonId: "lesson-1", + title: "Text lesson", + }, + { + lessonId: "lesson-2", + title: "Video lesson", + }, + ], + }, + ], + }), +})); + +jest.mock("@courselit/page-primitives", () => ({ + Button: ({ children, disabled, onClick }: any) => ( + + ), + Header1: ({ children }: any) =>

{children}

, + PageCard: ({ children }: any) =>
{children}
, + PageCardContent: ({ children }: any) =>
{children}
, + Text1: ({ children }: any) =>
{children}
, + Text2: ({ children }: any) => {children}, +})); + +jest.mock("lucide-react", () => ({ + BookOpen: () => null, + MessageSquare: () => null, +})); + +jest.mock("@courselit/utils", () => ({ + FetchBuilder: jest.fn().mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn(function (payload) { + payloads.push(payload); + return this; + }), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })), +})); + +function renderPage() { + const params = Promise.resolve({ + slug: "course-slug", + id: "course-1", + }) as any; + params.status = "fulfilled"; + params.value = { + slug: "course-slug", + id: "course-1", + }; + + return render( + + + Loading}> + + + + , + ); +} + +describe("CourseDiscussionsPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + payloads.length = 0; + mockExec.mockImplementation(() => { + const payload = payloads[payloads.length - 1]; + if (payload.variables?.cursor) { + return Promise.resolve({ + summaries: { + items: [ + { + entityId: "lesson-2", + totalCount: 1, + commentsCount: 1, + repliesCount: 0, + lastActivityAt: "2026-06-02T00:00:00.000Z", + }, + ], + nextCursor: undefined, + hasMore: false, + }, + }); + } + + return Promise.resolve({ + summaries: { + items: [ + { + entityId: "lesson-1", + totalCount: 3, + commentsCount: 1, + repliesCount: 2, + lastActivityAt: "2026-06-01T00:00:00.000Z", + }, + ], + nextCursor: "summary-cursor", + hasMore: true, + }, + }); + }); + }); + + it("lists lesson discussion summaries and links to the lesson with the panel open", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Text lesson")).toBeInTheDocument(); + }); + + expect(FetchBuilder).toHaveBeenCalled(); + expect(payloads[0].variables).toEqual({ + productId: "course-1", + cursor: undefined, + }); + expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByText("Text lesson").closest("a")).toHaveAttribute( + "href", + "/course/course-slug/course-1/lesson-1?discussion=open", + ); + }); + + it("paginates discussion summaries without showing zero-activity rows from the client", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Text lesson")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Load more")); + + await waitFor(() => { + expect(screen.getByText("Video lesson")).toBeInTheDocument(); + }); + expect(payloads[payloads.length - 1].variables).toEqual({ + productId: "course-1", + cursor: "summary-cursor", + }); + }); +}); diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/page.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/page.tsx new file mode 100644 index 000000000..b67ff8184 --- /dev/null +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useContext, useEffect, useMemo, useState, use } from "react"; +import { AddressContext, ThemeContext } from "@components/contexts"; +import { FetchBuilder } from "@courselit/utils"; +import { getProduct } from "../helpers"; +import { + Button, + Header1, + PageCard, + PageCardContent, + Text1, + Text2, +} from "@courselit/page-primitives"; +import { + COURSE_DISCUSSIONS_EMPTY, + COURSE_DISCUSSIONS_TITLE, + LOAD_MORE_TEXT, +} from "@ui-config/strings"; +import { BookOpen, MessageSquare } from "lucide-react"; +import NextLink from "next/link"; + +type DiscussionSummary = { + entityId: string; + totalCount: number; + commentsCount: number; + repliesCount: number; + lastActivityAt: string; +}; + +export default function CourseDiscussionsPage(props: { + params: Promise<{ slug: string; id: string }>; +}) { + const params = use(props.params); + const { slug, id } = params; + const address = useContext(AddressContext); + const { theme } = useContext(ThemeContext); + const [summaries, setSummaries] = useState([]); + const [courseTitle, setCourseTitle] = useState(""); + const [nextCursor, setNextCursor] = useState(); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(false); + const [lessonsById, setLessonsById] = useState>({}); + + useEffect(() => { + if (!id || !address?.backend) return; + getProduct(id, address.backend).then((product) => { + setCourseTitle(product.title || ""); + const lessons = Object.fromEntries( + product.groups + .flatMap((group) => group.lessons) + .map((lesson) => [lesson.lessonId, lesson.title]), + ); + setLessonsById(lessons); + }); + loadSummaries(); + }, [id, address?.backend]); + + const rows = useMemo( + () => + summaries.map((summary) => ({ + ...summary, + title: lessonsById[summary.entityId] || summary.entityId, + })), + [summaries, lessonsById], + ); + + async function graph(payload: Record) { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(payload) + .setIsGraphQLEndpoint(true) + .build(); + + return await fetch.exec(); + } + + async function loadSummaries(cursor?: string) { + setLoading(true); + try { + const response = await graph({ + query: ` + query GetProductDiscussionSummaries($productId: String!, $cursor: String) { + summaries: getProductDiscussionSummaries(productId: $productId, cursor: $cursor, limit: 20) { + items { + entityId + totalCount + commentsCount + repliesCount + lastActivityAt + } + nextCursor + hasMore + } + } + `, + variables: { + productId: id, + cursor, + }, + }); + + const page = response.summaries; + setSummaries((current) => + cursor ? [...current, ...page.items] : page.items, + ); + setNextCursor(page.nextCursor); + setHasMore(page.hasMore); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+
+ {courseTitle && ( + + + {courseTitle} + + )} + + {COURSE_DISCUSSIONS_TITLE} + +
+ {!loading && rows.length === 0 && ( + + {COURSE_DISCUSSIONS_EMPTY} + + )} +
+ {rows.map((summary) => ( + + + + + {summary.title} + + + + {summary.totalCount} + + + + + ))} +
+ {hasMore && ( + + )} +
+
+
+ ); +} diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts index 0e298e6b2..b4c0ce0a8 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts @@ -1,9 +1,11 @@ import { Course, Group, Lesson } from "@courselit/common-models"; import { FetchBuilder } from "@courselit/utils"; +import { responses } from "@/config/strings"; export type CourseFrontend = CourseWithoutGroups & { groups: GroupWithLessons[]; firstLesson: string; + isManager: boolean; }; export type GroupWithLessons = Group & { lessons: Lesson[] }; @@ -17,6 +19,7 @@ type CourseWithoutGroups = Pick< | "slug" | "cost" | "courseId" + | "discussions" | "tags" | "paymentPlans" | "defaultPaymentPlan" @@ -25,8 +28,9 @@ type CourseWithoutGroups = Pick< export const getProduct = async ( id: string, address: string, + requestHeaders?: Record, ): Promise => { - const fetch = new FetchBuilder() + const fetchBuilder = new FetchBuilder() .setUrl(`${address}/api/graph`) .setIsGraphQLEndpoint(true) .setPayload({ @@ -44,6 +48,8 @@ export const getProduct = async ( slug, cost, courseId, + discussions, + isManager, groups { id, name, @@ -82,15 +88,30 @@ export const getProduct = async ( `, variables: { id }, }) - .setIsGraphQLEndpoint(true) - .build(); + .setIsGraphQLEndpoint(true); + + if (requestHeaders) { + fetchBuilder.setHeaders(requestHeaders); + } + + const fetch = fetchBuilder.build(); const response = await fetch.exec(); return formatCourse(response.product); }; export function formatCourse( - post: Course & { lessons: Lesson[]; firstLesson: string; groups: Group[] }, + post: + | (Course & { + lessons: Lesson[]; + firstLesson: string; + groups: Group[]; + }) + | null, ): CourseFrontend { + if (!post) { + throw new Error(responses.item_not_found); + } + const groupsWithLessons = post.groups.map((group) => ({ ...group, lessons: post.lessons @@ -111,6 +132,10 @@ export function formatCourse( slug: post.slug, cost: post.cost, courseId: post.courseId, + discussions: post.discussions, + isManager: Boolean( + (post as Course & { isManager?: boolean }).isManager, + ), groups: groupsWithLessons as GroupWithLessons[], tags: post.tags, firstLesson: post.firstLesson, diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx index f9038a873..1ad0ee272 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { ReactNode, useContext } from "react"; +import { ReactNode, useContext, useEffect, useRef } from "react"; import constants from "@/config/constants"; import { formattedLocaleDate, @@ -11,12 +11,14 @@ import { CheckCircled, Circle, Lock } from "@courselit/icons"; import { BTN_EXIT_COURSE_TOOLTIP, SIDEBAR_TEXT_COURSE_ABOUT, + SIDEBAR_TEXT_COURSE_DISCUSSIONS, } from "@ui-config/strings"; import { Profile, Constants } from "@courselit/common-models"; import { ProfileContext, SiteInfoContext, ThemeContext, + AddressContext, } from "@components/contexts"; import { CourseFrontend, GroupWithLessons } from "./helpers"; import { @@ -32,12 +34,20 @@ import { SidebarMenuItem, SidebarProvider, SidebarTrigger, + useSidebar, } from "@components/ui/sidebar"; import { Image } from "@courselit/components-library"; import Link from "next/link"; -import { truncate } from "@courselit/utils"; +import { checkPermission, truncate } from "@courselit/utils"; import { Button } from "@components/ui/button"; -import { ChevronRight, Clock, LogOutIcon } from "lucide-react"; +import { + BookOpen, + ChevronRight, + Clock, + Folder, + LogOutIcon, + MessageSquare, +} from "lucide-react"; import { Tooltip, TooltipContent, @@ -49,9 +59,45 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@components/ui/collapsible"; -import { usePathname } from "next/navigation"; +import { usePathname, useSearchParams, useRouter } from "next/navigation"; import { Caption } from "@courselit/page-primitives"; import NextThemeSwitcher from "@components/admin/next-theme-switcher"; +import ProductDiscussionPanel from "@/components/public/product-discussions/panel"; + +function MobileStateSync() { + const { open, setOpenMobile, isMobile } = useSidebar(); + useEffect(() => { + setOpenMobile(open); + }, [open, isMobile, setOpenMobile]); + return null; +} + +function DiscussionSidebarSync({ + pathname, + router, + searchParams, +}: { + pathname: string | null; + router: ReturnType; + searchParams: ReturnType; +}) { + const { openMobile, isMobile } = useSidebar(); + const prevOpenMobile = useRef(openMobile); + useEffect(() => { + if (isMobile && prevOpenMobile.current && !openMobile) { + const params = new URLSearchParams(searchParams?.toString() || ""); + if (params.has("discussion")) { + params.delete("discussion"); + const newPath = params.toString() + ? `${pathname}?${params.toString()}` + : pathname; + router.push(newPath || ""); + } + } + prevOpenMobile.current = openMobile; + }, [openMobile, isMobile, searchParams, pathname, router]); + return null; +} export default function ProductPage({ product, @@ -61,6 +107,17 @@ export default function ProductPage({ children: React.ReactNode; }) { const { profile } = useContext(ProfileContext); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const isDiscussionOpen = searchParams?.get("discussion") === "open"; + const router = useRouter(); + const address = useContext(AddressContext); + + const pathSegments = pathname.split("/").filter(Boolean); + const isLessonPage = + pathSegments.length === 4 && pathSegments[0] === "course"; + const isActualLessonPage = + isLessonPage && pathSegments[3] !== "discussions"; if (!profile) { return null; @@ -78,9 +135,37 @@ export default function ProductPage({ > -
+
+ {isActualLessonPage && product.discussions && ( + + + + + + {SIDEBAR_TEXT_COURSE_DISCUSSIONS} + + + )} @@ -96,8 +181,64 @@ export default function ProductPage({
-
{children}
+
+ {children} +
+ {isActualLessonPage && product.discussions && ( + { + if (!open) { + const params = new URLSearchParams( + searchParams?.toString() || "", + ); + params.delete("discussion"); + const newPath = params.toString() + ? `${pathname}?${params.toString()}` + : pathname; + router.push(newPath || ""); + } + }} + style={ + { + "--sidebar-width": "20rem", + "--sidebar-width-mobile": "28rem", + } as React.CSSProperties + } + className="min-h-0 w-auto" + > + + + + { + const params = new URLSearchParams( + searchParams?.toString() || "", + ); + params.delete("discussion"); + const newPath = params.toString() + ? `${pathname}?${params.toString()}` + : pathname; + router.push(newPath || ""); + }} + /> + + + )} ); } @@ -162,23 +303,26 @@ export function AppSidebar({ >
- - - - {truncate( - item.title, - item.badge - ? 15 - : 26, - )} - - - {item.title} - - - + + + + + + {truncate( + item.title, + item.badge + ? 15 + : 26, + )} + + + {item.title} + + + + {item.badge?.text && ( @@ -231,8 +375,11 @@ export function AppSidebar({ href={ item.href } - className="w-full" + className="flex w-full min-w-0 items-center gap-2" > + { + item.leadingIcon + } + {item.icon} {item.title} @@ -305,6 +453,7 @@ export function AppSidebar({ interface SidebarItem { title: string; href: string; + icon?: ReactNode; badge?: { text: string; description: string; @@ -313,11 +462,33 @@ interface SidebarItem { items?: { title: string; href: string; + leadingIcon?: ReactNode; icon?: ReactNode; isActive?: boolean; }[]; } +function canManageCourseFromProfile(course: CourseFrontend, profile: Profile) { + if (!profile?.userId) { + return false; + } + + if ( + checkPermission(profile.permissions ?? [], [ + constants.permissions.manageAnyCourse, + ]) + ) { + return true; + } + + return ( + course.creatorId === profile.userId && + checkPermission(profile.permissions ?? [], [ + constants.permissions.manageCourse, + ]) + ); +} + export function generateSideBarItems( course: CourseFrontend, profile: Profile, @@ -325,14 +496,29 @@ export function generateSideBarItems( ): SidebarItem[] { if (!course) return []; + const isManager = + Boolean(course.isManager) || + canManageCourseFromProfile(course, profile); const items: SidebarItem[] = [ { title: SIDEBAR_TEXT_COURSE_ABOUT, href: `/course/${course.slug}/${course.courseId}`, + icon: , isActive: pathname === `/course/${course.slug}/${course.courseId}`, }, ]; + if (course.discussions) { + items.push({ + title: SIDEBAR_TEXT_COURSE_DISCUSSIONS, + href: `/course/${course.slug}/${course.courseId}/discussions`, + icon: , + isActive: + pathname === + `/course/${course.slug}/${course.courseId}/discussions`, + }); + } + let lastGroupDripDateInMillis = getRelativeDripAnchorMillis( course, profile, @@ -348,6 +534,7 @@ export function generateSideBarItems( group, profile, lastGroupDripDateInMillis, + isManager, }), items: [], }; @@ -359,28 +546,34 @@ export function generateSideBarItems( if (isActive) { groupItem.isActive = true; } + let lessonStatusIcon: ReactNode; + if (!isManager) { + if (!profile?.userId) { + lessonStatusIcon = lesson.requiresEnrollment ? ( + + ) : undefined; + } else if (isEnrolled(course.courseId, profile)) { + lessonStatusIcon = isLessonCompleted({ + courseId: course.courseId, + lessonId: lesson.lessonId, + profile, + }) ? ( + + ) : ( + + ); + } else { + lessonStatusIcon = lesson.requiresEnrollment ? ( + + ) : undefined; + } + } groupItem.items!.push({ title: lesson.title, href: `/course/${course.slug}/${course.courseId}/${lesson.lessonId}`, isActive, - icon: - profile && profile.userId ? ( - isEnrolled(course.courseId, profile) ? ( - isLessonCompleted({ - courseId: course.courseId, - lessonId: lesson.lessonId, - profile, - }) ? ( - - ) : ( - - ) - ) : lesson.requiresEnrollment ? ( - - ) : undefined - ) : lesson.requiresEnrollment ? ( - - ) : undefined, + leadingIcon: , + icon: lessonStatusIcon, }); } @@ -393,6 +586,7 @@ export function generateSideBarItems( group.drip.status && group.drip.type === Constants.dripType[0].split("-")[0].toUpperCase() && + !isManager && !isGroupAccessibleToUser(course, profile as Profile, group) ) { lastGroupDripDateInMillis += group?.drip?.delayInMillis ?? 0; @@ -407,12 +601,18 @@ function getDripLabel({ group, profile, lastGroupDripDateInMillis, + isManager, }: { course: CourseFrontend; group: GroupWithLessons; profile: Profile; lastGroupDripDateInMillis: number; + isManager: boolean; }): { text: string; description: string } | undefined { + if (isManager) { + return undefined; + } + if ( group.drip?.status && isGroupAccessibleToUser(course, profile as Profile, group) diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout.tsx index e5bc545ea..bd0f03649 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout.tsx @@ -12,6 +12,7 @@ export async function generateMetadata( parent: ResolvingMetadata, ): Promise { const params = await props.params; + const requestHeaders = await headers(); const address = await getAddressFromHeaders(headers); const siteInfo = await getFullSiteSetup(address); @@ -35,13 +36,23 @@ export async function generateMetadata( query, variables: { id: params.id }, }) + .setHeaders({ + cookie: requestHeaders.get("cookie") ?? "", + }) .setIsGraphQLEndpoint(true) .build(); const response = await fetch.exec(); const course = response.course; + const parentTitle = (await parent)?.title?.absolute; + + if (!course?.title) { + notFound(); + } return { - title: `${course?.title} | ${(await parent)?.title?.absolute}`, + title: parentTitle + ? `${course.title} | ${parentTitle}` + : course.title, }; } catch (error) { notFound(); @@ -57,8 +68,17 @@ export default async function Layout(props: { const { children } = props; const { id } = params; + const requestHeaders = await headers(); const address = await getAddressFromHeaders(headers); - const product = await getProduct(id, address); + let product; + + try { + product = await getProduct(id, address, { + cookie: requestHeaders.get("cookie") ?? "", + }); + } catch (error) { + notFound(); + } return {children}; } diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx index f5b217340..75424f139 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx @@ -77,88 +77,95 @@ export default function ProductPage(props: { : TextEditorEmptyDoc; return ( -
- - {product.title} - - {progress?.certificateId && ( - - - - )} - {!isEnrolled(product.courseId, profile as Profile) && - checkPermission(profile.permissions ?? [], [ - permissions.enrollInCourse, - ]) && ( -
-
-
- {getSymbolFromCurrency( - siteInfo.currencyISOCode ?? "", - )} - {product.cost} - - {product.costType ?? ""} - -
- - - -
-
- )} - {product.featuredImage && ( -
-
- {product.featuredImage.caption} -
-
- )} -
-
- +
+
+ - - - -
-
- {isEnrolled(product.courseId, profile as Profile) && - product.firstLesson && ( -
+ > + {product.title} + + {progress?.certificateId && ( - + )} + {!isEnrolled(product.courseId, profile as Profile) && + checkPermission(profile.permissions ?? [], [ + permissions.enrollInCourse, + ]) && ( +
+
+
+ {getSymbolFromCurrency( + siteInfo.currencyISOCode ?? "", + )} + {product.cost} + + {product.costType ?? ""} + +
+ + + +
+
+ )} + {product.featuredImage && ( +
+
+ {product.featuredImage.caption} +
+
+ )} +
+
+ + + + +
- )} + {isEnrolled(product.courseId, profile as Profile) && + product.firstLesson && ( +
+ + + +
+ )} +
+
); } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx index a07620738..7830420a1 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx @@ -4,6 +4,7 @@ import DashboardContent from "@components/admin/dashboard-content"; import { AddressContext, ProfileContext } from "@components/contexts"; import { COMMUNITY_HEADER, + COMMUNITY_REPORTS_HEADER, COMMUNITY_SETTINGS, DANGER_ZONE_HEADER, MEDIA_SELECTOR_REMOVE_BTN_CAPTION, @@ -545,7 +546,7 @@ export default function Page(props: { >
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/__tests__/page.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/__tests__/page.test.tsx index 432bae0ef..7168e8232 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/__tests__/page.test.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/__tests__/page.test.tsx @@ -90,6 +90,9 @@ describe("Notifications Page", () => { expect( screen.getByText("Community Membership Granted"), ).toBeInTheDocument(); + expect( + screen.getByText("Course Discussion Activity"), + ).toBeInTheDocument(); expect( screen.queryByText( "No notification preferences are available for your account.", diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/__tests__/product-discussions.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/__tests__/product-discussions.test.tsx new file mode 100644 index 000000000..35de8d296 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/__tests__/product-discussions.test.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ProductDiscussions from "../product-discussions"; + +const mockToast = jest.fn(); +const mockExec = jest.fn(); +const mockSetPayload = jest.fn(); + +jest.mock("@courselit/components-library", () => ({ + useToast: () => ({ + toast: mockToast, + }), +})); + +jest.mock("@/hooks/use-graphql-fetch", () => ({ + useGraphQLFetch: () => ({ + setPayload: mockSetPayload.mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + }), +})); + +jest.mock("@/components/ui/label", () => ({ + Label: ({ children }: { children: React.ReactNode }) => ( + + ), +})); + +jest.mock("@/components/ui/separator", () => ({ + Separator: () =>
, +})); + +jest.mock("@/components/ui/switch", () => ({ + Switch: ({ checked, disabled, onCheckedChange }: any) => ( + onCheckedChange(!checked)} + /> + ), +})); + +describe("ProductDiscussions", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockExec.mockResolvedValue({ + updateCourse: { + courseId: "course-1", + discussions: true, + }, + }); + }); + + it("renders and saves the discussion toggle for course products", async () => { + render( + , + ); + + fireEvent.click(screen.getByLabelText("discussions-switch")); + + await waitFor(() => { + expect(mockSetPayload).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + courseId: "course-1", + discussions: true, + }, + }), + ); + }); + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Success", + }), + ); + }); + + it("does not render for non-course products", () => { + render( + , + ); + + expect( + screen.queryByLabelText("discussions-switch"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/product-discussions.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/product-discussions.tsx new file mode 100644 index 000000000..86698133d --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/product-discussions.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; +import { useToast } from "@courselit/components-library"; +import { + APP_MESSAGE_COURSE_SAVED, + COURSE_DISCUSSIONS_DESCRIPTION, + COURSE_DISCUSSIONS_MANAGE_DISCUSSION, + COURSE_DISCUSSIONS_TITLE, + TOAST_TITLE_ERROR, + TOAST_TITLE_SUCCESS, +} from "@ui-config/strings"; +import { useGraphQLFetch } from "@/hooks/use-graphql-fetch"; +import { COURSE_TYPE_COURSE } from "@ui-config/constants"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; + +const MUTATION_UPDATE_DISCUSSIONS = ` + mutation UpdateDiscussions($courseId: String!, $discussions: Boolean!) { + updateCourse(courseData: { id: $courseId, discussions: $discussions }) { + courseId + discussions + } + } +`; + +interface ProductDiscussionsProps { + product: any; +} + +export default function ProductDiscussions({ + product, +}: ProductDiscussionsProps) { + const { toast } = useToast(); + const fetch = useGraphQLFetch(); + const [loading, setLoading] = useState(false); + const [discussions, setDiscussions] = useState( + product?.discussions || false, + ); + + const handleDiscussionsChange = async () => { + const newValue = !discussions; + const previousValue = discussions; + setDiscussions(newValue); + + if (!product?.courseId) return; + + try { + setLoading(true); + const response = await fetch + .setPayload({ + query: MUTATION_UPDATE_DISCUSSIONS, + variables: { + courseId: product.courseId, + discussions: newValue, + }, + }) + .build() + .exec(); + + if (response?.updateCourse) { + toast({ + title: TOAST_TITLE_SUCCESS, + description: APP_MESSAGE_COURSE_SAVED, + }); + } + } catch (err: any) { + setDiscussions(previousValue); + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + if (product?.type?.toLowerCase() !== COURSE_TYPE_COURSE) { + return null; + } + + return ( +
+
+
+ +

+ {COURSE_DISCUSSIONS_DESCRIPTION} +

+
+
+ +
+
+ {discussions && ( +
+ +
+ )} + +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/page.tsx new file mode 100644 index 000000000..8e6bd9284 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/page.tsx @@ -0,0 +1,1271 @@ +"use client"; + +import { useContext, useEffect, useState, useMemo, useRef } from "react"; +import type React from "react"; +import { useParams } from "next/navigation"; +import DashboardContent from "@components/admin/dashboard-content"; +import useProduct from "@/hooks/use-product"; +import { AddressContext, ThemeContext } from "@components/contexts"; +import { FetchBuilder, truncate } from "@courselit/utils"; +import { TextRenderer } from "@courselit/page-blocks"; +import { Editor, emptyDoc as TextEditorEmptyDoc } from "@courselit/text-editor"; +import type { TextEditorContent } from "@courselit/common-models"; +import { UIConstants } from "@courselit/common-models"; +import { isTextEditorNonEmpty } from "@ui-lib/utils"; +import { + COURSE_DISCUSSIONS_ADMIN_BROWSE_TARGET, + COURSE_DISCUSSIONS_ADMIN_COLLAPSE_REPLIES, + COURSE_DISCUSSIONS_ADMIN_EXPAND_REPLIES, + COURSE_DISCUSSIONS_ADMIN_NEXT, + COURSE_DISCUSSIONS_ADMIN_NO_CONTENT, + COURSE_DISCUSSIONS_ADMIN_PAGE_OF, + COURSE_DISCUSSIONS_ADMIN_POST_REPLY, + COURSE_DISCUSSIONS_ADMIN_PREVIOUS, + COURSE_DISCUSSIONS_ADMIN_REPLY, + COURSE_DISCUSSIONS_ADMIN_REPLY_COUNT, + COURSE_DISCUSSIONS_DELETED, + COURSE_DISCUSSIONS_REPORTED, + COURSE_DISCUSSIONS_TITLE, + LOAD_MORE_TEXT, + MANAGE_COURSES_PAGE_HEADING, + TOAST_TITLE_ERROR, + TOAST_TITLE_SUCCESS, +} from "@ui-config/strings"; +import { ReportReasonDialog } from "./report-reason-dialog"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { useToast } from "@courselit/components-library"; +import { + ChevronDown, + ChevronRight, + ChevronLeft, + Flag, + MessageSquare, + Reply, + X, + ThumbsUp, +} from "lucide-react"; + +const { permissions } = UIConstants; + +const PAGE_SIZE = 20; + +type DiscussionContent = { + commentId: string; + replyId?: string; + content: TextEditorContent | null; + deleted: boolean; + userId: string; + createdAt: string; + replies?: DiscussionContent[]; + replyCount?: number; + replyNextCursor?: string; + hasMoreReplies?: boolean; + likesCount?: number; + hasLiked?: boolean; + user?: { + name?: string; + email?: string; + avatar?: { + file?: string; + thumbnail?: string; + }; + }; +}; + +const COMMENT_FIELDS = ` + commentId + userId + content + deleted + createdAt + replyCount + replyNextCursor + hasMoreReplies + likesCount + hasLiked + user { + name + email + avatar { + file + thumbnail + } + } + replies { + commentId + replyId + userId + content + deleted + createdAt + likesCount + hasLiked + user { + name + email + avatar { + file + thumbnail + } + } + } +`; + +const REPLY_FIELDS = ` + commentId + replyId + userId + content + deleted + createdAt + likesCount + hasLiked + user { + name + email + avatar { + file + thumbnail + } + } +`; + +export default function DiscussionDetailPage() { + const params = useParams(); + const productId = params?.id as string; + const entityId = params?.entityId as string; + const { product } = useProduct(productId); + const address = useContext(AddressContext); + const { theme } = useContext(ThemeContext); + const { toast } = useToast(); + + // Pagination state + const [allComments, setAllComments] = useState([]); + const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]); + const [currentPage, setCurrentPage] = useState(0); + const [totalPages, setTotalPages] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(false); + const [loadingRepliesFor, setLoadingRepliesFor] = useState(); + + // Reply state + const [posting, setPosting] = useState(false); + const [replyingTo, setReplyingTo] = useState<{ + commentId: string; + parentReplyId?: string; + }>(); + const [replyContent, setReplyContent] = useState( + TextEditorEmptyDoc as TextEditorContent, + ); + const [replyRefresh, setReplyRefresh] = useState(0); + + const [reportDialogOpen, setReportDialogOpen] = useState(false); + const [pendingReport, setPendingReport] = useState<{ + contentType: "COMMENT" | "REPLY"; + contentId: string; + } | null>(null); + + const [highlightedId, setHighlightedId] = useState(""); + const [targetCommentId, setTargetCommentId] = useState(""); + const [targetReplyId, setTargetReplyId] = useState(""); + + useEffect(() => { + if (typeof window === "undefined") return; + + function updateHash() { + const hash = window.location.hash.replace("#", ""); + setHighlightedId(hash); + if (hash.includes("__")) { + const parts = hash.split("__"); + if (parts[0] === "discussion-reply" && parts[1] && parts[2]) { + setTargetCommentId(parts[1]); + setTargetReplyId(parts[2]); + } else { + setTargetCommentId(""); + setTargetReplyId(""); + } + } else if (hash.startsWith("discussion-comment-")) { + setTargetCommentId(hash.replace("discussion-comment-", "")); + setTargetReplyId(""); + } else { + setTargetCommentId(""); + setTargetReplyId(""); + } + } + + updateHash(); + window.addEventListener("hashchange", updateHash); + return () => window.removeEventListener("hashchange", updateHash); + }, [allComments]); + + useEffect(() => { + if (!highlightedId || allComments.length === 0) { + return; + } + + // Use a polling interval to find the element, in case replies are being auto-fetched asynchronously + let attempts = 0; + const interval = window.setInterval(() => { + let domId = highlightedId; + if (highlightedId.includes("__")) { + const parts = highlightedId.split("__"); + if (parts[0] === "discussion-reply" && parts[2]) { + domId = `discussion-reply-${parts[2]}`; + } + } + const element = document.getElementById(domId); + attempts++; + if (element) { + element.scrollIntoView({ block: "center", behavior: "smooth" }); + window.clearInterval(interval); + } else if (attempts > 30) { + // Limit retry to 3 seconds + window.clearInterval(interval); + } + }, 100); + + const clearHighlightTimeout = window.setTimeout(() => { + setHighlightedId(""); + setTargetCommentId(""); + setTargetReplyId(""); + }, 4000); + + return () => { + window.clearInterval(interval); + window.clearTimeout(clearHighlightTimeout); + }; + }, [highlightedId, allComments]); + + const lessonsById = useMemo(() => { + if (!product?.lessons) return {}; + return Object.fromEntries( + product.lessons.map((lesson: any) => [ + lesson.lessonId, + lesson.title, + ]), + ); + }, [product]); + + const targetTitle = lessonsById[entityId] || entityId; + + const breadcrumbs = [ + { label: MANAGE_COURSES_PAGE_HEADING, href: "/dashboard/products" }, + { + label: product ? truncate(product.title || "", 20) || "..." : "...", + href: `/dashboard/product/${productId}`, + }, + { + label: COURSE_DISCUSSIONS_TITLE, + href: `/dashboard/product/${productId}/manage/discussions`, + }, + { label: targetTitle, href: "#" }, + ]; + + useEffect(() => { + if (productId && entityId && address?.backend) { + loadPage(0); + } + }, [productId, entityId, address?.backend]); + + async function graph(payload: Record) { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(payload) + .setIsGraphQLEndpoint(true) + .build(); + + return await fetch.exec(); + } + + async function loadPage(pageIndex: number) { + setLoading(true); + const cursor = cursors[pageIndex]; + try { + const response = await graph({ + query: ` + query GetAdminProductDiscussionComments($productId: String!, $entityId: String!, $cursor: String) { + comments: getProductDiscussionComments(productId: $productId, entityType: LESSON, entityId: $entityId, admin: true, cursor: $cursor, limit: ${PAGE_SIZE}, replyPreviewLimit: 3) { + items { ${COMMENT_FIELDS} } + nextCursor + hasMore + } + } + `, + variables: { productId, entityId, cursor }, + }); + const page = response.comments; + setAllComments(page.items); + setCurrentPage(pageIndex); + setHasMore(page.hasMore); + + setCursors((prev) => { + const updated = [...prev]; + if (page.hasMore && !updated[pageIndex + 1]) { + updated[pageIndex + 1] = page.nextCursor; + } + return updated; + }); + + setTotalPages((prev) => + page.hasMore ? Math.max(prev, pageIndex + 2) : pageIndex + 1, + ); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setLoading(false); + } + } + + function openReportDialog( + contentType: "COMMENT" | "REPLY", + contentId: string, + ) { + setPendingReport({ contentType, contentId }); + setReportDialogOpen(true); + } + + async function handleReportSubmit(reason: string) { + setReportDialogOpen(false); + if (!pendingReport) return; + const { contentType, contentId } = pendingReport; + setPendingReport(null); + + try { + await graph({ + query: ` + mutation CreateProductDiscussionReport($productId: String!, $entityId: String!, $contentType: ProductDiscussionContentType!, $contentId: String!, $reason: String!) { + createProductDiscussionReport(productId: $productId, entityType: LESSON, entityId: $entityId, contentType: $contentType, contentId: $contentId, reason: $reason) { + reportId + } + } + `, + variables: { + productId, + entityId, + contentType, + contentId, + reason, + }, + }); + toast({ + title: TOAST_TITLE_SUCCESS, + description: COURSE_DISCUSSIONS_REPORTED, + }); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + } + + async function postReply() { + if (!replyingTo || !isTextEditorNonEmpty(replyContent)) return; + setPosting(true); + try { + const response = await graph({ + query: ` + mutation CreateProductDiscussionReply($productId: String!, $entityId: String!, $commentId: String!, $parentReplyId: String, $content: JSONObject!) { + reply: createProductDiscussionReply(productId: $productId, entityType: LESSON, entityId: $entityId, commentId: $commentId, parentReplyId: $parentReplyId, content: $content) { + ${REPLY_FIELDS} + } + } + `, + variables: { + productId, + entityId, + commentId: replyingTo.commentId, + parentReplyId: replyingTo.parentReplyId, + content: replyContent, + }, + }); + setAllComments((current) => + current.map((comment) => + comment.commentId === replyingTo.commentId + ? { + ...comment, + replyCount: (comment.replyCount ?? 0) + 1, + replies: [ + ...(comment.replies || []), + response.reply, + ], + hasMoreReplies: false, + } + : comment, + ), + ); + setReplyingTo(undefined); + setReplyContent(TextEditorEmptyDoc as TextEditorContent); + setReplyRefresh((v) => v + 1); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setPosting(false); + } + } + + async function loadMoreReplies(comment: DiscussionContent) { + if (!comment.replyNextCursor) { + return; + } + setLoadingRepliesFor(comment.commentId); + try { + const response = await graph({ + query: ` + query GetAdminProductDiscussionReplies($commentId: String!, $cursor: String) { + replies: getProductDiscussionReplies(commentId: $commentId, admin: true, cursor: $cursor, limit: 20) { + items { ${REPLY_FIELDS} } + nextCursor + hasMore + } + } + `, + variables: { + commentId: comment.commentId, + cursor: comment.replyNextCursor, + }, + }); + const page = response.replies; + setAllComments((current) => + current.map((item) => { + if (item.commentId !== comment.commentId) { + return item; + } + const existingReplyIds = new Set( + (item.replies || []).map((reply) => reply.replyId), + ); + return { + ...item, + replies: [ + ...(item.replies || []), + ...page.items.filter( + (reply: DiscussionContent) => + !existingReplyIds.has(reply.replyId), + ), + ], + replyNextCursor: page.nextCursor, + hasMoreReplies: page.hasMore, + }; + }), + ); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setLoadingRepliesFor(undefined); + } + } + + async function toggleLike( + contentType: "COMMENT" | "REPLY", + contentId: string, + liked: boolean, + ) { + try { + const response = await graph({ + query: ` + mutation ToggleProductDiscussionLike($productId: String!, $entityType: ProductDiscussionEntityType!, $entityId: String!, $contentType: ProductDiscussionContentType!, $contentId: String!, $liked: Boolean!) { + like: toggleProductDiscussionLike(productId: $productId, entityType: $entityType, entityId: $entityId, contentType: $contentType, contentId: $contentId, liked: $liked) { + contentType + contentId + likesCount + hasLiked + } + } + `, + variables: { + productId, + entityType: "LESSON", + entityId, + contentType, + contentId, + liked, + }, + }); + updateContent(contentType, contentId, { + likesCount: response.like.likesCount, + hasLiked: response.like.hasLiked, + }); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + } + + function updateContent( + contentType: "COMMENT" | "REPLY", + contentId: string, + patch: Partial, + ) { + setAllComments((current) => + current.map((comment) => { + if ( + contentType === "COMMENT" && + comment.commentId === contentId + ) { + return { ...comment, ...patch }; + } + + return { + ...comment, + replies: (comment.replies || []).map((reply) => + reply.replyId === contentId + ? { ...reply, ...patch } + : reply, + ), + }; + }), + ); + } + + return ( + <> + +
+ {/* Page header */} +
+

+ {targetTitle} +

+

+ {COURSE_DISCUSSIONS_ADMIN_BROWSE_TARGET} +

+
+ + {/* Comment cards */} +
+ {loading && allComments.length === 0 && ( +
+ Loading… +
+ )} + + {!loading && allComments.length === 0 && ( +
+ No comments yet. +
+ )} + + {allComments.map((comment, idx) => ( + + openReportDialog( + "COMMENT", + comment.commentId, + ) + } + onReportReply={(replyId) => + openReportDialog("REPLY", replyId) + } + onLoadMoreReplies={() => + loadMoreReplies(comment) + } + onStartReply={(parentReplyId) => + setReplyingTo({ + commentId: comment.commentId, + parentReplyId, + }) + } + onCancelReply={() => setReplyingTo(undefined)} + onReplyChange={setReplyContent} + onPostReply={postReply} + targetCommentId={targetCommentId} + targetReplyId={targetReplyId} + /> + ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + + {currentPage + 1}{" "} + {COURSE_DISCUSSIONS_ADMIN_PAGE_OF} {totalPages} + {hasMore ? "+" : ""} + + + +
+ )} +
+
+ + { + setReportDialogOpen(false); + setPendingReport(null); + }} + onSubmit={handleReportSubmit} + /> + + ); +} + +// ─── CommentCard ────────────────────────────────────────────────────────────── + +function CommentCard({ + comment, + index, + address, + theme, + loadingRepliesFor, + replyingTo, + replyContent, + replyRefresh, + posting, + onLike, + onReport, + onReportReply, + onLoadMoreReplies, + onStartReply, + onCancelReply, + onReplyChange, + onPostReply, + targetCommentId, + targetReplyId, +}: { + comment: DiscussionContent; + index: number; + address: any; + theme: any; + loadingRepliesFor?: string; + replyingTo?: { commentId: string; parentReplyId?: string }; + replyContent: TextEditorContent; + replyRefresh: number; + posting: boolean; + onLike: ( + contentType: "COMMENT" | "REPLY", + contentId: string, + liked: boolean, + ) => void; + onReport: () => void; + onReportReply: (replyId: string) => void; + onLoadMoreReplies: () => void; + onStartReply: (parentReplyId?: string) => void; + onCancelReply: () => void; + onReplyChange: (content: TextEditorContent) => void; + onPostReply: () => void; + targetCommentId?: string; + targetReplyId?: string; +}) { + const isTargetComment = + comment.commentId === targetCommentId && !targetReplyId; + + const [open, setOpen] = useState(comment.commentId === targetCommentId); + const [repliesOpen, setRepliesOpen] = useState( + !!targetReplyId && comment.commentId === targetCommentId, + ); + + useEffect(() => { + if (comment.commentId === targetCommentId) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setOpen(true); + if (targetReplyId) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setRepliesOpen(true); + } + } + }, [targetCommentId, targetReplyId, comment.commentId]); + + // Auto-fetch replies if targetReplyId is set but the reply is not in the list + useEffect(() => { + if (comment.commentId === targetCommentId && targetReplyId) { + const hasReply = comment.replies?.some( + (r) => r.replyId === targetReplyId, + ); + if (!hasReply && comment.hasMoreReplies && !loadingRepliesFor) { + onLoadMoreReplies(); + } + } + }, [ + comment.commentId, + targetCommentId, + targetReplyId, + comment.replies, + comment.hasMoreReplies, + loadingRepliesFor, + onLoadMoreReplies, + ]); + + const isReplyingToThis = + replyingTo?.commentId === comment.commentId && + !replyingTo?.parentReplyId; + + const displayName = + comment.user?.name || comment.user?.email || comment.userId; + const initials = getInitials(displayName); + const displayDate = new Date(comment.createdAt).toLocaleDateString(); + const replyCount = comment.replyCount ?? comment.replies?.length ?? 0; + + const preview = getTextPreview(comment.content); + + // Auto-expand replies panel when a reply is submitted + const prevReplyCount = useRef(replyCount); + useEffect(() => { + if (replyCount > prevReplyCount.current) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setRepliesOpen(true); + } + prevReplyCount.current = replyCount; + }, [replyCount]); + + return ( + + + {/* ── Collapsed header (always visible) ── */} + + +
+ {/* Avatar */} + + {comment.user?.avatar && + (comment.user.avatar.thumbnail || + comment.user.avatar.file) && ( + + )} + + {initials} + + + + {/* Meta */} +
+
+ + {displayName} + + + {displayDate} + + {comment.deleted && ( + + {COURSE_DISCUSSIONS_DELETED} + + )} + {replyCount > 0 && ( + + + {replyCount}{" "} + { + COURSE_DISCUSSIONS_ADMIN_REPLY_COUNT + } + + )} +
+ {/* One-line preview when collapsed */} + {!open && ( +

+ {comment.deleted + ? COURSE_DISCUSSIONS_ADMIN_NO_CONTENT + : preview} +

+ )} +
+ + {/* Actions */} +
+ {replyCount > 0 && ( + + + {replyCount} + + )} + +
+
+
+
+ + {/* ── Expanded content ── */} + + + {/* Divider */} +
+ + {/* Full content */} +
+
+ {comment.content ? ( + + ) : ( + + {COURSE_DISCUSSIONS_ADMIN_NO_CONTENT} + + )} +
+ +
+ + {/* Action bar */} + {!comment.deleted && ( +
+ + +
+ )} + + {/* Inline reply composer */} + {isReplyingToThis && ( + + )} + + {/* Replies section */} + {replyCount > 0 && ( +
+ + + + + + + {comment.replies?.map((reply) => ( + + onReportReply( + reply.replyId || "", + ) + } + targetReplyId={targetReplyId} + onLike={() => + onLike( + "REPLY", + reply.replyId || "", + !reply.hasLiked, + ) + } + onStartReply={() => { + if ( + replyingTo?.commentId === + comment.commentId && + replyingTo?.parentReplyId === + reply.replyId + ) { + onCancelReply(); + } else { + onStartReply( + reply.replyId, + ); + } + }} + isReplying={ + replyingTo?.commentId === + comment.commentId && + replyingTo?.parentReplyId === + reply.replyId + } + replyComposer={ + replyingTo?.commentId === + comment.commentId && + replyingTo?.parentReplyId === + reply.replyId ? ( + + ) : undefined + } + /> + ))} + + {comment.hasMoreReplies && ( + + )} + + +
+ )} + + + + + ); +} + +// ─── ReplyComposer ──────────────────────────────────────────────────────────── + +function ReplyComposer({ + address, + content, + refresh, + posting, + onChange, + onSubmit, +}: { + address: any; + content: TextEditorContent; + refresh: number; + posting: boolean; + onChange: (content: TextEditorContent) => void; + onSubmit: () => void; +}) { + const { theme } = useContext(ThemeContext); + const inputStyles = theme.theme?.interactives?.input; + const canSubmit = isTextEditorNonEmpty(content); + + return ( +
+
+ + onChange(value as TextEditorContent) + } + placeholder={COURSE_DISCUSSIONS_ADMIN_REPLY + "…"} + showToolbar={false} + editorClassName="min-h-[48px] max-w-none" + className={`${inputStyles?.border?.radius} ${inputStyles?.border?.width} ${inputStyles?.border?.style} ${inputStyles?.shadow} ${inputStyles?.custom}`} + /> +
+
+ +
+
+ ); +} + +// ─── ReplyRow ───────────────────────────────────────────────────────────────── + +function ReplyRow({ + reply, + theme, + onReport, + targetReplyId, + onLike, + onStartReply, + isReplying, + replyComposer, +}: { + reply: DiscussionContent; + theme: any; + onReport: () => void; + targetReplyId?: string; + onLike: () => void; + onStartReply: () => void; + isReplying: boolean; + replyComposer?: React.ReactNode; +}) { + const displayName = reply.user?.name || reply.user?.email || reply.userId; + const initials = getInitials(displayName); + const displayDate = new Date(reply.createdAt).toLocaleDateString(); + const isTargetReply = targetReplyId === reply.replyId; + + return ( +
+ + {reply.user?.avatar && + (reply.user.avatar.thumbnail || reply.user.avatar.file) && ( + + )} + + {initials} + + +
+
+ {displayName} + + {displayDate} + + {reply.deleted && ( + + {COURSE_DISCUSSIONS_DELETED} + + )} +
+
+ {reply.content ? ( + + ) : ( + + {COURSE_DISCUSSIONS_ADMIN_NO_CONTENT} + + )} +
+ {!reply.deleted && ( +
+ + +
+ )} + {isReplying && replyComposer && ( +
{replyComposer}
+ )} +
+ +
+ ); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getInitials(name: string): string { + return ( + name + .split(" ") + .map((n: string) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) || "?" + ); +} + +function getTextPreview(content: TextEditorContent | null): string { + if (!content) return ""; + try { + const walk = (nodes: any[]): string => { + for (const node of nodes) { + if (node.type === "text" && node.text) return node.text; + if (node.content) { + const found = walk(node.content); + if (found) return found; + } + } + return ""; + }; + return walk((content as any).content || []); + } catch { + return ""; + } +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/report-reason-dialog.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/report-reason-dialog.tsx new file mode 100644 index 000000000..812cc7580 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/report-reason-dialog.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { POPUP_CANCEL_ACTION } from "@ui-config/strings"; + +const REPORT_DIALOG_TITLE = "Report Post"; +const REPORT_DIALOG_DESCRIPTION = + "Please provide a reason for reporting this post."; +const REPORT_DIALOG_PLACEHOLDER = "Reason for reporting..."; +const REPORT_DIALOG_SUBMIT = "Submit"; + +interface ReportReasonDialogProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (reason: string) => void; +} + +export function ReportReasonDialog({ + isOpen, + onClose, + onSubmit, +}: ReportReasonDialogProps) { + const [reason, setReason] = useState(""); + const textareaRef = useRef(null); + + function handleSubmit() { + const trimmed = reason.trim(); + if (!trimmed) { + textareaRef.current?.focus(); + return; + } + onSubmit(trimmed); + setReason(""); + } + + function handleClose() { + setReason(""); + onClose(); + } + + return ( + + + + {REPORT_DIALOG_TITLE} + + {REPORT_DIALOG_DESCRIPTION} + + + +
+