diff --git a/src/components/bitcoinConnect/CoursePaymentButton.js b/src/components/bitcoinConnect/CoursePaymentButton.js new file mode 100644 index 0000000..2269366 --- /dev/null +++ b/src/components/bitcoinConnect/CoursePaymentButton.js @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from 'react'; +import dynamic from 'next/dynamic'; +import { initializeBitcoinConnect } from './BitcoinConnect'; +import { LightningAddress } from '@getalby/lightning-tools'; +import { useToast } from '@/hooks/useToast'; +import { useSession } from 'next-auth/react'; +import axios from 'axios'; // Import axios for API calls + +const PayButton = dynamic( + () => import('@getalby/bitcoin-connect-react').then((mod) => mod.PayButton), + { + ssr: false, + } +); + +const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId }) => { + const [invoice, setInvoice] = useState(null); + const [userId, setUserId] = useState(null); + const { showToast } = useToast(); + const [pollingInterval, setPollingInterval] = useState(null); + const { data: session } = useSession(); + + useEffect(() => { + if (session?.user) { + setUserId(session.user.id); + } + }, [session]); + + useEffect(() => { + initializeBitcoinConnect(); + }, []); + + useEffect(() => { + const fetchInvoice = async () => { + try { + const ln = new LightningAddress(lnAddress); + await ln.fetch(); + const invoice = await ln.requestInvoice({ satoshi: amount }); + setInvoice(invoice); + } catch (error) { + console.error('Error fetching invoice:', error); + showToast('error', 'Invoice Error', 'Failed to fetch the invoice.'); + if (onError) onError(error); + } + }; + + fetchInvoice(); + }, [lnAddress, amount, onError, showToast]); + + const startPolling = (invoice) => { + const intervalId = setInterval(async () => { + try { + const paid = await invoice.verifyPayment(); + console.log('Polling for payment - Paid:', paid); + if (paid) { + clearInterval(intervalId); // Stop polling + handlePaymentSuccess(invoice); + } + } catch (error) { + console.error('Polling error:', error); + clearInterval(intervalId); // Stop polling on error + handlePaymentError(error); + } + }, 5000); // Poll every 5 seconds + + setPollingInterval(intervalId); + }; + + const stopPolling = () => { + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + }; + + const handlePaymentSuccess = async (response) => { + stopPolling(); + await closeModal(); + + try { + const purchaseData = { + userId: userId, + courseId: courseId, + amountPaid: parseInt(amount, 10) + }; + + const result = await axios.post('/api/purchase/course', purchaseData); + + if (result.status === 200) { + showToast('success', 'Payment Successful', `Paid ${amount} sats and updated user purchases`); + if (onSuccess) onSuccess(response); + } else { + throw new Error('Failed to update user purchases'); + } + } catch (error) { + console.error('Error updating user purchases:', error); + showToast('error', 'Purchase Update Failed', 'Payment was successful, but failed to update user purchases.'); + if (onError) onError(error); + } + }; + + const handlePaymentError = (error) => { + console.error('Payment failed:', error); + showToast('error', 'Payment Failed', error.message || 'An error occurred during payment.'); + if (onError) onError(error); + stopPolling(); // Stop polling on error + }; + + const handleModalOpen = () => { + console.log('Modal opened'); + if (invoice) { + startPolling(invoice); // Start polling when modal is opened + } + }; + + const handleModalClose = () => { + console.log('Modal closed'); + stopPolling(); // Stop polling when modal is closed + }; + + const closeModal = async () => { + const { closeModal } = await import('@getalby/bitcoin-connect-react'); + closeModal(); + }; + + return ( +
+ {invoice ? ( + + Pay Now + + ) : ( + + )} + {amount} sats +
+ ); +}; + +export default CoursePaymentButton; diff --git a/src/components/content/courses/CourseDetails.js b/src/components/content/courses/CourseDetails.js index ffb61dd..b5f3634 100644 --- a/src/components/content/courses/CourseDetails.js +++ b/src/components/content/courses/CourseDetails.js @@ -4,7 +4,7 @@ import { useImageProxy } from '@/hooks/useImageProxy'; import ZapDisplay from '@/components/zaps/ZapDisplay'; import { getTotalFromZaps } from '@/utils/lightning'; import { Tag } from 'primereact/tag'; -import { nip19 } from 'nostr-tools'; +import { nip19, nip04 } from 'nostr-tools'; import { useSession } from 'next-auth/react'; import Image from 'next/image'; import dynamic from 'next/dynamic'; @@ -13,6 +13,7 @@ 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"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -21,7 +22,7 @@ const MDDisplay = dynamic( } ); -export default function CourseDetails({ processedEvent }) { +export default function CourseDetails({ processedEvent, paidCourse, decryptedContent, handlePaymentSuccess, handlePaymentError }) { const [author, setAuthor] = useState(null); const [nAddress, setNAddress] = useState(null); const [zapAmount, setZapAmount] = useState(0); @@ -32,6 +33,18 @@ export default function CourseDetails({ processedEvent }) { const router = useRouter(); const {ndk, addSigner} = useNDKContext(); + useEffect(() => { + console.log("processedEvent", processedEvent); + }, [processedEvent]); + + useEffect(() => { + console.log("zaps", zaps); + }, [zaps]); + + useEffect(() => { + console.log("paidCourse", paidCourse); + }, [paidCourse]); + useEffect(() => { if (session) { setUser(session.user); @@ -108,15 +121,30 @@ export default function CourseDetails({ processedEvent }) { {processedEvent && (
resource thumbnail -
- -
+
+ {paidCourse && !decryptedContent && ( + + )} + {paidCourse && decryptedContent && author && processedEvent?.pubkey !== session?.user?.pubkey && ( +

Paid {processedEvent.price} sats

+ )} + {paidCourse && author && processedEvent?.pubkey === session?.user?.pubkey && ( +

Price {processedEvent.price} sats

+ )} + +
)} @@ -139,4 +167,4 @@ export default function CourseDetails({ processedEvent }) { ); -} +} \ No newline at end of file diff --git a/src/components/content/courses/DraftCourseDetails.js b/src/components/content/courses/DraftCourseDetails.js index 4d26c00..bf9ec73 100644 --- a/src/components/content/courses/DraftCourseDetails.js +++ b/src/components/content/courses/DraftCourseDetails.js @@ -85,7 +85,7 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons }) } // Step 2: Create and publish course - const courseEvent = createCourseEvent(newCourseId, processedEvent.title, processedEvent.summary, processedEvent.image, processedLessons); + const courseEvent = createCourseEvent(newCourseId, processedEvent.title, processedEvent.summary, processedEvent.image, processedLessons, processedEvent.price); const published = await courseEvent.publish(); console.log('published', published); @@ -124,7 +124,7 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons }) } }; - const createCourseEvent = (courseId, title, summary, coverImage, lessons) => { + const createCourseEvent = (courseId, title, summary, coverImage, lessons, price) => { const event = new NDKEvent(ndk); event.kind = 30004; event.content = ""; @@ -135,6 +135,7 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons }) ['image', coverImage], ['description', summary], ['l', "Education"], + ['price', price.toString()], ...lessons.map((lesson) => ['a', `${lesson.kind}:${lesson.pubkey}:${lesson.d}`]), ]; return event; diff --git a/src/components/forms/CourseForm.js b/src/components/forms/CourseForm.js index 0c55e22..991909a 100644 --- a/src/components/forms/CourseForm.js +++ b/src/components/forms/CourseForm.js @@ -22,6 +22,7 @@ import 'primeicons/primeicons.css'; // todo dont allow adding courses as resources // todo need to update how I handle unpubbed resources +// todo add back topics const CourseForm = ({ draft = null, isPublished = false }) => { const [title, setTitle] = useState(''); const [summary, setSummary] = useState(''); @@ -111,7 +112,7 @@ const CourseForm = ({ draft = null, isPublished = false }) => { title, summary, image: coverImage, - price: checked ? price : 0, + price: price || 0, topics, }; diff --git a/src/db/models/userModels.js b/src/db/models/userModels.js index 820ed07..df0482d 100644 --- a/src/db/models/userModels.js +++ b/src/db/models/userModels.js @@ -65,6 +65,20 @@ export const addResourcePurchaseToUser = async (userId, purchaseData) => { }); }; +export const addCoursePurchaseToUser = async (userId, purchaseData) => { + return await prisma.user.update({ + where: { id: userId }, + data: { + purchased: { + create: { + courseId: purchaseData.courseId, + amountPaid: purchaseData.amountPaid, + }, + }, + }, + }); + }; + export const createUser = async (data) => { return await prisma.user.create({ data, diff --git a/src/pages/api/purchase/course.js b/src/pages/api/purchase/course.js new file mode 100644 index 0000000..499da96 --- /dev/null +++ b/src/pages/api/purchase/course.js @@ -0,0 +1,21 @@ +import { addCoursePurchaseToUser } from "@/db/models/userModels"; + +export default async function handler(req, res) { + if (req.method === 'POST') { + try { + const { userId, courseId, amountPaid } = req.body; + + const updatedUser = await addCoursePurchaseToUser(userId, { + courseId, + amountPaid: parseInt(amountPaid, 10) + }); + + res.status(200).json(updatedUser); + } catch (error) { + res.status(500).json({ error: error.message }); + } + } else { + res.setHeader('Allow', ['POST']); + res.status(405).end(`Method ${req.method} Not Allowed`); + } +} \ No newline at end of file diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index a441cc7..a26b1fa 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -5,6 +5,9 @@ import CourseDetails from "@/components/content/courses/CourseDetails"; import CourseLesson from "@/components/content/courses/CourseLesson"; import dynamic from 'next/dynamic'; import { useNDKContext } from "@/context/NDKContext"; +import { useToast } from '@/hooks/useToast'; +import { useSession } from 'next-auth/react'; +import { nip04 } from 'nostr-tools'; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -17,9 +20,15 @@ const Course = () => { const [course, setCourse] = useState(null); const [lessonIds, setLessonIds] = useState([]); const [lessons, setLessons] = useState([]); + const [paidCourse, setPaidCourse] = useState(false); + const [decryptedContent, setDecryptedContent] = useState(null); 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; const fetchAuthor = useCallback(async (pubkey) => { const author = await ndk.getUser({ pubkey }); @@ -95,9 +104,53 @@ const Course = () => { } }, [lessonIds, ndk, fetchAuthor]); + useEffect(() => { + if (course?.price) { + setPaidCourse(true); + } + }, [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); + } + } else if (session.user?.role && session.user.role.subscribed) { + const decryptedContent = await nip04.decrypt(privkey, pubkey, course.content); + setDecryptedContent(decryptedContent); + } + } + } + decryptContent(); + }, [session, paidCourse, course]); + + const handlePaymentSuccess = async (response, newCourse) => { + if (response && response?.preimage) { + console.log("newCourse", newCourse); + const updated = await update(); + console.log("session after update", updated); + } else { + showToast('error', 'Error', 'Failed to purchase course. Please try again.'); + } + } + + const handlePaymentError = (error) => { + showToast('error', 'Payment Error', `Failed to purchase course. Please try again. Error: ${error}`); + } + return ( <> - + {lessons.length > 0 && lessons.map((lesson, index) => ( ))} diff --git a/src/utils/nostr.js b/src/utils/nostr.js index e6f6522..61d4f53 100644 --- a/src/utils/nostr.js +++ b/src/utils/nostr.js @@ -134,6 +134,9 @@ export const parseCourseEvent = (event) => { case 'd': eventData.d = tag[1]; break; + case 'price': + eventData.price = tag[1]; + break; // How do we get topics / tags? case 'l': // Grab index 1 and any subsequent elements in the array