2025-04-02 16:38:37 -05:00
|
|
|
import React, { useEffect, useState, useCallback } from "react";
|
|
|
|
import { useRouter } from "next/router";
|
|
|
|
import { parseCourseEvent, parseEvent, findKind0Fields } from "@/utils/nostr";
|
|
|
|
import CourseDetails from "@/components/content/courses/CourseDetails";
|
|
|
|
import VideoLesson from "@/components/content/courses/VideoLesson";
|
|
|
|
import DocumentLesson from "@/components/content/courses/DocumentLesson";
|
|
|
|
import CombinedLesson from "@/components/content/courses/CombinedLesson";
|
|
|
|
import { useNDKContext } from "@/context/NDKContext";
|
|
|
|
import { useSession } from "next-auth/react";
|
|
|
|
import axios from "axios";
|
|
|
|
import { nip04, nip19 } from "nostr-tools";
|
|
|
|
import { useToast } from "@/hooks/useToast";
|
|
|
|
import { ProgressSpinner } from "primereact/progressspinner";
|
|
|
|
import { Accordion, AccordionTab } from "primereact/accordion";
|
|
|
|
import { Tag } from "primereact/tag";
|
|
|
|
import { useDecryptContent } from "@/hooks/encryption/useDecryptContent";
|
|
|
|
import dynamic from "next/dynamic";
|
|
|
|
import ZapThreadsWrapper from "@/components/ZapThreadsWrapper";
|
|
|
|
import appConfig from "@/config/appConfig";
|
|
|
|
|
|
|
|
const MDDisplay = dynamic(() => import("@uiw/react-markdown-preview"), {
|
|
|
|
ssr: false,
|
|
|
|
});
|
2024-02-27 18:29:57 -06:00
|
|
|
|
2024-09-14 16:43:03 -05:00
|
|
|
const useCourseData = (ndk, fetchAuthor, router) => {
|
2025-04-02 17:47:30 -05:00
|
|
|
const [course, setCourse] = useState(null);
|
|
|
|
const [lessonIds, setLessonIds] = useState([]);
|
|
|
|
const [paidCourse, setPaidCourse] = useState(null);
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const { showToast } = useToast();
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (!router.isReady) return;
|
|
|
|
|
|
|
|
const { slug } = router.query;
|
|
|
|
let id;
|
|
|
|
|
|
|
|
const fetchCourseId = async () => {
|
2025-04-02 16:38:37 -05:00
|
|
|
if (slug.includes("naddr")) {
|
2025-04-02 17:47:30 -05:00
|
|
|
const { data } = nip19.decode(slug);
|
|
|
|
if (!data?.identifier) {
|
2025-04-02 16:38:37 -05:00
|
|
|
showToast("error", "Error", "Resource not found");
|
2025-04-02 17:47:30 -05:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
return data.identifier;
|
|
|
|
} else {
|
|
|
|
return slug;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2025-04-02 16:38:37 -05:00
|
|
|
const fetchCourse = async (courseId) => {
|
2025-04-02 17:47:30 -05:00
|
|
|
try {
|
|
|
|
await ndk.connect();
|
2025-04-02 16:38:37 -05:00
|
|
|
const event = await ndk.fetchEvent({ "#d": [courseId] });
|
2025-04-02 17:47:30 -05:00
|
|
|
if (!event) return null;
|
|
|
|
|
|
|
|
const author = await fetchAuthor(event.pubkey);
|
2025-04-02 16:38:37 -05:00
|
|
|
const lessonIds = event.tags
|
|
|
|
.filter((tag) => tag[0] === "a")
|
|
|
|
.map((tag) => tag[1].split(":")[2]);
|
2025-04-02 17:47:30 -05:00
|
|
|
|
|
|
|
const parsedCourse = { ...parseCourseEvent(event), author };
|
|
|
|
return { parsedCourse, lessonIds };
|
|
|
|
} catch (error) {
|
2025-04-02 16:38:37 -05:00
|
|
|
console.error("Error fetching event:", error);
|
2025-04-02 17:47:30 -05:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const initializeCourse = async () => {
|
|
|
|
setLoading(true);
|
|
|
|
id = await fetchCourseId();
|
|
|
|
if (!id) {
|
|
|
|
setLoading(false);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const courseData = await fetchCourse(id);
|
|
|
|
if (courseData) {
|
|
|
|
const { parsedCourse, lessonIds } = courseData;
|
|
|
|
setCourse(parsedCourse);
|
|
|
|
setLessonIds(lessonIds);
|
|
|
|
setPaidCourse(parsedCourse.price && parsedCourse.price > 0);
|
|
|
|
}
|
|
|
|
setLoading(false);
|
|
|
|
};
|
|
|
|
|
|
|
|
initializeCourse();
|
|
|
|
}, [router.isReady, router.query, ndk, fetchAuthor, showToast]);
|
|
|
|
|
|
|
|
return { course, lessonIds, paidCourse, loading };
|
2024-09-14 16:43:03 -05:00
|
|
|
};
|
|
|
|
|
2024-09-15 15:15:58 -05:00
|
|
|
const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => {
|
2025-04-02 17:47:30 -05:00
|
|
|
const [lessons, setLessons] = useState([]);
|
|
|
|
const [uniqueLessons, setUniqueLessons] = useState([]);
|
|
|
|
const { showToast } = useToast();
|
|
|
|
useEffect(() => {
|
|
|
|
if (lessonIds.length > 0) {
|
2025-04-02 16:38:37 -05:00
|
|
|
const fetchLesson = async (lessonId) => {
|
2025-04-02 17:47:30 -05:00
|
|
|
try {
|
|
|
|
await ndk.connect();
|
2025-04-02 16:38:37 -05:00
|
|
|
const filter = {
|
|
|
|
"#d": [lessonId],
|
|
|
|
kinds: [30023, 30402],
|
|
|
|
authors: [pubkey],
|
|
|
|
};
|
2025-04-02 17:47:30 -05:00
|
|
|
const event = await ndk.fetchEvent(filter);
|
|
|
|
if (event) {
|
|
|
|
const author = await fetchAuthor(event.pubkey);
|
|
|
|
const parsedLesson = { ...parseEvent(event), author };
|
2025-04-02 16:38:37 -05:00
|
|
|
setLessons((prev) => {
|
2025-04-02 17:47:30 -05:00
|
|
|
// Check if the lesson already exists in the array
|
2025-04-02 16:38:37 -05:00
|
|
|
const exists = prev.some(
|
|
|
|
(lesson) => lesson.id === parsedLesson.id
|
|
|
|
);
|
2025-04-02 17:47:30 -05:00
|
|
|
if (!exists) {
|
|
|
|
return [...prev, parsedLesson];
|
|
|
|
}
|
|
|
|
return prev;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} catch (error) {
|
2025-04-02 16:38:37 -05:00
|
|
|
console.error("Error fetching event:", error);
|
2024-07-30 17:16:09 -05:00
|
|
|
}
|
2025-04-02 17:47:30 -05:00
|
|
|
};
|
2025-04-02 16:38:37 -05:00
|
|
|
lessonIds.forEach((lessonId) => fetchLesson(lessonId));
|
2025-04-02 17:47:30 -05:00
|
|
|
}
|
|
|
|
}, [lessonIds, ndk, fetchAuthor, pubkey]);
|
2024-07-30 17:16:09 -05:00
|
|
|
|
2025-04-02 17:47:30 -05:00
|
|
|
useEffect(() => {
|
|
|
|
const newUniqueLessons = Array.from(
|
2025-04-02 16:38:37 -05:00
|
|
|
new Map(lessons.map((lesson) => [lesson.id, lesson])).values()
|
2025-04-02 17:47:30 -05:00
|
|
|
);
|
|
|
|
setUniqueLessons(newUniqueLessons);
|
|
|
|
}, [lessons]);
|
2024-08-25 12:12:55 -05:00
|
|
|
|
2025-04-02 17:47:30 -05:00
|
|
|
return { lessons, uniqueLessons, setLessons };
|
2024-09-14 16:43:03 -05:00
|
|
|
};
|
2024-08-25 12:12:55 -05:00
|
|
|
|
2024-09-14 16:43:03 -05:00
|
|
|
const useDecryption = (session, paidCourse, course, lessons, setLessons) => {
|
2025-04-02 17:47:30 -05:00
|
|
|
const [decryptionPerformed, setDecryptionPerformed] = useState(false);
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const { decryptContent } = useDecryptContent();
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
const decrypt = async () => {
|
|
|
|
if (session?.user && paidCourse && !decryptionPerformed) {
|
|
|
|
setLoading(true);
|
|
|
|
const canAccess =
|
2025-04-02 16:38:37 -05:00
|
|
|
session.user.purchased?.some(
|
|
|
|
(purchase) => purchase.courseId === course?.d
|
|
|
|
) ||
|
2025-04-02 17:47:30 -05:00
|
|
|
session.user?.role?.subscribed ||
|
|
|
|
session.user?.pubkey === course?.pubkey;
|
|
|
|
|
|
|
|
if (canAccess && lessons.length > 0) {
|
|
|
|
try {
|
|
|
|
const decryptedLessons = await Promise.all(
|
2025-04-02 16:38:37 -05:00
|
|
|
lessons.map(async (lesson) => {
|
2025-04-02 17:47:30 -05:00
|
|
|
const decryptedContent = await decryptContent(lesson.content);
|
|
|
|
return { ...lesson, content: decryptedContent };
|
|
|
|
})
|
|
|
|
);
|
|
|
|
setLessons(decryptedLessons);
|
|
|
|
setDecryptionPerformed(true);
|
|
|
|
} catch (error) {
|
2025-04-02 16:38:37 -05:00
|
|
|
console.error("Error decrypting lessons:", error);
|
2025-04-02 17:47:30 -05:00
|
|
|
}
|
2024-08-16 18:00:46 -05:00
|
|
|
}
|
2025-04-02 17:47:30 -05:00
|
|
|
setLoading(false);
|
|
|
|
}
|
|
|
|
setLoading(false);
|
|
|
|
};
|
|
|
|
decrypt();
|
|
|
|
}, [session, paidCourse, course, lessons, decryptionPerformed, setLessons]);
|
2024-09-14 16:43:03 -05:00
|
|
|
|
2025-04-02 17:47:30 -05:00
|
|
|
return { decryptionPerformed, loading };
|
2024-09-14 16:43:03 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
const Course = () => {
|
2025-04-02 17:47:30 -05:00
|
|
|
const router = useRouter();
|
|
|
|
const { ndk, addSigner } = useNDKContext();
|
|
|
|
const { data: session, update } = useSession();
|
|
|
|
const { showToast } = useToast();
|
|
|
|
const [expandedIndex, setExpandedIndex] = useState(null);
|
|
|
|
const [completedLessons, setCompletedLessons] = useState([]);
|
2025-04-02 16:38:37 -05:00
|
|
|
const [nAddresses, setNAddresses] = useState({});
|
|
|
|
const [nsec, setNsec] = useState(null);
|
|
|
|
const [npub, setNpub] = useState(null);
|
2024-09-14 16:43:03 -05:00
|
|
|
|
2025-04-02 16:38:37 -05:00
|
|
|
const setCompleted = useCallback((lessonId) => {
|
|
|
|
setCompletedLessons((prev) => [...prev, lessonId]);
|
2025-04-02 17:47:30 -05:00
|
|
|
}, []);
|
2024-09-23 22:44:32 -05:00
|
|
|
|
2025-04-02 17:47:30 -05:00
|
|
|
const fetchAuthor = useCallback(
|
2025-04-02 16:38:37 -05:00
|
|
|
async (pubkey) => {
|
2025-04-02 17:47:30 -05:00
|
|
|
const author = await ndk.getUser({ pubkey });
|
|
|
|
const profile = await author.fetchProfile();
|
|
|
|
const fields = await findKind0Fields(profile);
|
|
|
|
return fields;
|
|
|
|
},
|
|
|
|
[ndk]
|
|
|
|
);
|
2024-08-16 18:00:46 -05:00
|
|
|
|
2025-04-02 17:47:30 -05:00
|
|
|
const {
|
|
|
|
course,
|
|
|
|
lessonIds,
|
|
|
|
paidCourse,
|
|
|
|
loading: courseLoading,
|
|
|
|
} = useCourseData(ndk, fetchAuthor, router);
|
|
|
|
const { lessons, uniqueLessons, setLessons } = useLessons(
|
|
|
|
ndk,
|
|
|
|
fetchAuthor,
|
|
|
|
lessonIds,
|
|
|
|
course?.pubkey
|
|
|
|
);
|
|
|
|
const { decryptionPerformed, loading: decryptionLoading } = useDecryption(
|
|
|
|
session,
|
|
|
|
paidCourse,
|
|
|
|
course,
|
|
|
|
lessons,
|
|
|
|
setLessons
|
|
|
|
);
|
2024-08-16 18:00:46 -05:00
|
|
|
|
2025-04-02 17:47:30 -05:00
|
|
|
useEffect(() => {
|
|
|
|
if (router.isReady) {
|
|
|
|
const { active } = router.query;
|
|
|
|
if (active !== undefined) {
|
|
|
|
setExpandedIndex(parseInt(active, 10));
|
|
|
|
} else {
|
|
|
|
setExpandedIndex(null);
|
|
|
|
}
|
2024-08-16 18:00:46 -05:00
|
|
|
}
|
2025-04-02 17:47:30 -05:00
|
|
|
}, [router.isReady, router.query]);
|
2024-08-16 18:00:46 -05:00
|
|
|
|
2025-04-02 16:38:37 -05:00
|
|
|
useEffect(() => {
|
|
|
|
if (uniqueLessons.length > 0) {
|
|
|
|
const addresses = {};
|
|
|
|
uniqueLessons.forEach((lesson) => {
|
|
|
|
const addr = nip19.naddrEncode({
|
|
|
|
pubkey: lesson.pubkey,
|
|
|
|
kind: lesson.kind,
|
|
|
|
identifier: lesson.d,
|
|
|
|
relays: appConfig.defaultRelayUrls,
|
|
|
|
});
|
|
|
|
addresses[lesson.id] = addr;
|
|
|
|
});
|
|
|
|
setNAddresses(addresses);
|
|
|
|
}
|
|
|
|
}, [uniqueLessons]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (session?.user?.privkey) {
|
|
|
|
const privkeyBuffer = Buffer.from(session.user.privkey, "hex");
|
|
|
|
setNsec(nip19.nsecEncode(privkeyBuffer));
|
|
|
|
} else if (session?.user?.pubkey) {
|
|
|
|
setNpub(nip19.npubEncode(session.user.pubkey));
|
|
|
|
}
|
|
|
|
}, [session]);
|
|
|
|
|
|
|
|
const handleAccordionChange = (e) => {
|
2025-04-02 17:47:30 -05:00
|
|
|
const newIndex = e.index === expandedIndex ? null : e.index;
|
|
|
|
setExpandedIndex(newIndex);
|
|
|
|
|
|
|
|
if (newIndex !== null) {
|
2025-04-02 16:38:37 -05:00
|
|
|
router.push(
|
|
|
|
`/course/${router.query.slug}?active=${newIndex}`,
|
|
|
|
undefined,
|
|
|
|
{ shallow: true }
|
|
|
|
);
|
2025-04-02 17:47:30 -05:00
|
|
|
} else {
|
|
|
|
router.push(`/course/${router.query.slug}`, undefined, { shallow: true });
|
2024-08-17 12:56:27 -05:00
|
|
|
}
|
2025-04-02 17:47:30 -05:00
|
|
|
};
|
2024-08-17 12:56:27 -05:00
|
|
|
|
2025-04-02 16:38:37 -05:00
|
|
|
const handlePaymentSuccess = async (response) => {
|
2025-04-02 17:47:30 -05:00
|
|
|
if (response && response?.preimage) {
|
|
|
|
const updated = await update();
|
2025-04-02 16:38:37 -05:00
|
|
|
showToast(
|
|
|
|
"success",
|
|
|
|
"Payment Success",
|
|
|
|
"You have successfully purchased this course"
|
|
|
|
);
|
2025-04-02 17:47:30 -05:00
|
|
|
} else {
|
2025-04-02 16:38:37 -05:00
|
|
|
showToast(
|
|
|
|
"error",
|
|
|
|
"Error",
|
|
|
|
"Failed to purchase course. Please try again."
|
|
|
|
);
|
2024-11-20 17:46:52 -06:00
|
|
|
}
|
2025-04-02 17:47:30 -05:00
|
|
|
};
|
2024-11-20 17:46:52 -06:00
|
|
|
|
2025-04-02 16:38:37 -05:00
|
|
|
const handlePaymentError = (error) => {
|
2025-04-02 17:47:30 -05:00
|
|
|
showToast(
|
2025-04-02 16:38:37 -05:00
|
|
|
"error",
|
|
|
|
"Payment Error",
|
2025-04-02 17:47:30 -05:00
|
|
|
`Failed to purchase course. Please try again. Error: ${error}`
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
if (courseLoading || decryptionLoading) {
|
2024-02-27 18:29:57 -06:00
|
|
|
return (
|
2025-04-02 17:47:30 -05:00
|
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
|
|
<ProgressSpinner />
|
|
|
|
</div>
|
2024-02-27 18:29:57 -06:00
|
|
|
);
|
2025-04-02 17:47:30 -05:00
|
|
|
}
|
|
|
|
|
2025-04-02 16:38:37 -05:00
|
|
|
const renderLesson = (lesson) => {
|
|
|
|
if (
|
|
|
|
lesson.topics?.includes("video") &&
|
|
|
|
lesson.topics?.includes("document")
|
|
|
|
) {
|
2025-04-02 17:47:30 -05:00
|
|
|
return (
|
|
|
|
<CombinedLesson
|
|
|
|
lesson={lesson}
|
|
|
|
course={course}
|
|
|
|
decryptionPerformed={decryptionPerformed}
|
|
|
|
isPaid={paidCourse}
|
|
|
|
setCompleted={setCompleted}
|
|
|
|
/>
|
|
|
|
);
|
2025-04-02 16:38:37 -05:00
|
|
|
} else if (
|
|
|
|
lesson.type === "video" &&
|
|
|
|
!lesson.topics?.includes("document")
|
|
|
|
) {
|
2025-04-02 17:47:30 -05:00
|
|
|
return (
|
|
|
|
<VideoLesson
|
|
|
|
lesson={lesson}
|
|
|
|
course={course}
|
|
|
|
decryptionPerformed={decryptionPerformed}
|
|
|
|
isPaid={paidCourse}
|
|
|
|
setCompleted={setCompleted}
|
|
|
|
/>
|
|
|
|
);
|
2025-04-02 16:38:37 -05:00
|
|
|
} else if (
|
|
|
|
lesson.type === "document" &&
|
|
|
|
!lesson.topics?.includes("video")
|
|
|
|
) {
|
2025-04-02 17:47:30 -05:00
|
|
|
return (
|
|
|
|
<DocumentLesson
|
|
|
|
lesson={lesson}
|
|
|
|
course={course}
|
|
|
|
decryptionPerformed={decryptionPerformed}
|
|
|
|
isPaid={paidCourse}
|
|
|
|
setCompleted={setCompleted}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
{course && paidCourse !== null && (
|
|
|
|
<CourseDetails
|
|
|
|
processedEvent={course}
|
|
|
|
paidCourse={paidCourse}
|
|
|
|
lessons={uniqueLessons}
|
|
|
|
decryptionPerformed={decryptionPerformed}
|
|
|
|
handlePaymentSuccess={handlePaymentSuccess}
|
|
|
|
handlePaymentError={handlePaymentError}
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
<Accordion
|
|
|
|
activeIndex={expandedIndex}
|
|
|
|
onTabChange={handleAccordionChange}
|
|
|
|
className="mt-4 px-4 max-mob:px-0 max-tab:px-0"
|
|
|
|
>
|
|
|
|
{uniqueLessons.length > 0 &&
|
|
|
|
uniqueLessons.map((lesson, index) => (
|
|
|
|
<AccordionTab
|
|
|
|
key={index}
|
|
|
|
pt={{
|
2025-04-02 16:38:37 -05:00
|
|
|
root: { className: "border-none" },
|
|
|
|
header: { className: "border-none" },
|
|
|
|
headerAction: { className: "border-none" },
|
|
|
|
content: { className: "border-none max-mob:px-0 max-tab:px-0" },
|
|
|
|
accordiontab: { className: "border-none" },
|
2025-04-02 17:47:30 -05:00
|
|
|
}}
|
|
|
|
header={
|
|
|
|
<div className="flex align-items-center justify-between w-full">
|
|
|
|
<span
|
|
|
|
id={`lesson-${index}`}
|
|
|
|
className="font-bold text-xl"
|
|
|
|
>{`Lesson ${index + 1}: ${lesson.title}`}</span>
|
|
|
|
{completedLessons.includes(lesson.id) ? (
|
|
|
|
<Tag severity="success" value="Completed" />
|
|
|
|
) : null}
|
|
|
|
</div>
|
|
|
|
}
|
|
|
|
>
|
2025-04-02 16:38:37 -05:00
|
|
|
<div className="w-full py-4 rounded-b-lg">
|
|
|
|
{renderLesson(lesson)}
|
|
|
|
{nAddresses[lesson.id] && (
|
|
|
|
<div className="mt-8">
|
|
|
|
{!paidCourse ||
|
|
|
|
decryptionPerformed ||
|
|
|
|
session?.user?.role?.subscribed ? (
|
|
|
|
<ZapThreadsWrapper
|
|
|
|
anchor={nAddresses[lesson.id]}
|
|
|
|
user={session?.user ? nsec || npub : null}
|
|
|
|
relays={appConfig.defaultRelayUrls.join(",")}
|
|
|
|
disable="zaps"
|
|
|
|
isAuthorized={true}
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<div className="text-center p-4 bg-gray-800/50 rounded-lg">
|
|
|
|
<p className="text-gray-400">
|
|
|
|
Comments are only available to course purchasers,
|
|
|
|
subscribers, and the course creator.
|
|
|
|
</p>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
2025-04-02 17:47:30 -05:00
|
|
|
</AccordionTab>
|
|
|
|
))}
|
|
|
|
</Accordion>
|
|
|
|
<div className="mx-auto my-6">
|
2025-04-02 16:38:37 -05:00
|
|
|
{course?.content && (
|
|
|
|
<MDDisplay className="p-4 rounded-lg" source={course.content} />
|
|
|
|
)}
|
2025-04-02 17:47:30 -05:00
|
|
|
</div>
|
|
|
|
</>
|
|
|
|
);
|
|
|
|
};
|
2024-02-27 18:29:57 -06:00
|
|
|
|
2024-10-14 18:58:12 -05:00
|
|
|
export default Course;
|