implementation of pay as you go and recurring subscriptions on the frontend is fully working, just need the cron endpoint now.

This commit is contained in:
austinkelsay 2024-08-31 18:18:21 -05:00
parent c8870bc1ff
commit c6c6fc4fbd
9 changed files with 259 additions and 181 deletions

38
package-lock.json generated
View File

@ -11,6 +11,7 @@
"dependencies": {
"@getalby/bitcoin-connect-react": "^3.5.3",
"@getalby/lightning-tools": "^5.0.3",
"@getalby/sdk": "^3.6.1",
"@nostr-dev-kit/ndk": "^2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "^2.5.1",
"@prisma/client": "^5.17.0",
@ -1357,28 +1358,6 @@
"@types/ms": "*"
}
},
"node_modules/@types/eslint": {
"version": "8.56.10",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz",
"integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
}
},
"node_modules/@types/eslint-scope": {
"version": "3.7.7",
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint": "*",
"@types/estree": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
@ -4358,9 +4337,9 @@
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.17.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz",
"integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==",
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz",
"integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
@ -11757,13 +11736,12 @@
}
},
"node_modules/webpack": {
"version": "5.93.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.93.0.tgz",
"integrity": "sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==",
"version": "5.94.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz",
"integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.3",
"@types/estree": "^1.0.5",
"@webassemblyjs/ast": "^1.12.1",
"@webassemblyjs/wasm-edit": "^1.12.1",
@ -11772,7 +11750,7 @@
"acorn-import-attributes": "^1.9.5",
"browserslist": "^4.21.10",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.0",
"enhanced-resolve": "^5.17.1",
"es-module-lexer": "^1.2.1",
"eslint-scope": "5.1.1",
"events": "^3.2.0",

View File

@ -12,6 +12,7 @@
"dependencies": {
"@getalby/bitcoin-connect-react": "^3.5.3",
"@getalby/lightning-tools": "^5.0.3",
"@getalby/sdk": "^3.6.1",
"@nostr-dev-kit/ndk": "^2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "^2.5.1",
"@prisma/client": "^5.17.0",

View File

@ -1,21 +1,24 @@
import React, { useState, useEffect } from 'react';
import { Button } from 'primereact/button';
import { ProgressSpinner } from 'primereact/progressspinner';
import { initializeBitcoinConnect } from './BitcoinConnect';
import { LightningAddress } from '@getalby/lightning-tools';
import { useToast } from '@/hooks/useToast';
import { useSession } from 'next-auth/react';
import { useLocalStorageWithEffect } from '@/hooks/useLocalStroage';
import { webln, nwc } from '@getalby/sdk';
import { useRouter } from 'next/router';
import dynamic from 'next/dynamic';
import AlbyButton from '@/components/buttons/AlbyButton';
import axios from 'axios';
const PaymentModal = dynamic(
() => import('@getalby/bitcoin-connect-react').then((mod) => mod.Payment),
{ ssr: false }
);
const SubscriptionPaymentButtons = ({ onSuccess, onError }) => {
const SubscriptionPaymentButtons = ({ onSuccess, onError, onRecurringSubscriptionSuccess, setIsProcessing }) => {
const [invoice, setInvoice] = useState(null);
const [paid, setPaid] = useState(null);
const [nwcUrl, setNwcUrl] = useState(null);
const [showRecurringOptions, setShowRecurringOptions] = useState(false);
const [nwcInput, setNwcInput] = useState('');
const { showToast } = useToast();
const { data: session } = useSession();
@ -32,12 +35,7 @@ const SubscriptionPaymentButtons = ({ onSuccess, onError }) => {
intervalId = setInterval(async () => {
const paid = await invoice.verifyPayment();
console.log('paid', paid);
if (paid && invoice.preimage) {
setPaid({
preimage: invoice.preimage,
});
clearInterval(intervalId);
// handle success
onSuccess();
@ -60,11 +58,10 @@ const SubscriptionPaymentButtons = ({ onSuccess, onError }) => {
await ln.fetch();
const newInvoice = await ln.requestInvoice({ satoshi: amount });
console.log('newInvoice', newInvoice);
setInvoice(newInvoice);
return newInvoice;
} catch (error) {
console.error('Error fetching invoice:', error);
showToast('error', 'Invoice Error', 'Failed to fetch the invoice.');
showToast('error', 'Invoice Error', `Failed to fetch the invoice: ${error.message}`);
if (onError) onError(error);
return null;
}
@ -73,84 +70,168 @@ const SubscriptionPaymentButtons = ({ onSuccess, onError }) => {
const handlePaymentSuccess = async (response) => {
console.log('Payment successful', response);
clearInterval(checkPaymentInterval);
showToast('success', 'Payment Successful', 'Your payment has been processed successfully.');
if (onSuccess) onSuccess(response);
};
const handlePaymentError = async (error) => {
console.error('Payment error', error);
clearInterval(checkPaymentInterval);
showToast('error', 'Payment Failed', `An error occurred during payment: ${error.message}`);
if (onError) onError(error);
};
const handleRecurringSubscription = async () => {
const { init, launchModal, onConnected } = await import('@getalby/bitcoin-connect-react');
init({
appName: 'plebdevs.com',
filters: ['nwc'],
onConnected: async (connector) => {
console.log('connector', connector);
if (connector.type === 'nwc') {
console.log('connector inside nwc', connector);
const nwcConnector = connector;
const url = await nwcConnector.getNWCUrl();
setNwcUrl(url);
console.log('NWC URL:', url);
// Here you can handle the NWC URL, e.g., send it to your backend
setIsProcessing(true);
const newNwc = nwc.NWCClient.withNewSecret();
const yearFromNow = new Date();
yearFromNow.setFullYear(yearFromNow.getFullYear() + 1);
try {
const initNwcOptions = {
name: "plebdevs.com",
requestMethods: ['pay_invoice'],
maxAmount: 25,
editable: false,
budgetRenewal: 'monthly',
expiresAt: yearFromNow,
};
await newNwc.initNWC(initNwcOptions);
showToast('info', 'Alby', 'Alby connection window opened.');
const newNWCUrl = newNwc.getNostrWalletConnectUrl();
if (newNWCUrl) {
const subscriptionResponse = await axios.put('/api/users/subscription', {
userId: session.user.id,
isSubscribed: true,
nwc: newNWCUrl,
});
if (subscriptionResponse.status === 200) {
showToast('success', 'Subscription Setup', 'Recurring subscription setup successful!');
if (onRecurringSubscriptionSuccess) onRecurringSubscriptionSuccess();
} else {
throw new Error(`Unexpected response status: ${subscriptionResponse.status}`);
}
},
});
} else {
throw new Error('Failed to generate NWC URL');
}
} catch (error) {
console.error('Error initializing NWC:', error);
showToast('error', 'Subscription Setup Failed', `Error: ${error.message}`);
if (onError) onError(error);
} finally {
setIsProcessing(false);
}
};
launchModal();
const handleManualNwcSubmit = async () => {
if (!nwcInput) {
showToast('error', 'NWC', 'Please enter a valid NWC URL');
return;
}
// Set up a listener for the connection event
const unsubscribe = onConnected((provider) => {
console.log('Connected provider:', provider);
const nwc = provider?.client?.options?.nostrWalletConnectUrl;
// try to make payment
// if successful, encrypt and send to db with subscription object on the user
});
setIsProcessing(true);
try {
const nwc = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: nwcInput,
});
// Clean up the listener when the component unmounts
return () => {
unsubscribe();
};
await nwc.enable();
const invoice = await fetchInvoice();
if (!invoice || !invoice.paymentRequest) {
showToast('error', 'NWC', `Failed to fetch invoice from ${lnAddress}`);
return;
}
const payResponse = await nwc.sendPayment(invoice.paymentRequest);
if (!payResponse || !payResponse.preimage) {
showToast('error', 'NWC', 'Payment failed');
return;
}
showToast('success', 'NWC', 'Payment successful!');
try {
const subscriptionResponse = await axios.put('/api/users/subscription', {
userId: session.user.id,
isSubscribed: true,
nwc: nwcInput,
});
if (subscriptionResponse.status === 200) {
showToast('success', 'NWC', 'Subscription setup successful!');
if (onRecurringSubscriptionSuccess) onRecurringSubscriptionSuccess();
} else {
throw new Error('Unexpected response status');
}
} catch (error) {
console.error('Subscription setup error:', error);
showToast('error', 'NWC', 'Subscription setup failed. Please contact support.');
if (onError) onError(error);
}
} catch (error) {
console.error('NWC error:', error);
showToast('error', 'NWC', `An error occurred: ${error.message}`);
if (onError) onError(error);
} finally {
setIsProcessing(false);
}
};
return (
<>
{
!invoice && (
<div className="w-full flex flex-row justify-between">
<Button
label="Pay as you go"
icon="pi pi-bolt"
onClick={() => {
fetchInvoice();
}}
severity='primary'
className="mt-4 text-[#f8f8ff]"
/>
<Button
label="Setup Recurring Subscription"
className="mt-4 text-[#f8f8ff]"
onClick={handleRecurringSubscription}
/>
</div>
)
}
{
invoice && invoice.paymentRequest && (
<div className="w-full mx-auto mt-8">
<PaymentModal
invoice={invoice?.paymentRequest}
onPaid={handlePaymentSuccess}
onError={handlePaymentError}
paymentMethods='external'
title={`Pay ${amount} sats`}
/>
</div>
)
}
{!invoice && (
<div className="w-full flex flex-row justify-between">
<Button
label="Pay as you go"
icon="pi pi-bolt"
onClick={async () => {
const invoice = await fetchInvoice();
setInvoice(invoice);
}}
severity='primary'
className="mt-4 text-[#f8f8ff]"
/>
<Button
label="Setup Recurring Subscription"
className="mt-4 text-[#f8f8ff]"
onClick={() => setShowRecurringOptions(!showRecurringOptions)}
/>
</div>
)}
{showRecurringOptions && (
<div className="w-fit mx-auto flex flex-col items-center mt-4">
<AlbyButton handleSubmit={handleRecurringSubscription} />
<span className='my-4 text-lg font-bold'>or</span>
<p className='text-lg font-bold'>Manually enter NWC URL</p>
<span className='text-sm text-gray-500'>*make sure you set a budget of at least 25000 sats and set budget renewal to monthly</span>
<input
type="text"
value={nwcInput}
onChange={(e) => setNwcInput(e.target.value)}
placeholder="Enter NWC URL"
className="w-full p-2 mb-4 border rounded"
/>
<Button
label="Submit"
onClick={handleManualNwcSubmit}
className="mt-4 text-[#f8f8ff]"
/>
</div>
)}
{invoice && invoice.paymentRequest && (
<div className="w-full mx-auto mt-8">
<PaymentModal
invoice={invoice?.paymentRequest}
onPaid={handlePaymentSuccess}
onError={handlePaymentError}
paymentMethods='external'
title={`Pay ${amount} sats`}
/>
</div>
)}
</>
);
};

View File

@ -0,0 +1,30 @@
import React from 'react';
import { Button } from 'primereact/button';
const AlbySVG = () => (
<svg width="21" height="22" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="1.575" cy="1.575" r="1.575" transform="matrix(-1 0 0 1 5.61743 4.85748)" fill="black"/>
<path d="M3.77997 6.19623L6.71997 9.13623" stroke="black" strokeWidth="0.7875"/>
<circle cx="16.9051" cy="6.43248" r="1.575" fill="black"/>
<path d="M17.1938 6.19623L14.2538 9.13623" stroke="black" strokeWidth="0.7875"/>
<path fillRule="evenodd" clipRule="evenodd" d="M4.48959 15.8309C3.64071 15.4268 3.14668 14.5193 3.31217 13.5938C4.02245 9.62169 6.98253 6.64246 10.5263 6.64246C14.0786 6.64246 17.0444 9.63614 17.7455 13.6227C17.9085 14.5499 17.4105 15.457 16.5587 15.8578C14.7361 16.7155 12.7003 17.195 10.5525 17.195C8.38243 17.195 6.32666 16.7055 4.48959 15.8309Z" fill="#FFDF6F"/>
<path d="M16.5587 15.8578L16.3911 15.5015L16.5587 15.8578ZM4.48959 15.8309L4.32034 16.1865L4.48959 15.8309ZM3.69977 13.6632C4.38609 9.82499 7.2231 7.03621 10.5263 7.03621V6.24871C6.74195 6.24871 3.65881 9.41839 2.92457 13.5245L3.69977 13.6632ZM10.5263 7.03621C13.8374 7.03621 16.6802 9.83861 17.3577 13.6909L18.1333 13.5545C17.4086 9.43368 14.3198 6.24871 10.5263 6.24871V7.03621ZM16.3911 15.5015C14.6198 16.3351 12.6411 16.8012 10.5525 16.8012V17.5887C12.7595 17.5887 14.8524 17.0959 16.7264 16.2141L16.3911 15.5015ZM10.5525 16.8012C8.44222 16.8012 6.44416 16.3253 4.65883 15.4754L4.32034 16.1865C6.20916 17.0856 8.32265 17.5887 10.5525 17.5887V16.8012ZM17.3577 13.6909C17.4884 14.4343 17.0904 15.1724 16.3911 15.5015L16.7264 16.2141C17.7306 15.7415 18.3287 14.6655 18.1333 13.5545L17.3577 13.6909ZM2.92457 13.5245C2.72626 14.6335 3.31958 15.71 4.32034 16.1865L4.65883 15.4754C3.96184 15.1436 3.5671 14.4051 3.69977 13.6632L2.92457 13.5245Z" fill="black"/>
<path fillRule="evenodd" clipRule="evenodd" d="M6.00417 14.8434C5.32087 14.5651 4.91555 13.8381 5.15231 13.1393C5.88248 10.9844 8.01244 9.42493 10.5263 9.42493C13.0401 9.42493 15.17 10.9844 15.9002 13.1393C16.137 13.8381 15.7317 14.5651 15.0484 14.8434C13.6528 15.4118 12.1261 15.7249 10.5263 15.7249C8.92644 15.7249 7.39975 15.4118 6.00417 14.8434Z" fill="black"/>
<ellipse cx="12.3375" cy="12.785" rx="1.3125" ry="1.05" fill="white"/>
<ellipse cx="8.5802" cy="12.7856" rx="1.3125" ry="1.05" fill="white"/>
</svg>
);
const AlbyButton = ({ handleSubmit }) => {
return (
<Button className="p-button-success hover:opacity-75" style={{ backgroundColor: '#FFDE6E', borderColor: '#FFDE6E', padding: '10px 20px' }} onClick={handleSubmit}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '10px' }}>
<AlbySVG style={{ width: '21px', height: '22px' }} />
<span style={{ color: 'black' }}>Generate with Alby</span>
</div>
</Button>
);
};
export default AlbyButton;

View File

@ -1,38 +1,59 @@
import React from 'react';
import React, { useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { ProgressSpinner } from 'primereact/progressspinner';
import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
import axios from 'axios';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/router';
import { useToast } from '@/hooks/useToast';
// todo encrypt nwc before saving in db
const SubscribeModal = ({ visible, onHide }) => {
const { data: session, update } = useSession();
const { showToast } = useToast();
const router = useRouter();
const [isProcessing, setIsProcessing] = useState(false);
const handleSubscriptionSuccess = async () => {
const handleSubscriptionSuccess = async (response) => {
setIsProcessing(true);
try {
const response = await axios.put('/api/users/subscription', {
const apiResponse = await axios.put('/api/users/subscription', {
userId: session.user.id,
isSubscribed: true,
});
if (response.data) {
if (apiResponse.data) {
await update();
showToast('success', 'Subscription successful', 'success');
showToast('success', 'Subscription Successful', 'Your subscription has been activated.');
onHide();
router.reload();
} else {
throw new Error('Failed to update subscription status');
}
} catch (error) {
console.error('Subscription update error:', error);
showToast('error', 'Subscription failed', 'error');
showToast('error', 'Subscription Update Failed', `Error: ${error.message}`);
} finally {
setIsProcessing(false);
}
};
const handleSubscriptionError = (error) => {
console.error('Subscription error:', error);
showToast('error', 'Subscription failed', 'error');
showToast('error', 'Subscription Failed', `An error occurred: ${error.message}`);
setIsProcessing(false);
};
const handleRecurringSubscriptionSuccess = async () => {
setIsProcessing(true);
try {
await update();
showToast('success', 'Recurring Subscription Activated', 'Your recurring subscription has been set up successfully.');
onHide();
} catch (error) {
console.error('Session update error:', error);
showToast('error', 'Session Update Failed', `Error: ${error.message}`);
} finally {
setIsProcessing(false);
}
};
return (
@ -42,24 +63,35 @@ const SubscribeModal = ({ visible, onHide }) => {
style={{ width: '50vw' }}
onHide={onHide}
>
<p className="m-0 font-bold">
Subscribe to PlebDevs and get access to:
</p>
<ul>
<li>- All of our content free and paid</li>
<li>- PlebLab Bitcoin Hackerspace Slack</li>
<li>- An exclusive calendar to book 1:1&apos;s with our team</li>
</ul>
<p className="m-0 font-bold">
ALSO
</p>
<ul>
<li>- I WILL MAKE SURE YOU WIN HARD AND LEVEL UP AS A DEV</li>
</ul>
<SubscriptionPaymentButtons
onSuccess={handleSubscriptionSuccess}
onError={handleSubscriptionError}
/>
{isProcessing ? (
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
<ProgressSpinner />
<span className="ml-2">Processing subscription...</span>
</div>
) : (
<>
<p className="m-0 font-bold">
Subscribe to PlebDevs and get access to:
</p>
<ul>
<li>- All of our content free and paid</li>
<li>- PlebLab Bitcoin Hackerspace Slack</li>
<li>- An exclusive calendar to book 1:1&apos;s with our team</li>
</ul>
<p className="m-0 font-bold">
ALSO
</p>
<ul>
<li>- I WILL MAKE SURE YOU WIN HARD AND LEVEL UP AS A DEV</li>
</ul>
<SubscriptionPaymentButtons
onSuccess={handleSubscriptionSuccess}
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
onError={handleSubscriptionError}
setIsProcessing={setIsProcessing}
/>
</>
)}
</Dialog>
);
};

View File

@ -99,7 +99,7 @@ export const deleteUser = async (id) => {
});
};
export const updateUserSubscription = async (userId, isSubscribed) => {
export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
const now = new Date();
return await prisma.user.update({
where: { id: userId },
@ -110,11 +110,13 @@ export const updateUserSubscription = async (userId, isSubscribed) => {
subscribed: isSubscribed,
subscriptionStartDate: isSubscribed ? now : null,
lastPaymentAt: isSubscribed ? now : null,
nwc: nwc ? nwc : null,
},
update: {
subscribed: isSubscribed,
subscriptionStartDate: isSubscribed ? { set: now } : { set: null },
lastPaymentAt: isSubscribed ? now : { set: null },
nwc: nwc ? nwc : null,
},
},
},

View File

@ -1,45 +0,0 @@
import { useState, useEffect } from 'react';
// This version of the hook initializes state without immediately attempting to read from localStorage
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(initialValue);
// Function to update localStorage and state
const setValue = value => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore); // Update state
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore)); // Update localStorage
}
} catch (error) {
console.log(error);
}
};
return [storedValue, setValue];
}
// Custom hook to handle fetching and setting data from localStorage
export function useLocalStorageWithEffect(key, initialValue) {
const [storedValue, setStoredValue] = useLocalStorage(key, initialValue);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
try {
const item = window.localStorage.getItem(key);
// Only update if the item exists to prevent overwriting the initial value with null
if (item !== null) {
setStoredValue(JSON.parse(item));
}
} catch (error) {
console.log(error);
}
}, [key]); // Dependencies array ensures this runs once on mount
return [storedValue, setStoredValue];
}
export default useLocalStorage;

View File

@ -3,9 +3,8 @@ import { updateUserSubscription } from "@/db/models/userModels";
export default async function handler(req, res) {
if (req.method === 'PUT') {
try {
const { userId, isSubscribed } = req.body;
const updatedUser = await updateUserSubscription(userId, isSubscribed);
const { userId, isSubscribed, nwc } = req.body;
const updatedUser = await updateUserSubscription(userId, isSubscribed, nwc);
res.status(200).json(updatedUser);
} catch (error) {

View File

@ -31,7 +31,7 @@ const Profile = () => {
setUser(session.user);
if (session.user.role) {
setSubscribed(session.user.role.subscribed);
const subscribedAt = new Date(session.user.role.subscribedAt);
const subscribedAt = new Date(session.user.role.lastPaymentAt);
// The user is subscribed until the date in subscribedAt + 30 days
const subscribedUntil = new Date(subscribedAt.getTime() + 30 * 24 * 60 * 60 * 1000);
setSubscribedUntil(subscribedUntil);