From c956c77a2698b0ab78cfce21a65a02dfbff50141 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Tue, 27 Jan 2026 05:30:35 +0000 Subject: [PATCH 1/2] fix: Add 'None' option to payment method dropdown to allow resetting payment config Fixes #583 - Added PAYMENT_METHOD_NONE as the first option in the payment method dropdown - Removed placeholderMessage since 'None' is now a selectable option - Users can now reset their payment configuration by selecting 'None' --- apps/web/components/admin/settings/index.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/components/admin/settings/index.tsx b/apps/web/components/admin/settings/index.tsx index 4bd92981c..59e5b9cae 100644 --- a/apps/web/components/admin/settings/index.tsx +++ b/apps/web/components/admin/settings/index.tsx @@ -813,6 +813,10 @@ const Settings = (props: SettingsProps) => { newSettings.paymentMethod || PAYMENT_METHOD_NONE } options={[ + { + label: SITE_SETTINGS_PAYMENT_METHOD_NONE_LABEL, + value: PAYMENT_METHOD_NONE, + }, { label: capitalize( PAYMENT_METHOD_STRIPE.toLowerCase(), @@ -857,9 +861,6 @@ const Settings = (props: SettingsProps) => { }), ) } - placeholderMessage={ - SITE_SETTINGS_PAYMENT_METHOD_NONE_LABEL - } disabled={!newSettings.currencyISOCode} /> From 250b1930c2b92ba060da60ff9125ab5ee7e04154 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Sat, 30 May 2026 03:15:18 +0000 Subject: [PATCH 2/2] feat: course-linked discussions Enable per-lesson discussions by linking Communities to Courses. Data Model: - Add discussions + discussionCommunityId fields to Course - Add courseId field to Community (for course-linked communities) - Add lessonId field to CommunityPost (for lesson-linked posts) - Add COURSE_DISCUSSION_* notification entity actions Backend: - Auto-create Community when discussions toggled ON in course settings - Auto-create CommunityPost per lesson (syncs title, soft-deletes on removal) - Cascade soft-delete discussion community on course deletion - Filter course-linked communities from main community listing - New GraphQL queries: getCourseDiscussionPosts, getCourseDiscussionStream - Notification routing: course discussion events get deep-linked to lesson URL - Auto-enroll students into discussion community on course enrollment Frontend: - Course Manage page: Discussions toggle component (Switch) - Lesson page: Desktop sidebar (340px) + mobile slide-in FAB/drawer - LessonDiscussionPanel: Comments, threaded replies, likes via existing APIs - use-product hook updated to fetch discussions + discussionCommunityId --- .../course/[slug]/[id]/[lesson]/page.tsx | 136 ++++- .../manage/components/course-discussions.tsx | 98 ++++ .../(sidebar)/product/[id]/manage/page.tsx | 2 + .../public/lesson-discussion-panel.tsx | 520 ++++++++++++++++++ apps/web/config/strings.ts | 6 + apps/web/graphql/communities/logic.ts | 49 +- apps/web/graphql/communities/types.ts | 1 + apps/web/graphql/courses/logic.ts | 269 ++++++++- apps/web/graphql/courses/query.ts | 59 ++ apps/web/graphql/courses/types/index.ts | 3 + apps/web/graphql/lessons/logic.ts | 32 ++ apps/web/graphql/notifications/logic.ts | 91 +++ apps/web/graphql/users/logic.ts | 51 ++ apps/web/hooks/use-product.ts | 2 + apps/web/models/Community.ts | 2 + apps/web/models/CommunityPost.ts | 2 + apps/web/ui-config/strings.ts | 17 + packages/common-logic/src/models/course.ts | 4 + packages/common-models/src/community-post.ts | 1 + packages/common-models/src/community.ts | 1 + packages/common-models/src/constants.ts | 4 + packages/common-models/src/course.ts | 2 + 22 files changed, 1329 insertions(+), 23 deletions(-) create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/course-discussions.tsx create mode 100644 apps/web/components/public/lesson-discussion-panel.tsx diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/[lesson]/page.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/[lesson]/page.tsx index 160ffeea5..d837fb494 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/[lesson]/page.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/[lesson]/page.tsx @@ -1,10 +1,12 @@ "use client"; import { LessonViewer } from "@components/public/lesson-viewer"; +import { LessonDiscussionPanel } from "@components/public/lesson-discussion-panel"; import { redirect } from "next/navigation"; -import { useContext, use } from "react"; +import { useContext, use, useEffect, useState } from "react"; import { ProfileContext, AddressContext } from "@components/contexts"; import { Profile } from "@courselit/common-models"; +import { FetchBuilder } from "@courselit/utils"; export default function LessonPage(props: { params: Promise<{ @@ -17,19 +19,135 @@ export default function LessonPage(props: { const { slug, id, lesson } = params; const { profile, setProfile } = useContext(ProfileContext); const address = useContext(AddressContext); + const [discussionsEnabled, setDiscussionsEnabled] = useState(false); + + useEffect(() => { + if (id) { + loadCourseDiscussionsStatus(); + } + }, [id]); + + const loadCourseDiscussionsStatus = async () => { + try { + const query = ` + query { + course: getCourse(id: "${id}") { + discussions + } + } + `; + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(query) + .setIsGraphQLEndpoint(true) + .build(); + const response = await fetch.exec(); + if (response.course) { + setDiscussionsEnabled(!!response.course.discussions); + } + } catch (err) { + // ignore + } + }; if (!lesson) { redirect(`/course/${slug}/${id}`); } return ( - +
+
+ +
+ {discussionsEnabled && ( + <> +
+ +
+
+ + + +
+ + )} +
+ ); +} + +function MobileDiscussionDrawer({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(false); + + return ( + <> + + {open && ( +
+
setOpen(false)} + /> +
+
+

Discussions

+ +
+
{children}
+
+
+ )} + ); } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/course-discussions.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/course-discussions.tsx new file mode 100644 index 000000000..e1683b956 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/course-discussions.tsx @@ -0,0 +1,98 @@ +"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, + TOAST_TITLE_ERROR, + TOAST_TITLE_SUCCESS, +} from "@ui-config/strings"; +import { responses } from "@/config/strings"; +import { useGraphQLFetch } from "@/hooks/use-graphql-fetch"; + +const MUTATION_UPDATE_DISCUSSIONS = ` + mutation UpdateDiscussions($courseId: String!, $discussions: Boolean!) { + updateCourse(courseData: { id: $courseId, discussions: $discussions }) { + courseId + } + } +`; + +interface CourseDiscussionsProps { + product: any; +} + +export default function CourseDiscussions({ product }: CourseDiscussionsProps) { + const { toast } = useToast(); + const fetch = useGraphQLFetch(); + const [loading, setLoading] = useState(false); + const [discussionsEnabled, setDiscussionsEnabled] = useState( + product?.discussions || false, + ); + + const handleDiscussionsChange = async () => { + const newValue = !discussionsEnabled; + const previousValue = discussionsEnabled; + setDiscussionsEnabled(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: newValue + ? responses.discussions_enabled + : responses.discussions_disabled, + }); + } + } catch (err: any) { + setDiscussionsEnabled(previousValue); + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +

+ {responses.discussions_toggle_description} +

+
+ +
+
+ +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx index 75a1ec2bf..1eaf89b7c 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx @@ -19,6 +19,7 @@ import PaymentPlans from "./components/payment-plans"; import DownloadOptions from "./components/download-options"; import ProductPublishing from "./components/product-publishing"; import Certificates from "./components/certificates"; +import CourseDiscussions from "./components/course-discussions"; import ProductDeletion from "./components/product-deletion"; const { permissions } = UIConstants; @@ -109,6 +110,7 @@ export default function SettingsPage() { /> +
diff --git a/apps/web/components/public/lesson-discussion-panel.tsx b/apps/web/components/public/lesson-discussion-panel.tsx new file mode 100644 index 000000000..ebf17a9c7 --- /dev/null +++ b/apps/web/components/public/lesson-discussion-panel.tsx @@ -0,0 +1,520 @@ +"use client"; + +import { useEffect, useState, useContext } from "react"; +import { FetchBuilder } from "@courselit/utils"; +import { useRouter } from "next/navigation"; +import { + AddressContext, + ProfileContext, + ThemeContext, +} from "@components/contexts"; +import { Button } from "@components/ui/button"; +import { Textarea } from "@components/ui/textarea"; +import { Avatar, AvatarFallback } from "@components/ui/avatar"; +import { Skeleton } from "@components/ui/skeleton"; +import { useToast } from "@courselit/components-library"; +import { MessageSquare, X, Heart, Reply, ChevronRight } from "lucide-react"; +import { + LESSON_DISCUSSIONS_HEADER, + LESSON_DISCUSSIONS_WRITE_COMMENT, + LESSON_DISCUSSIONS_VIEW_ALL, + LESSON_DISCUSSIONS_EMPTY, + TOAST_TITLE_ERROR, + DELETED_COMMENT_PLACEHOLDER, +} from "@ui-config/strings"; +import { truncate } from "@courselit/utils"; +import type { Profile } from "@courselit/common-models"; + +interface LessonDiscussionPanelProps { + courseId: string; + lessonId: string; + slug: string; +} + +interface Comment { + commentId: string; + content: string; + userId: string; + likesCount: number; + hasLiked: boolean; + updatedAt: string; + replies: Reply[]; + deleted: boolean; + user?: { + userId: string; + name: string; + email: string; + avatar?: { file?: string }; + }; +} + +interface Reply { + replyId: string; + content: string; + userId: string; + likesCount: number; + hasLiked: boolean; + parentReplyId?: string; + updatedAt: string; + deleted: boolean; + user?: { + userId: string; + name: string; + email: string; + avatar?: { file?: string }; + }; +} + +interface Post { + postId: string; + communityId: string; + title: string; + lessonId: string; +} + +export function LessonDiscussionPanel({ + courseId, + lessonId, + slug, +}: LessonDiscussionPanelProps) { + const [post, setPost] = useState(null); + const [comments, setComments] = useState([]); + const [newComment, setNewComment] = useState(""); + const [replyTo, setReplyTo] = useState(null); + const [replyContent, setReplyContent] = useState(""); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + + const address = useContext(AddressContext); + const { profile } = useContext(ProfileContext); + const { toast } = useToast(); + const router = useRouter(); + + useEffect(() => { + if (courseId && lessonId) { + loadDiscussionPost(); + } + }, [courseId, lessonId]); + + const loadDiscussionPost = async () => { + setLoading(true); + try { + const query = ` + query { + posts: getCourseDiscussionPosts( + courseId: "${courseId}", + lessonId: "${lessonId}" + ) { + postId + communityId + title + lessonId + } + } + `; + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(query) + .setIsGraphQLEndpoint(true) + .build(); + const response = await fetch.exec(); + if (response.posts && response.posts.length > 0) { + const discussionPost = response.posts[0]; + setPost(discussionPost); + await loadComments( + discussionPost.communityId, + discussionPost.postId, + ); + } + } catch (err: any) { + // Discussion not available + } finally { + setLoading(false); + } + }; + + const loadComments = async (communityId: string, postId: string) => { + try { + const query = ` + query { + comments: getComments( + communityId: "${communityId}", + postId: "${postId}" + ) { + commentId + content + userId + likesCount + hasLiked + updatedAt + deleted + user { + userId + name + email + avatar { + file + } + } + replies { + replyId + content + userId + likesCount + hasLiked + parentReplyId + updatedAt + deleted + user { + userId + name + email + avatar { + file + } + } + } + } + } + `; + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(query) + .setIsGraphQLEndpoint(true) + .build(); + const response = await fetch.exec(); + if (response.comments) { + setComments(response.comments); + } + } catch (err: any) { + // Comments unavailable + } + }; + + const handlePostComment = async () => { + if (!newComment.trim() || !post) return; + setSubmitting(true); + try { + const query = ` + mutation { + comment: postComment( + communityId: "${post.communityId}", + postId: "${post.postId}", + content: "${escapeString(newComment)}" + ) { + commentId + } + } + `; + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(query) + .setIsGraphQLEndpoint(true) + .build(); + await fetch.exec(); + setNewComment(""); + await loadComments(post.communityId, post.postId); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setSubmitting(false); + } + }; + + const handlePostReply = async (commentId: string) => { + if (!replyContent.trim() || !post) return; + setSubmitting(true); + try { + const query = ` + mutation { + comment: postComment( + communityId: "${post.communityId}", + postId: "${post.postId}", + content: "${escapeString(replyContent)}", + parentCommentId: "${commentId}" + ) { + commentId + } + } + `; + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(query) + .setIsGraphQLEndpoint(true) + .build(); + await fetch.exec(); + setReplyTo(null); + setReplyContent(""); + await loadComments(post.communityId, post.postId); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setSubmitting(false); + } + }; + + const handleToggleLike = async (commentId: string) => { + if (!post) return; + try { + const query = ` + mutation { + comment: toggleCommentLike( + communityId: "${post.communityId}", + postId: "${post.postId}", + commentId: "${commentId}" + ) { + commentId + } + } + `; + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(query) + .setIsGraphQLEndpoint(true) + .build(); + await fetch.exec(); + await loadComments(post.communityId, post.postId); + } catch (err: any) { + // ignore + } + }; + + const getInitials = (name?: string, email?: string) => { + const displayName = name || email || "U"; + return displayName + .split(" ") + .map((n) => n[0]) + .join("") + .substring(0, 2) + .toUpperCase(); + }; + + return ( +
+
+
+ +

+ {LESSON_DISCUSSIONS_HEADER} +

+
+
+ +
+ {loading && ( +
+ + +
+ )} + + {!loading && comments.length === 0 && ( +

+ {LESSON_DISCUSSIONS_EMPTY} +

+ )} + + {comments.map((comment) => ( +
+
+ + + {getInitials( + comment.user?.name, + comment.user?.email, + )} + + +
+
+ + {comment.user?.name || + comment.user?.email || + "User"} + + + {new Date( + comment.updatedAt, + ).toLocaleDateString()} + +
+

+ {comment.deleted + ? DELETED_COMMENT_PLACEHOLDER + : comment.content} +

+ {!comment.deleted && ( +
+ + +
+ )} + + {replyTo === comment.commentId && ( +
+