Progress on course progress flow, lso fixes for course tracking

This commit is contained in:
austinkelsay 2024-09-23 22:44:32 -05:00
parent 215a00e593
commit 4f98ea3656
8 changed files with 156 additions and 52 deletions

View File

@ -0,0 +1,66 @@
import React, { useEffect, useState } from "react";
import { useNDKContext } from "@/context/NDKContext";
import { parseCourseEvent } from "@/utils/nostr";
import { ProgressSpinner } from "primereact/progressspinner";
import { nip19 } from "nostr-tools";
import appConfig from "@/config/appConfig";
const ProgressListItem = ({ dTag, category }) => {
const { ndk } = useNDKContext();
const [event, setEvent] = useState(null);
useEffect(() => {
const fetchEvent = async () => {
if (!dTag) return;
try {
await ndk.connect();
const filter = {
kinds: [30004],
"#d": [dTag]
}
const event = await ndk.fetchEvent(filter);
if (event) {
setEvent(parseCourseEvent(event));
}
} catch (error) {
console.error("Error fetching event:", error);
}
}
fetchEvent();
}, [dTag, ndk]);
const encodeNaddr = () => {
return nip19.naddrEncode({
pubkey: event.pubkey,
identifier: dTag,
kind: 30004,
relayUrls: appConfig.defaultRelayUrls
})
}
const renderContent = () => {
if (!event) return null;
if (category === "name") {
return (
<a className="text-blue-500 underline hover:text-blue-600" href={`/course/${encodeNaddr()}`}>
{event.name}
</a>
);
} else if (category === "lessons") {
const lessonsLength = event.tags.filter(tag => tag[0] === "a").length;
return <span>{lessonsLength}</span>;
}
return null;
}
return !event || !ndk || !dTag ? (
<ProgressSpinner className="w-[40px] h-[40px]" />
) : (
renderContent()
);
}
export default ProgressListItem;

View File

@ -5,7 +5,7 @@ import { Column } from "primereact/column";
import { useImageProxy } from "@/hooks/useImageProxy";
import { useSession } from 'next-auth/react';
import { ProgressSpinner } from "primereact/progressspinner";
import PurchasedListItem from "@/components/profile/PurchasedListItem";
import ProgressListItem from "@/components/content/lists/ProgressListItem";
import { useNDKContext } from "@/context/NDKContext";
import { formatDateTime } from "@/utils/time";
import { Tooltip } from "primereact/tooltip";
@ -15,6 +15,7 @@ import GithubContributionChart from "@/components/charts/GithubContributionChart
import useWindowWidth from "@/hooks/useWindowWidth";
import { useToast } from "@/hooks/useToast";
import UserProgress from "@/components/profile/progress/UserProgress";
import { classNames } from "primereact/utils";
const UserProfile = () => {
const windowWidth = useWindowWidth();
@ -93,7 +94,7 @@ const UserProfile = () => {
) : (
<DataTable
emptyMessage="No Courses or Milestones completed"
value={session.user?.purchased}
value={session.user?.userCourses}
header={header}
style={{ maxWidth: windowWidth < 768 ? "100%" : "90%", margin: "0 auto", borderRadius: "10px" }}
pt={{
@ -105,15 +106,22 @@ const UserProfile = () => {
}
}}
>
<Column field="amountPaid" header="Cost"></Column>
<Column
field="completed"
header="Completed"
body={(rowData) => (
<i className={classNames('pi', {'pi-check-circle text-green-500': rowData.completed, 'pi-times-circle text-red-500': !rowData.completed})}></i>
)}
></Column>
<Column
body={(rowData) => {
console.log("rowData", rowData);
return <PurchasedListItem eventId={rowData?.resource?.noteId || rowData?.course?.noteId} category={rowData?.course ? "courses" : "resources"} />
return <ProgressListItem dTag={rowData.courseId} category="name" />
}}
header="Name"
></Column>
<Column body={session.user?.purchased?.some((item) => item.courseId) ? "course" : "resource"} header="Category"></Column>
<Column body={(rowData) => {
return <ProgressListItem dTag={rowData.courseId} category="lessons" />
}} header="Lessons"></Column>
<Column body={rowData => formatDateTime(rowData?.createdAt)} header="Date"></Column>
</DataTable>
)}

View File

@ -8,7 +8,7 @@ import { ProgressSpinner } from "primereact/progressspinner";
import { useNDKContext } from "@/context/NDKContext";
import useWindowWidth from "@/hooks/useWindowWidth";
import Image from "next/image";
import PurchasedListItem from "@/components/profile/PurchasedListItem";
import PurchasedListItem from "@/components/content/lists/PurchasedListItem";
import { formatDateTime } from "@/utils/time";
import BitcoinConnectButton from "@/components/bitcoinConnect/BitcoinConnect";
import { Panel } from "primereact/panel";

View File

@ -1,11 +1,55 @@
import React, { useState, useEffect } from 'react';
import { ProgressBar } from 'primereact/progressbar';
import { Accordion, AccordionTab } from 'primereact/accordion';
import { useSession } from 'next-auth/react';
const UserProgress = () => {
const [progress, setProgress] = useState(0);
const [currentTier, setCurrentTier] = useState('Pleb');
const [expanded, setExpanded] = useState(null);
const [completedCourses, setCompletedCourses] = useState([]);
const [tasks, setTasks] = useState([]);
const { data: session } = useSession();
useEffect(() => {
if (session?.user) {
const user = session.user;
const ids = user?.userCourses.map(course => course?.completed ? course.courseId : null).filter(id => id !== null);
setCompletedCourses(ids);
generateTasks(ids);
}
}, [session]);
const generateTasks = (completedCourseIds) => {
const allTasks = [
{ status: 'Create Account', completed: true, tier: 'Pleb', courseId: null },
{
status: 'Complete PlebDevs Starter',
completed: false,
tier: 'New Dev',
courseId: null,
subTasks: [
{ status: 'Connect GitHub', completed: false },
{ status: 'Create First GitHub Repo', completed: false },
{ status: 'Push Commit', completed: false }
]
},
{ status: 'Complete PlebDevs Course 1', completed: false, tier: 'Junior Dev', courseId: 'd20e2e9b-5123-4a91-b27f-d75ea1d5811e' },
{ status: 'Complete PlebDevs Course 2', completed: false, tier: 'Plebdev', courseId: 'aa3b1641-ad2b-4ef4-9f0f-38951ae307b7' },
];
const updatedTasks = allTasks.map(task => ({
...task,
completed: task.courseId === null || completedCourseIds.includes(task.courseId),
subTasks: task.subTasks ? task.subTasks.map(subTask => ({
...subTask,
completed: completedCourseIds.includes(task.courseId)
})) : undefined
}));
setTasks(updatedTasks);
};
const getProgress = async () => {
return 10;
@ -15,8 +59,6 @@ const UserProgress = () => {
return 'Pleb';
};
useEffect(() => {
// Fetch progress and current tier from backend
// For now, let's assume we have a function to get these values
const fetchProgress = async () => {
const progress = await getProgress();
const currentTier = await getCurrentTier();
@ -27,22 +69,6 @@ const UserProgress = () => {
fetchProgress();
}, []);
const tasks = [
{ status: 'Create Account', completed: true, tier: 'Pleb' },
{
status: 'Complete PlebDevs Starter',
completed: false,
tier: 'New Dev',
subTasks: [
{ status: 'Connect GitHub', completed: false },
{ status: 'Create First GitHub Repo', completed: false },
{ status: 'Push Commit', completed: false }
]
},
{ status: 'Complete PlebDevs Course 1', completed: false, tier: 'Junior Dev' },
{ status: 'Complete PlebDevs Course 2', completed: false, tier: 'Plebdev' },
];
return (
<div className="bg-gray-800 rounded-3xl p-6 w-[500px] mx-auto my-8">
<h1 className="text-3xl font-bold text-white mb-2">Your Dev Journey</h1>

View File

@ -11,11 +11,13 @@ const useTrackCourse = ({courseId, paidCourse, decryptionPerformed}) => {
if (!session?.user || !courseId) return false;
try {
const response = await axios.get(`/api/users/${session.user.id}/courses/${courseId}`);
// fix this condition?
if (response.status === 200 && response?.data) {
setIsCompleted(true);
completedRef.current = true;
} else if (response.status === 204) {
// Only create a new UserCourse entry if it's a free course or if decryption has been performed for a paid course
console.log("about to create new UserCourse entry", paidCourse, decryptionPerformed);
if (paidCourse === false || (paidCourse && decryptionPerformed)) {
console.log("creating new UserCourse entry");
await axios.post(`/api/users/${session.user.id}/courses?courseSlug=${courseId}`, {

View File

@ -19,6 +19,8 @@ const MDDisplay = dynamic(() => import("@uiw/react-markdown-preview"), { ssr: fa
const useCourseData = (ndk, fetchAuthor, router) => {
const [course, setCourse] = useState(null);
const [lessonIds, setLessonIds] = useState([]);
const [paidCourse, setPaidCourse] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (router.isReady) {
@ -26,6 +28,7 @@ const useCourseData = (ndk, fetchAuthor, router) => {
const { data } = nip19.decode(slug);
if (!data) {
showToast('error', 'Error', 'Course not found');
setLoading(false);
return;
}
const id = data?.identifier;
@ -41,9 +44,12 @@ const useCourseData = (ndk, fetchAuthor, router) => {
setLessonIds(lessonIds);
const parsedCourse = { ...parseCourseEvent(event), author };
setCourse(parsedCourse);
setPaidCourse(parsedCourse.price && parsedCourse.price > 0);
}
setLoading(false);
} catch (error) {
console.error('Error fetching event:', error);
setLoading(false);
}
};
if (ndk && id) {
@ -52,7 +58,7 @@ const useCourseData = (ndk, fetchAuthor, router) => {
}
}, [router.isReady, router.query, ndk, fetchAuthor]);
return { course, lessonIds };
return { course, lessonIds, paidCourse, loading };
};
const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => {
@ -64,7 +70,7 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => {
const fetchLesson = async (lessonId) => {
try {
await ndk.connect();
const filter = { "#d": [lessonId], kinds:[30023, 30402], authors: [pubkey] };
const filter = { "#d": [lessonId], kinds: [30023, 30402], authors: [pubkey] };
const event = await ndk.fetchEvent(filter);
if (event) {
const author = await fetchAuthor(event.pubkey);
@ -107,7 +113,7 @@ const useDecryption = (session, paidCourse, course, lessons, setLessons) => {
const decrypt = async () => {
if (session?.user && paidCourse && !decryptionPerformed) {
setLoading(true);
const canAccess =
const canAccess =
session.user.purchased?.some(purchase => purchase.courseId === course?.d) ||
session.user?.role?.subscribed ||
session.user?.pubkey === course?.pubkey;
@ -139,7 +145,6 @@ const Course = () => {
const { ndk, addSigner } = useNDKContext();
const { data: session, update } = useSession();
const { showToast } = useToast();
const [paidCourse, setPaidCourse] = useState(null);
const [expandedIndex, setExpandedIndex] = useState(null);
const [completedLessons, setCompletedLessons] = useState([]);
@ -155,20 +160,14 @@ const Course = () => {
return fields;
}, [ndk]);
const { course, lessonIds } = useCourseData(ndk, fetchAuthor, router);
const { course, lessonIds, paidCourse, loading: courseLoading } = useCourseData(ndk, fetchAuthor, router);
const { lessons, uniqueLessons, setLessons } = useLessons(ndk, fetchAuthor, lessonIds, course?.pubkey);
const { decryptionPerformed, loading } = useDecryption(session, paidCourse, course, lessons, setLessons);
const { decryptionPerformed, loading: decryptionLoading } = useDecryption(session, paidCourse, course, lessons, setLessons);
useEffect(() => {
console.log('lessonIds', lessonIds);
}, [lessonIds]);
useEffect(() => {
if (course?.price && course?.price > 0) {
setPaidCourse(true);
}
}, [course]);
useEffect(() => {
if (router.isReady) {
const { active } = router.query;
@ -183,7 +182,7 @@ const Course = () => {
const handleAccordionChange = (e) => {
const newIndex = e.index === expandedIndex ? null : e.index;
setExpandedIndex(newIndex);
if (newIndex !== null) {
router.push(`/course/${router.query.slug}?active=${newIndex}`, undefined, { shallow: true });
} else {
@ -206,7 +205,7 @@ const Course = () => {
showToast('error', 'Payment Error', `Failed to purchase course. Please try again. Error: ${error}`);
}
if (loading) {
if (courseLoading || decryptionLoading) {
return (
<div className='w-full h-full flex items-center justify-center'><ProgressSpinner /></div>
);
@ -214,21 +213,23 @@ const Course = () => {
return (
<>
<CourseDetailsNew
processedEvent={course}
paidCourse={paidCourse}
lessons={uniqueLessons}
decryptionPerformed={decryptionPerformed}
handlePaymentSuccess={handlePaymentSuccess}
handlePaymentError={handlePaymentError}
/>
<Accordion
activeIndex={expandedIndex}
{course && paidCourse !== null && (
<CourseDetailsNew
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
<AccordionTab
key={index}
pt={{
root: { className: 'border-none' },
@ -245,8 +246,8 @@ const Course = () => {
}
>
<div className="w-full py-4 rounded-b-lg">
{lesson.type === 'video' ?
<VideoLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} setCompleted={setCompleted} /> :
{lesson.type === 'video' ?
<VideoLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} setCompleted={setCompleted} /> :
<DocumentLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} setCompleted={setCompleted} />
}
</div>

View File

@ -5,6 +5,7 @@ import VideosCarousel from '@/components/content/carousels/VideosCarousel';
import DocumentsCarousel from '@/components/content/carousels/DocumentsCarousel';
import InteractivePromotionalCarousel from '@/components/content/carousels/InteractivePromotionalCarousel';
// todo: make paid course videos and documents not appear in carousels
export default function Home() {
return (
<>