mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-05-18 16:02:25 +00:00
Start of individual content payment flow
This commit is contained in:
parent
b953b76785
commit
42a9d243ca
1
package-lock.json
generated
1
package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getalby/bitcoin-connect-react": "^3.5.3",
|
"@getalby/bitcoin-connect-react": "^3.5.3",
|
||||||
|
"@getalby/lightning-tools": "^5.0.3",
|
||||||
"@nostr-dev-kit/ndk": "^2.10.0",
|
"@nostr-dev-kit/ndk": "^2.10.0",
|
||||||
"@prisma/client": "^5.17.0",
|
"@prisma/client": "^5.17.0",
|
||||||
"@tanstack/react-query": "^5.51.21",
|
"@tanstack/react-query": "^5.51.21",
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getalby/bitcoin-connect-react": "^3.5.3",
|
"@getalby/bitcoin-connect-react": "^3.5.3",
|
||||||
|
"@getalby/lightning-tools": "^5.0.3",
|
||||||
"@nostr-dev-kit/ndk": "^2.10.0",
|
"@nostr-dev-kit/ndk": "^2.10.0",
|
||||||
"@prisma/client": "^5.17.0",
|
"@prisma/client": "^5.17.0",
|
||||||
"@tanstack/react-query": "^5.51.21",
|
"@tanstack/react-query": "^5.51.21",
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
"use client";
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
123
src/components/bitcoinConnect/PaymentButton.js
Normal file
123
src/components/bitcoinConnect/PaymentButton.js
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const PayButton = dynamic(
|
||||||
|
() => import('@getalby/bitcoin-connect-react').then((mod) => mod.PayButton),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const PaymentButton = ({ lnAddress, amount, onSuccess, onError }) => {
|
||||||
|
const [invoice, setInvoice] = useState(null);
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [pollingInterval, setPollingInterval] = useState(null);
|
||||||
|
|
||||||
|
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(); // Stop polling after success
|
||||||
|
|
||||||
|
// Close the modal
|
||||||
|
await closeModal();
|
||||||
|
|
||||||
|
// After the modal is closed, show the success toast
|
||||||
|
showToast('success', 'Payment Successful', `Paid ${amount} sats`);
|
||||||
|
if (onSuccess) onSuccess(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 PaymentButton;
|
@ -11,7 +11,7 @@ const ZapDisplay = ({ zapAmount, event, zapsLoading }) => {
|
|||||||
let timeout;
|
let timeout;
|
||||||
if (!zapsLoading && zapAmount === 0) {
|
if (!zapsLoading && zapAmount === 0) {
|
||||||
setExtraLoading(true);
|
setExtraLoading(true);
|
||||||
timeout = setTimeout(() => setExtraLoading(false), 5000);
|
timeout = setTimeout(() => setExtraLoading(false), 3000);
|
||||||
}
|
}
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [zapsLoading, zapAmount]);
|
}, [zapsLoading, zapAmount]);
|
||||||
|
@ -16,8 +16,25 @@ export default async function handler(req, res) {
|
|||||||
}
|
}
|
||||||
} else if (req.method === 'PUT') {
|
} else if (req.method === 'PUT') {
|
||||||
try {
|
try {
|
||||||
const resource = await updateResource(slug, req.body);
|
// Fetch the resource by ID to check if it's part of a course
|
||||||
res.status(200).json(resource);
|
const resource = await getResourceById(slug);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return res.status(404).json({ error: 'Resource not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the resource is part of a course
|
||||||
|
const isPartOfAnyCourse = resource.courseId !== null;
|
||||||
|
|
||||||
|
if (isPartOfAnyCourse) {
|
||||||
|
// Update the specific lesson in the course
|
||||||
|
await updateLessonInCourse(resource.courseId, slug, req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the resource
|
||||||
|
const updatedResource = await updateResource(slug, req.body);
|
||||||
|
|
||||||
|
res.status(200).json(updatedResource);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ error: error.message });
|
res.status(400).json({ error: error.message });
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,8 @@ import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
|||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useNDKContext } from '@/context/NDKContext';
|
import { useNDKContext } from '@/context/NDKContext';
|
||||||
import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription';
|
import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription';
|
||||||
|
import { LightningAddress } from "@getalby/lightning-tools";
|
||||||
|
import PaymentButton from '@/components/bitcoinConnect/PaymentButton';
|
||||||
import 'primeicons/primeicons.css';
|
import 'primeicons/primeicons.css';
|
||||||
|
|
||||||
const MDDisplay = dynamic(
|
const MDDisplay = dynamic(
|
||||||
@ -56,7 +58,6 @@ export default function Details() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session) {
|
if (session) {
|
||||||
console.log('session:', session);
|
|
||||||
setUser(session.user);
|
setUser(session.user);
|
||||||
}
|
}
|
||||||
}, [session]);
|
}, [session]);
|
||||||
@ -80,10 +81,9 @@ export default function Details() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const decryptContent = async () => {
|
const decryptContent = async () => {
|
||||||
if (user && paidResource) {
|
if (user && paidResource) {
|
||||||
if (user.purchased.includes(processedEvent.id) || (user?.role && user?.role.subscribed)) {
|
if (user?.purchased?.includes(processedEvent.id) || (user?.role && user?.role.subscribed)) {
|
||||||
// decrypt the content
|
// decrypt the content
|
||||||
const decryptedContent = await nip04.decrypt(privkey, pubkey, processedEvent.content);
|
const decryptedContent = await nip04.decrypt(privkey, pubkey, processedEvent.content);
|
||||||
console.log('decryptedContent', decryptedContent);
|
|
||||||
setDecryptedContent(decryptedContent);
|
setDecryptedContent(decryptedContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -198,6 +198,27 @@ export default function Details() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (decryptedContent) {
|
||||||
|
return <MDDisplay source={decryptedContent} />;
|
||||||
|
}
|
||||||
|
if (paidResource && !decryptedContent) {
|
||||||
|
return <p className="text-center text-xl text-red-500">This content is paid and needs to be purchased before viewing.</p>;
|
||||||
|
}
|
||||||
|
if (processedEvent?.content) {
|
||||||
|
return <MDDisplay source={processedEvent.content} />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaymentSuccess = (response) => {
|
||||||
|
console.log("response in higher level", response)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaymentError = (error) => {
|
||||||
|
console.log("error in higher level", error)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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 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'>
|
<div className='w-full flex flex-row justify-between max-tab:flex-col max-mob:flex-col'>
|
||||||
@ -240,15 +261,10 @@ export default function Details() {
|
|||||||
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"
|
||||||
/>
|
/>
|
||||||
{bitcoinConnect ? (
|
<div className='w-full flex flex-row justify-between'>
|
||||||
<div>
|
{paidResource && !decryptedContent && <PaymentButton lnAddress={'bitcoinplebdev@stacker.news'} amount={processedEvent.price} onSuccess={handlePaymentSuccess} onError={handlePaymentError} />}
|
||||||
<BitcoinConnectPayButton onClick={handleZapEvent} />
|
<ZapDisplay zapAmount={zapAmount} event={processedEvent} zapsLoading={zapsLoading} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="w-full flex justify-end">
|
|
||||||
<ZapDisplay zapAmount={zapAmount} event={processedEvent} zapsLoading={zapsLoading} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -257,8 +273,8 @@ export default function Details() {
|
|||||||
{authorView && (
|
{authorView && (
|
||||||
<div className='w-[75vw] mx-auto flex flex-row justify-end mt-12'>
|
<div className='w-[75vw] mx-auto flex flex-row justify-end mt-12'>
|
||||||
<div className='w-fit flex flex-row justify-between'>
|
<div className='w-fit flex flex-row justify-between'>
|
||||||
<Button onClick={() => router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined className="w-auto m-2" />
|
<Button onClick={() => router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined className="w-auto m-2" />
|
||||||
<Button onClick={handleDelete} label="Delete" severity='danger' outlined className="w-auto m-2 mr-0" />
|
<Button onClick={handleDelete} label="Delete" severity='danger' outlined className="w-auto m-2 mr-0" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -273,13 +289,7 @@ export default function Details() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className='w-[75vw] mx-auto mt-12 p-12 border-t-2 border-gray-300 max-tab:p-0 max-mob:p-0 max-tab:max-w-[100vw] max-mob:max-w-[100vw]'>
|
<div className='w-[75vw] mx-auto mt-12 p-12 border-t-2 border-gray-300 max-tab:p-0 max-mob:p-0 max-tab:max-w-[100vw] max-mob:max-w-[100vw]'>
|
||||||
{
|
{renderContent()}
|
||||||
decryptedContent ? (
|
|
||||||
<MDDisplay source={decryptedContent} />
|
|
||||||
) : (
|
|
||||||
processedEvent?.content && <MDDisplay source={processedEvent.content} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -7,15 +7,26 @@ import { useImageProxy } from "@/hooks/useImageProxy";
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import UserContent from "@/components/profile/UserContent";
|
import UserContent from "@/components/profile/UserContent";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import BitcoinConnectButton from "@/components/profile/BitcoinConnect";
|
import BitcoinConnectButton from "@/components/bitcoinConnect/BitcoinConnect";
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
|
const [bitcoinConnect, setBitcoinConnect] = useState(false);
|
||||||
|
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const { returnImageProxy } = useImageProxy();
|
const { returnImageProxy } = useImageProxy();
|
||||||
const menu = useRef(null);
|
const menu = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const bitcoinConnectConfig = window.localStorage.getItem('bc:config');
|
||||||
|
|
||||||
|
if (bitcoinConnectConfig) {
|
||||||
|
setBitcoinConnect(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session) {
|
if (session) {
|
||||||
setUser(session.user);
|
setUser(session.user);
|
||||||
@ -74,7 +85,7 @@ const Profile = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
|
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
|
||||||
<h2>Connect Your Lightning Wallet</h2>
|
<h2>Connect Your Lightning Wallet</h2>
|
||||||
<BitcoinConnectButton />
|
{bitcoinConnect ? <BitcoinConnectButton /> : <p>Connecting...</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
|
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
|
||||||
<h2>Subscription</h2>
|
<h2>Subscription</h2>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user