mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-22 15:35:05 +00:00
Paid course creation, got started on rendering paid course details
This commit is contained in:
parent
4232c1963b
commit
cee1679a8b
148
src/components/bitcoinConnect/CoursePaymentButton.js
Normal file
148
src/components/bitcoinConnect/CoursePaymentButton.js
Normal 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;
|
@ -4,7 +4,7 @@ import { useImageProxy } from '@/hooks/useImageProxy';
|
|||||||
import ZapDisplay from '@/components/zaps/ZapDisplay';
|
import ZapDisplay from '@/components/zaps/ZapDisplay';
|
||||||
import { getTotalFromZaps } from '@/utils/lightning';
|
import { getTotalFromZaps } from '@/utils/lightning';
|
||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19, nip04 } from 'nostr-tools';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
@ -13,6 +13,7 @@ import { useNDKContext } from "@/context/NDKContext";
|
|||||||
import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription';
|
import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription';
|
||||||
import { findKind0Fields } from '@/utils/nostr';
|
import { findKind0Fields } from '@/utils/nostr';
|
||||||
import 'primeicons/primeicons.css';
|
import 'primeicons/primeicons.css';
|
||||||
|
import ResourcePaymentButton from "@/components/bitcoinConnect/ResourcePaymentButton";
|
||||||
|
|
||||||
const MDDisplay = dynamic(
|
const MDDisplay = dynamic(
|
||||||
() => import("@uiw/react-markdown-preview"),
|
() => 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 [author, setAuthor] = useState(null);
|
||||||
const [nAddress, setNAddress] = useState(null);
|
const [nAddress, setNAddress] = useState(null);
|
||||||
const [zapAmount, setZapAmount] = useState(0);
|
const [zapAmount, setZapAmount] = useState(0);
|
||||||
@ -32,6 +33,18 @@ export default function CourseDetails({ processedEvent }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {ndk, addSigner} = useNDKContext();
|
const {ndk, addSigner} = useNDKContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("processedEvent", processedEvent);
|
||||||
|
}, [processedEvent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("zaps", zaps);
|
||||||
|
}, [zaps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("paidCourse", paidCourse);
|
||||||
|
}, [paidCourse]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session) {
|
if (session) {
|
||||||
setUser(session.user);
|
setUser(session.user);
|
||||||
@ -108,15 +121,30 @@ export default function CourseDetails({ processedEvent }) {
|
|||||||
{processedEvent && (
|
{processedEvent && (
|
||||||
<div className='flex flex-col items-center justify-between rounded-lg h-72 p-4 bg-gray-700 drop-shadow-md'>
|
<div className='flex flex-col items-center justify-between rounded-lg h-72 p-4 bg-gray-700 drop-shadow-md'>
|
||||||
<Image
|
<Image
|
||||||
alt="resource thumbnail"
|
alt="course thumbnail"
|
||||||
src={returnImageProxy(processedEvent.image)}
|
src={returnImageProxy(processedEvent.image)}
|
||||||
width={344}
|
width={344}
|
||||||
height={194}
|
height={194}
|
||||||
className="w-[344px] h-[194px] object-cover object-top rounded-lg"
|
className="w-[344px] h-[194px] object-cover object-top rounded-lg"
|
||||||
/>
|
/>
|
||||||
<div className='w-full flex justify-end'>
|
<div className='w-full flex justify-between items-center'>
|
||||||
<ZapDisplay zapAmount={zapAmount} event={processedEvent} zapsLoading={zapsLoading} />
|
{paidCourse && !decryptedContent && (
|
||||||
</div>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -139,4 +167,4 @@ export default function CourseDetails({ processedEvent }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -85,7 +85,7 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Create and publish course
|
// 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();
|
const published = await courseEvent.publish();
|
||||||
|
|
||||||
console.log('published', published);
|
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);
|
const event = new NDKEvent(ndk);
|
||||||
event.kind = 30004;
|
event.kind = 30004;
|
||||||
event.content = "";
|
event.content = "";
|
||||||
@ -135,6 +135,7 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
|
|||||||
['image', coverImage],
|
['image', coverImage],
|
||||||
['description', summary],
|
['description', summary],
|
||||||
['l', "Education"],
|
['l', "Education"],
|
||||||
|
['price', price.toString()],
|
||||||
...lessons.map((lesson) => ['a', `${lesson.kind}:${lesson.pubkey}:${lesson.d}`]),
|
...lessons.map((lesson) => ['a', `${lesson.kind}:${lesson.pubkey}:${lesson.d}`]),
|
||||||
];
|
];
|
||||||
return event;
|
return event;
|
||||||
|
@ -22,6 +22,7 @@ import 'primeicons/primeicons.css';
|
|||||||
|
|
||||||
// todo dont allow adding courses as resources
|
// todo dont allow adding courses as resources
|
||||||
// todo need to update how I handle unpubbed resources
|
// todo need to update how I handle unpubbed resources
|
||||||
|
// todo add back topics
|
||||||
const CourseForm = ({ draft = null, isPublished = false }) => {
|
const CourseForm = ({ draft = null, isPublished = false }) => {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [summary, setSummary] = useState('');
|
const [summary, setSummary] = useState('');
|
||||||
@ -111,7 +112,7 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
|
|||||||
title,
|
title,
|
||||||
summary,
|
summary,
|
||||||
image: coverImage,
|
image: coverImage,
|
||||||
price: checked ? price : 0,
|
price: price || 0,
|
||||||
topics,
|
topics,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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) => {
|
export const createUser = async (data) => {
|
||||||
return await prisma.user.create({
|
return await prisma.user.create({
|
||||||
data,
|
data,
|
||||||
|
21
src/pages/api/purchase/course.js
Normal file
21
src/pages/api/purchase/course.js
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,9 @@ import CourseDetails from "@/components/content/courses/CourseDetails";
|
|||||||
import CourseLesson from "@/components/content/courses/CourseLesson";
|
import CourseLesson from "@/components/content/courses/CourseLesson";
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useNDKContext } from "@/context/NDKContext";
|
import { useNDKContext } from "@/context/NDKContext";
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { nip04 } from 'nostr-tools';
|
||||||
|
|
||||||
const MDDisplay = dynamic(
|
const MDDisplay = dynamic(
|
||||||
() => import("@uiw/react-markdown-preview"),
|
() => import("@uiw/react-markdown-preview"),
|
||||||
@ -17,9 +20,15 @@ const Course = () => {
|
|||||||
const [course, setCourse] = useState(null);
|
const [course, setCourse] = useState(null);
|
||||||
const [lessonIds, setLessonIds] = useState([]);
|
const [lessonIds, setLessonIds] = useState([]);
|
||||||
const [lessons, setLessons] = useState([]);
|
const [lessons, setLessons] = useState([]);
|
||||||
|
const [paidCourse, setPaidCourse] = useState(false);
|
||||||
|
const [decryptedContent, setDecryptedContent] = useState(null);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {ndk, addSigner} = useNDKContext();
|
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 fetchAuthor = useCallback(async (pubkey) => {
|
||||||
const author = await ndk.getUser({ pubkey });
|
const author = await ndk.getUser({ pubkey });
|
||||||
@ -95,9 +104,53 @@ const Course = () => {
|
|||||||
}
|
}
|
||||||
}, [lessonIds, ndk, fetchAuthor]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<CourseDetails processedEvent={course} />
|
<CourseDetails
|
||||||
|
processedEvent={course}
|
||||||
|
paidCourse={paidCourse}
|
||||||
|
decryptedContent={decryptedContent}
|
||||||
|
handlePaymentSuccess={handlePaymentSuccess}
|
||||||
|
handlePaymentError={handlePaymentError}
|
||||||
|
/>
|
||||||
{lessons.length > 0 && lessons.map((lesson, index) => (
|
{lessons.length > 0 && lessons.map((lesson, index) => (
|
||||||
<CourseLesson key={index} lesson={lesson} course={course} />
|
<CourseLesson key={index} lesson={lesson} course={course} />
|
||||||
))}
|
))}
|
||||||
|
@ -134,6 +134,9 @@ export const parseCourseEvent = (event) => {
|
|||||||
case 'd':
|
case 'd':
|
||||||
eventData.d = tag[1];
|
eventData.d = tag[1];
|
||||||
break;
|
break;
|
||||||
|
case 'price':
|
||||||
|
eventData.price = tag[1];
|
||||||
|
break;
|
||||||
// How do we get topics / tags?
|
// How do we get topics / tags?
|
||||||
case 'l':
|
case 'l':
|
||||||
// Grab index 1 and any subsequent elements in the array
|
// Grab index 1 and any subsequent elements in the array
|
||||||
|
Loading…
x
Reference in New Issue
Block a user