Paid course creation, got started on rendering paid course details

This commit is contained in:
austinkelsay 2024-08-16 18:00:46 -05:00
parent 4232c1963b
commit cee1679a8b
8 changed files with 280 additions and 11 deletions

View File

@ -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 (
<div className="flex items-center">
{invoice ? (
<PayButton
invoice={invoice.paymentRequest}
onClick={handleModalOpen}
onPaid={handlePaymentSuccess}
onModalClose={handleModalClose}
title={`Pay ${amount} sats`}
>
Pay Now
</PayButton>
) : (
<button disabled className="p-2 bg-gray-500 text-white rounded">
Loading...
</button>
)}
<span className="ml-2 text-white text-lg">{amount} sats</span>
</div>
);
};
export default CoursePaymentButton;

View File

@ -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 && (
<div className='flex flex-col items-center justify-between rounded-lg h-72 p-4 bg-gray-700 drop-shadow-md'>
<Image
alt="resource thumbnail"
alt="course thumbnail"
src={returnImageProxy(processedEvent.image)}
width={344}
height={194}
className="w-[344px] h-[194px] object-cover object-top rounded-lg"
/>
<div className='w-full flex justify-end'>
<ZapDisplay zapAmount={zapAmount} event={processedEvent} zapsLoading={zapsLoading} />
</div>
<div className='w-full flex justify-between items-center'>
{paidCourse && !decryptedContent && (
<ResourcePaymentButton
lnAddress={'bitcoinplebdev@stacker.news'}
amount={processedEvent.price}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
resourceId={processedEvent.d}
/>
)}
{paidCourse && decryptedContent && author && processedEvent?.pubkey !== session?.user?.pubkey && (
<p className='text-green-500'>Paid {processedEvent.price} sats</p>
)}
{paidCourse && author && processedEvent?.pubkey === session?.user?.pubkey && (
<p className='text-green-500'>Price {processedEvent.price} sats</p>
)}
<ZapDisplay zapAmount={zapAmount} event={processedEvent} zapsLoading={zapsLoading} />
</div>
</div>
)}
</div>
@ -139,4 +167,4 @@ export default function CourseDetails({ processedEvent }) {
</div>
</div>
);
}
}

View File

@ -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;

View File

@ -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,
};

View File

@ -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,

View File

@ -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`);
}
}

View File

@ -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 (
<>
<CourseDetails processedEvent={course} />
<CourseDetails
processedEvent={course}
paidCourse={paidCourse}
decryptedContent={decryptedContent}
handlePaymentSuccess={handlePaymentSuccess}
handlePaymentError={handlePaymentError}
/>
{lessons.length > 0 && lessons.map((lesson, index) => (
<CourseLesson key={index} lesson={lesson} course={course} />
))}

View File

@ -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