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 && (
-
-
-
+
+ {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