Start of individual content payment flow

This commit is contained in:
austinkelsay 2024-08-09 19:00:31 -05:00
parent b953b76785
commit 42a9d243ca
8 changed files with 189 additions and 27 deletions

1
package-lock.json generated
View File

@ -10,6 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@getalby/bitcoin-connect-react": "^3.5.3",
"@getalby/lightning-tools": "^5.0.3",
"@nostr-dev-kit/ndk": "^2.10.0",
"@prisma/client": "^5.17.0",
"@tanstack/react-query": "^5.51.21",

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@getalby/bitcoin-connect-react": "^3.5.3",
"@getalby/lightning-tools": "^5.0.3",
"@nostr-dev-kit/ndk": "^2.10.0",
"@prisma/client": "^5.17.0",
"@tanstack/react-query": "^5.51.21",

View File

@ -1,4 +1,3 @@
"use client";
import dynamic from 'next/dynamic';
import { useEffect } from 'react';

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

View File

@ -11,7 +11,7 @@ const ZapDisplay = ({ zapAmount, event, zapsLoading }) => {
let timeout;
if (!zapsLoading && zapAmount === 0) {
setExtraLoading(true);
timeout = setTimeout(() => setExtraLoading(false), 5000);
timeout = setTimeout(() => setExtraLoading(false), 3000);
}
return () => clearTimeout(timeout);
}, [zapsLoading, zapAmount]);

View File

@ -16,8 +16,25 @@ export default async function handler(req, res) {
}
} else if (req.method === 'PUT') {
try {
const resource = await updateResource(slug, req.body);
res.status(200).json(resource);
// Fetch the resource by ID to check if it's part of a course
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) {
res.status(400).json({ error: error.message });
}

View File

@ -15,6 +15,8 @@ import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
import { useToast } from '@/hooks/useToast';
import { useNDKContext } from '@/context/NDKContext';
import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription';
import { LightningAddress } from "@getalby/lightning-tools";
import PaymentButton from '@/components/bitcoinConnect/PaymentButton';
import 'primeicons/primeicons.css';
const MDDisplay = dynamic(
@ -56,7 +58,6 @@ export default function Details() {
useEffect(() => {
if (session) {
console.log('session:', session);
setUser(session.user);
}
}, [session]);
@ -80,10 +81,9 @@ export default function Details() {
useEffect(() => {
const decryptContent = async () => {
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
const decryptedContent = await nip04.decrypt(privkey, pubkey, processedEvent.content);
console.log('decryptedContent', 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 (
<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'>
@ -240,15 +261,10 @@ export default function Details() {
height={194}
className="w-[344px] h-[194px] object-cover object-top rounded-lg"
/>
{bitcoinConnect ? (
<div>
<BitcoinConnectPayButton onClick={handleZapEvent} />
</div>
) : (
<div className="w-full flex justify-end">
<ZapDisplay zapAmount={zapAmount} event={processedEvent} zapsLoading={zapsLoading} />
</div>
)}
<div className='w-full flex flex-row justify-between'>
{paidResource && !decryptedContent && <PaymentButton lnAddress={'bitcoinplebdev@stacker.news'} amount={processedEvent.price} onSuccess={handlePaymentSuccess} onError={handlePaymentError} />}
<ZapDisplay zapAmount={zapAmount} event={processedEvent} zapsLoading={zapsLoading} />
</div>
</div>
)}
</div>
@ -257,8 +273,8 @@ export default function Details() {
{authorView && (
<div className='w-[75vw] mx-auto flex flex-row justify-end mt-12'>
<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={handleDelete} label="Delete" severity='danger' outlined className="w-auto m-2 mr-0" />
<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" />
</div>
</div>
)}
@ -273,13 +289,7 @@ export default function Details() {
</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]'>
{
decryptedContent ? (
<MDDisplay source={decryptedContent} />
) : (
processedEvent?.content && <MDDisplay source={processedEvent.content} />
)
}
{renderContent()}
</div>
</div>
);

View File

@ -7,15 +7,26 @@ import { useImageProxy } from "@/hooks/useImageProxy";
import { useSession } from 'next-auth/react';
import UserContent from "@/components/profile/UserContent";
import Image from "next/image";
import BitcoinConnectButton from "@/components/profile/BitcoinConnect";
import BitcoinConnectButton from "@/components/bitcoinConnect/BitcoinConnect";
const Profile = () => {
const [user, setUser] = useState(null);
const [bitcoinConnect, setBitcoinConnect] = useState(false);
const { data: session, status } = useSession();
const { returnImageProxy } = useImageProxy();
const menu = useRef(null);
useEffect(() => {
if (typeof window === 'undefined') return;
const bitcoinConnectConfig = window.localStorage.getItem('bc:config');
if (bitcoinConnectConfig) {
setBitcoinConnect(true);
}
}, []);
useEffect(() => {
if (session) {
setUser(session.user);
@ -74,7 +85,7 @@ const Profile = () => {
</h2>
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
<h2>Connect Your Lightning Wallet</h2>
<BitcoinConnectButton />
{bitcoinConnect ? <BitcoinConnectButton /> : <p>Connecting...</p>}
</div>
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
<h2>Subscription</h2>