Segregate paid resources with paid courses and vice versa, fix paid field on workshop

This commit is contained in:
austinkelsay 2024-08-17 12:56:27 -05:00
parent cee1679a8b
commit 374bef5a51
7 changed files with 105 additions and 59 deletions

View File

@ -31,7 +31,9 @@ export default function WorkshopsCarousel() {
const fetch = async () => {
try {
if (workshops && workshops.length > 0) {
console.log('workshops', workshops);
const processedWorkshops = workshops.map(workshop => parseEvent(workshop));
console.log('processedWorkshops', processedWorkshops);
setProcessedWorkshops(processedWorkshops);
} else {
console.log('No workshops fetched or empty array returned');

View File

@ -13,7 +13,8 @@ import { useNDKContext } from "@/context/NDKContext";
import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription';
import { findKind0Fields } from '@/utils/nostr';
import 'primeicons/primeicons.css';
import ResourcePaymentButton from "@/components/bitcoinConnect/ResourcePaymentButton";
import CoursePaymentButton from "@/components/bitcoinConnect/CoursePaymentButton";
import { ProgressSpinner } from 'primereact/progressspinner';
const MDDisplay = dynamic(
() => import("@uiw/react-markdown-preview"),
@ -22,7 +23,7 @@ const MDDisplay = dynamic(
}
);
export default function CourseDetails({ processedEvent, paidCourse, decryptedContent, handlePaymentSuccess, handlePaymentError }) {
export default function CourseDetails({ processedEvent, paidCourse, lessons, decryptionPerformed, handlePaymentSuccess, handlePaymentError }) {
const [author, setAuthor] = useState(null);
const [nAddress, setNAddress] = useState(null);
const [zapAmount, setZapAmount] = useState(0);
@ -45,6 +46,10 @@ export default function CourseDetails({ processedEvent, paidCourse, decryptedCon
console.log("paidCourse", paidCourse);
}, [paidCourse]);
useEffect(() => {
console.log("decryptionPerformed", decryptionPerformed);
}, [decryptionPerformed]);
useEffect(() => {
if (session) {
setUser(session.user);
@ -86,6 +91,14 @@ export default function CourseDetails({ processedEvent, paidCourse, decryptedCon
setZapAmount(total);
}, [zaps, processedEvent]);
if (!processedEvent || !author) {
return (
<div className="flex justify-center items-center h-screen">
<ProgressSpinner />
</div>
);
}
return (
<div className='w-full px-24 pt-12 mx-auto mt-4 max-tab:px-0 max-mob:px-0 max-tab:pt-2 max-mob:pt-2'>
<div className='w-full flex flex-row justify-between max-tab:flex-col max-mob:flex-col'>
@ -128,8 +141,8 @@ export default function CourseDetails({ processedEvent, paidCourse, decryptedCon
className="w-[344px] h-[194px] object-cover object-top rounded-lg"
/>
<div className='w-full flex justify-between items-center'>
{paidCourse && !decryptedContent && (
<ResourcePaymentButton
{paidCourse && !decryptionPerformed && (
<CoursePaymentButton
lnAddress={'bitcoinplebdev@stacker.news'}
amount={processedEvent.price}
onSuccess={handlePaymentSuccess}
@ -137,7 +150,7 @@ export default function CourseDetails({ processedEvent, paidCourse, decryptedCon
resourceId={processedEvent.d}
/>
)}
{paidCourse && decryptedContent && author && processedEvent?.pubkey !== session?.user?.pubkey && (
{paidCourse && decryptionPerformed && author && processedEvent?.pubkey !== session?.user?.pubkey && (
<p className='text-green-500'>Paid {processedEvent.price} sats</p>
)}
{paidCourse && author && processedEvent?.pubkey === session?.user?.pubkey && (

View File

@ -10,14 +10,6 @@ const ContentListItem = (content) => {
const isDraft = Object.keys(content).includes('type');
const isCourse = content && content?.kind === 30004;
useEffect(() => {
if (content && content?.kind === 30004) {
console.log("isDraft", isDraft);
console.log("content", content);
console.log("isCourse", isCourse);
}
}, [content, isDraft, isCourse]);
const handleClick = () => {
let path = '';

View File

@ -26,8 +26,8 @@ import 'primeicons/primeicons.css';
const CourseForm = ({ draft = null, isPublished = false }) => {
const [title, setTitle] = useState('');
const [summary, setSummary] = useState('');
const [checked, setChecked] = useState(false);
const [price, setPrice] = useState(0);
const [isPaidCourse, setIsPaidCourse] = useState(draft?.price ? true : false);
const [price, setPrice] = useState(draft?.price || 0);
const [coverImage, setCoverImage] = useState('');
const [lessons, setLessons] = useState([{ id: uuidv4(), title: 'Select a lesson' }]);
const [loadingLessons, setLoadingLessons] = useState(true);
@ -89,7 +89,7 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
console.log('draft:', draft);
setTitle(draft.title);
setSummary(draft.summary);
setChecked(draft.price > 0);
setIsPaidCourse(draft.price > 0);
setPrice(draft.price || 0);
setCoverImage(draft.image);
// setSelectedLessons(draft.resources || []);
@ -97,6 +97,10 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
}
}, [draft]);
const handlePriceChange = (value) => {
setPrice(value);
};
const handleSubmit = async (e) => {
e.preventDefault();
@ -198,12 +202,20 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
if (resourcesLoading || !resources || workshopsLoading || !workshops || draftsLoading || !drafts) {
return [];
}
const draftOptions = drafts.map(draft => ({
const filterContent = (content) => {
console.log('contentttttt', content);
// If there is price in content.tags, then it is a paid content 'price' in the 0 index and stringified int in the 1 index
const contentPrice = content.tags.find(tag => tag[0] === 'price') ? parseInt(content.tags.find(tag => tag[0] === 'price')[1]) : 0;
return isPaidCourse ? contentPrice > 0 : contentPrice === 0;
};
const draftOptions = drafts.filter(filterContent).map(draft => ({
label: <ContentDropdownItem content={draft} onSelect={(content) => handleLessonSelect(content, index)} selected={lessons[index] && lessons[index].id === draft.id} />,
value: draft.id
}));
const resourceOptions = resources.map(resource => {
const resourceOptions = resources.filter(filterContent).map(resource => {
const { id, kind, pubkey, content, title, summary, image, published_at, d, topics } = parseEvent(resource);
return {
label: <ContentDropdownItem content={{ id, kind, pubkey, content, title, summary, image, published_at, d, topics }} onSelect={(content) => handleLessonSelect(content, index)} selected={lessons[index] && lessons[index].id === id} />,
@ -211,7 +223,7 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
};
});
const workshopOptions = workshops.map(workshop => {
const workshopOptions = workshops.filter(filterContent).map(workshop => {
const { id, kind, pubkey, content, title, summary, image, published_at, d, topics } = parseEvent(workshop);
return {
label: <ContentDropdownItem content={{ id, kind, pubkey, content, title, summary, image, published_at, d, topics }} onSelect={(content) => handleLessonSelect(content, index)} selected={lessons[index] && lessons[index].id === id} />,
@ -251,12 +263,17 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
<div className="p-inputgroup flex-1 mt-4">
<InputText value={coverImage} onChange={(e) => setCoverImage(e.target.value)} placeholder="Cover Image URL" />
</div>
<div className="p-inputgroup flex-1 mt-4 flex-col">
<div className="p-inputgroup flex-1 mt-8 flex-col">
<p className="py-2">Paid Course</p>
<InputSwitch checked={checked} onChange={(e) => setChecked(e.value)} />
{checked && (
<InputSwitch checked={isPaidCourse} onChange={(e) => setIsPaidCourse(e.value)} />
{isPaidCourse && (
<div className="p-inputgroup flex-1 py-4">
<InputNumber value={price} onValueChange={(e) => setPrice(e.value)} placeholder="Price (sats)" />
<InputNumber
value={price}
onValueChange={(e) => handlePriceChange(e.value)}
placeholder="Price (sats)"
min={1}
/>
</div>
)}
</div>

View File

@ -25,6 +25,7 @@ const EditCourseForm = ({ draft }) => {
const [summary, setSummary] = useState('');
const [checked, setChecked] = useState(false);
const [price, setPrice] = useState(0);
const [isPaid, setIsPaid] = useState(false);
const [coverImage, setCoverImage] = useState('');
const [selectedLessons, setSelectedLessons] = useState([]);
const [selectedLessonsLoading, setSelectedLessonsLoading] = useState(false);
@ -63,11 +64,17 @@ const EditCourseForm = ({ draft }) => {
setSummary(draft.summary);
setChecked(draft.price > 0);
setPrice(draft.price || 0);
setIsPaid(draft.price > 0);
setCoverImage(draft.image);
setTopics(draft.topics || ['']);
}
}, [draft, ndk, showToast, parseEvent]);
const handlePriceChange = (value) => {
setPrice(value);
setIsPaid(value > 0);
};
const handleSubmit = async (e) => {
e.preventDefault();
@ -90,7 +97,7 @@ const EditCourseForm = ({ draft }) => {
title,
summary,
image: coverImage,
price: checked ? price : 0,
price: isPaid ? price : 0,
topics,
resourceIds: lessonsToUpdate.filter(lesson => lesson && lesson.id).map(lesson => lesson.id)
};
@ -137,7 +144,12 @@ const EditCourseForm = ({ draft }) => {
return [];
}
const resourceOptions = resources.map(resource => {
const filterContent = (content) => {
const contentPrice = content.price || 0;
return isPaid ? contentPrice > 0 : contentPrice === 0;
};
const resourceOptions = resources.filter(filterContent).map(resource => {
const parsedResource = parseEvent(resource);
return {
label: <ContentDropdownItem content={parsedResource} onSelect={handleLessonSelect} selected={selectedLessons.some(lesson => lesson.id === parsedResource.id)} />,
@ -145,7 +157,7 @@ const EditCourseForm = ({ draft }) => {
};
});
const workshopOptions = workshops.map(workshop => {
const workshopOptions = workshops.filter(filterContent).map(workshop => {
const parsedWorkshop = parseEvent(workshop);
return {
label: <ContentDropdownItem content={parsedWorkshop} onSelect={handleLessonSelect} selected={selectedLessons.some(lesson => lesson.id === parsedWorkshop.id)} />,
@ -179,7 +191,7 @@ const EditCourseForm = ({ draft }) => {
<InputSwitch checked={checked} onChange={(e) => setChecked(e.value)} />
{checked && (
<div className="p-inputgroup flex-1 py-4">
<InputNumber value={price} onValueChange={(e) => setPrice(e.value)} placeholder="Price (sats)" />
<InputNumber value={price} onValueChange={(e) => handlePriceChange(e.value)} placeholder="Price (sats)" />
</div>
)}
</div>

View File

@ -8,6 +8,7 @@ import { useNDKContext } from "@/context/NDKContext";
import { useToast } from '@/hooks/useToast';
import { useSession } from 'next-auth/react';
import { nip04 } from 'nostr-tools';
import { ProgressSpinner } from 'primereact/progressspinner';
const MDDisplay = dynamic(
() => import("@uiw/react-markdown-preview"),
@ -21,12 +22,13 @@ const Course = () => {
const [lessonIds, setLessonIds] = useState([]);
const [lessons, setLessons] = useState([]);
const [paidCourse, setPaidCourse] = useState(false);
const [decryptedContent, setDecryptedContent] = useState(null);
const [decryptionPerformed, setDecryptionPerformed] = useState(false);
const [loading, setLoading] = useState(true);
const router = useRouter();
const {ndk, addSigner} = useNDKContext();
const { data: session, update } = useSession();
const { showToast } = useToast();
const privkey = process.env.NEXT_PUBLIC_APP_PRIV_KEY;
const pubkey = process.env.NEXT_PUBLIC_APP_PUBLIC_KEY;
@ -112,21 +114,37 @@ const Course = () => {
useEffect(() => {
const decryptContent = async () => {
if (session?.user && paidCourse) {
if (session.user?.purchased?.length > 0) {
const purchasedCourse = session.user.purchased.find(purchase => purchase.resourceId === course.d);
if (purchasedCourse) {
const decryptedContent = await nip04.decrypt(privkey, pubkey, course.content);
setDecryptedContent(decryptedContent);
if (session?.user && paidCourse && !decryptionPerformed) {
setLoading(true);
const canAccess =
session.user.purchased?.some(purchase => purchase.courseId === course?.d) ||
session.user?.role?.subscribed ||
session.user?.pubkey === course?.pubkey;
if (canAccess && lessons.length > 0) {
try {
const decryptedLessons = await Promise.all(lessons.map(async (lesson) => {
const decryptedContent = await nip04.decrypt(privkey, pubkey, lesson.content);
return { ...lesson, content: decryptedContent };
}));
setLessons(decryptedLessons);
setDecryptionPerformed(true);
} catch (error) {
console.error('Error decrypting lessons:', error);
}
} else if (session.user?.role && session.user.role.subscribed) {
const decryptedContent = await nip04.decrypt(privkey, pubkey, course.content);
setDecryptedContent(decryptedContent);
}
setLoading(false);
}
setLoading(false);
}
decryptContent();
}, [session, paidCourse, course]);
}, [session, paidCourse, course, lessons, privkey, pubkey, decryptionPerformed]);
useEffect(() => {
if (course && lessons.length > 0 && (!paidCourse || decryptionPerformed)) {
setLoading(false);
}
}, [course, lessons, paidCourse, decryptionPerformed]);
const handlePaymentSuccess = async (response, newCourse) => {
if (response && response?.preimage) {
@ -142,12 +160,21 @@ const Course = () => {
showToast('error', 'Payment Error', `Failed to purchase course. Please try again. Error: ${error}`);
}
if (loading) {
return (
<div className="flex justify-center items-center h-screen">
<ProgressSpinner />
</div>
);
}
return (
<>
<CourseDetails
processedEvent={course}
paidCourse={paidCourse}
decryptedContent={decryptedContent}
lessons={lessons}
decryptionPerformed={decryptionPerformed}
handlePaymentSuccess={handlePaymentSuccess}
handlePaymentError={handlePaymentError}
/>
@ -155,9 +182,7 @@ const Course = () => {
<CourseLesson key={index} lesson={lesson} course={course} />
))}
<div className="mx-auto my-6">
{
course?.content && <MDDisplay source={course.content} />
}
{course?.content && <MDDisplay source={course.content} />}
</div>
</>
);

View File

@ -227,26 +227,11 @@ export default function Draft() {
['image', draft.image],
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []),
];
type = 'workshop';
break;
case 'course':
event.kind = 30023;
event.content = draft.content;
event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = user.pubkey;
event.tags = [
['d', NewDTag],
['title', draft.title],
['summary', draft.summary],
['image', draft.image],
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
];
type = 'course';
break;
default:
return null;
}