diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..b43e32e
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,9 @@
+{
+ "semi": true,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "trailingComma": "es5",
+ "printWidth": 100,
+ "bracketSpacing": true,
+ "arrowParens": "avoid"
+}
\ No newline at end of file
diff --git a/src/components/content/carousels/templates/CourseTemplate.js b/src/components/content/carousels/templates/CourseTemplate.js
new file mode 100644
index 0000000..0634b05
--- /dev/null
+++ b/src/components/content/carousels/templates/CourseTemplate.js
@@ -0,0 +1,183 @@
+import { useState, useEffect } from 'react';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Tag } from 'primereact/tag';
+import ZapDisplay from '@/components/zaps/ZapDisplay';
+import { nip19 } from 'nostr-tools';
+import Image from 'next/image';
+import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription';
+import { getTotalFromZaps } from '@/utils/lightning';
+import { useImageProxy } from '@/hooks/useImageProxy';
+import { useRouter } from 'next/router';
+import { formatTimestampToHowLongAgo } from '@/utils/time';
+import { ProgressSpinner } from 'primereact/progressspinner';
+import { Message } from 'primereact/message';
+import useWindowWidth from '@/hooks/useWindowWidth';
+import GenericButton from '@/components/buttons/GenericButton';
+import appConfig from '@/config/appConfig';
+import { BookOpen } from 'lucide-react';
+
+export function CourseTemplate({ course, showMetaTags = true }) {
+ const { zaps, zapsLoading, zapsError } = useZapsSubscription({
+ event: course,
+ });
+ const [zapAmount, setZapAmount] = useState(0);
+ const [lessonCount, setLessonCount] = useState(0);
+ const [nAddress, setNAddress] = useState(null);
+ const router = useRouter();
+ const { returnImageProxy } = useImageProxy();
+ const windowWidth = useWindowWidth();
+ const isMobile = windowWidth < 768;
+
+ useEffect(() => {
+ if (zaps.length > 0) {
+ const total = getTotalFromZaps(zaps, course);
+ setZapAmount(total);
+ }
+ }, [zaps, course]);
+
+ useEffect(() => {
+ if (course && course?.tags) {
+ const lessons = course.tags.filter(tag => tag[0] === 'a');
+ setLessonCount(lessons.length);
+ }
+ }, [course]);
+
+ useEffect(() => {
+ if (course && course?.d) {
+ const nAddress = nip19.naddrEncode({
+ pubkey: course.pubkey,
+ kind: course.kind,
+ identifier: course.d,
+ relays: appConfig.defaultRelayUrls,
+ });
+ setNAddress(nAddress);
+ }
+ }, [course]);
+
+ const shouldShowMetaTags = topic => {
+ if (!showMetaTags) {
+ return !['lesson', 'document', 'video', 'course'].includes(topic);
+ }
+ return true;
+ };
+
+ if (!nAddress)
+ return (
+
+ );
+
+ if (zapsError) return Error: {zapsError}
;
+
+ return (
+
+ router.push(`/course/${nAddress}`)}
+ >
+
+
+
+
+
+
+
+
+ {course.name}
+
+
+
+
+
+
+
+ {course &&
+ course.topics &&
+ course.topics.map(
+ (topic, index) =>
+ shouldShowMetaTags(topic) && (
+
+ {topic}
+
+ )
+ )}
+
+ {course?.price && course?.price > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {(course.summary || course.description)?.split('\n').map((line, index) => (
+
+ {line}
+
+ ))}
+
+
+
+
+ {course?.published_at && course.published_at !== ''
+ ? formatTimestampToHowLongAgo(course.published_at)
+ : formatTimestampToHowLongAgo(course.created_at)}
+
+ router.push(`/course/${nAddress}`)}
+ size="small"
+ label="Start Learning"
+ icon="pi pi-chevron-right"
+ iconPos="right"
+ outlined
+ className="items-center py-2"
+ />
+
+
+ );
+}
diff --git a/src/components/content/courses/CourseLesson.js b/src/components/content/courses/CourseLesson.js
new file mode 100644
index 0000000..ac8504f
--- /dev/null
+++ b/src/components/content/courses/CourseLesson.js
@@ -0,0 +1,211 @@
+import React, { useEffect, useState, useRef } from 'react';
+import { Tag } from 'primereact/tag';
+import Image from 'next/image';
+import { useImageProxy } from '@/hooks/useImageProxy';
+import { getTotalFromZaps } from '@/utils/lightning';
+import ZapDisplay from '@/components/zaps/ZapDisplay';
+import dynamic from 'next/dynamic';
+import { useZapsQuery } from '@/hooks/nostrQueries/zaps/useZapsQuery';
+import { Toast } from 'primereact/toast';
+import useTrackDocumentLesson from '@/hooks/tracking/useTrackDocumentLesson';
+import useWindowWidth from '@/hooks/useWindowWidth';
+import { nip19 } from 'nostr-tools';
+import appConfig from '@/config/appConfig';
+import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
+import { useSession } from 'next-auth/react';
+
+const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
+ ssr: false,
+});
+
+const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
+ const [zapAmount, setZapAmount] = useState(0);
+ const { zaps, zapsLoading, zapsError } = useZapsQuery({ event: lesson,
+ type: 'lesson' });
+ const { returnImageProxy } = useImageProxy();
+ const menuRef = useRef(null);
+ const toastRef = useRef(null);
+ const windowWidth = useWindowWidth();
+ const isMobileView = windowWidth <= 768;
+ const { data: session } = useSession();
+
+ const readTime = lesson?.content ? Math.max(30, Math.ceil(lesson.content.length / 20)) : 60;
+
+ const { isCompleted, isTracking, markLessonAsCompleted } = useTrackDocumentLesson({
+ lessonId: lesson?.d,
+ courseId: course?.d,
+ readTime,
+ paidCourse: isPaid,
+ decryptionPerformed,
+ });
+
+ const buildMenuItems = () => {
+ const items = [];
+
+ const hasAccess =
+ session?.user && (!isPaid || decryptionPerformed || session.user.role?.subscribed);
+
+ if (hasAccess) {
+ items.push({
+ label: 'Mark as completed',
+ icon: 'pi pi-check-circle',
+ command: async () => {
+ try {
+ await markLessonAsCompleted();
+ setCompleted && setCompleted(lesson.id);
+ toastRef.current.show({
+ severity: 'success',
+ summary: 'Success',
+ detail: 'Lesson marked as completed',
+ life: 3000,
+ });
+ } catch (error) {
+ console.error('Failed to mark lesson as completed:', error);
+ toastRef.current.show({
+ severity: 'error',
+ summary: 'Error',
+ detail: 'Failed to mark lesson as completed',
+ life: 3000,
+ });
+ }
+ },
+ });
+ }
+
+ items.push({
+ label: 'Open lesson',
+ icon: 'pi pi-arrow-up-right',
+ command: () => {
+ window.open(`/details/${lesson.id}`, '_blank');
+ },
+ });
+
+ items.push({
+ label: 'View Nostr note',
+ icon: 'pi pi-globe',
+ command: () => {
+ if (lesson?.d) {
+ const addr = nip19.naddrEncode({
+ pubkey: lesson.pubkey,
+ kind: lesson.kind,
+ identifier: lesson.d,
+ relays: appConfig.defaultRelayUrls || [],
+ });
+ window.open(`https://habla.news/a/${addr}`, '_blank');
+ }
+ },
+ });
+
+ return items;
+ };
+
+ useEffect(() => {
+ if (!zaps || zapsLoading || zapsError) return;
+
+ const total = getTotalFromZaps(zaps, lesson);
+
+ setZapAmount(total);
+ }, [zaps, zapsLoading, zapsError, lesson]);
+
+ useEffect(() => {
+ if (isCompleted && !isTracking && setCompleted) {
+ setCompleted(lesson.id);
+ }
+ }, [isCompleted, isTracking, lesson.id, setCompleted]);
+
+ const renderContent = () => {
+ if (isPaid && decryptionPerformed) {
+ return ;
+ }
+ if (isPaid && !decryptionPerformed) {
+ return (
+
+ This content is paid and needs to be purchased before viewing.
+
+ );
+ }
+ if (lesson?.content) {
+ return ;
+ }
+ return null;
+ };
+
+ return (
+
+
+
+
+
+
+
{lesson?.title}
+
+
+
+ {lesson &&
+ lesson.topics &&
+ lesson.topics.length > 0 &&
+ lesson.topics.map((topic, index) => (
+
+ ))}
+
+
+ {lesson?.summary && (
+
+ {lesson.summary.split('\n').map((line, index) => (
+
{line}
+ ))}
+
+ )}
+
+
+
+
+ {lesson && (
+
+
+
+ )}
+
+
+
+
+ {renderContent()}
+
+
+ );
+};
+
+export default CourseLesson;