pay as you go subscriptions working and recurring subscriptions flow is halfway done, had to change schema.

This commit is contained in:
austinkelsay 2024-08-30 19:37:13 -05:00
parent e3cced22c6
commit c8870bc1ff
10 changed files with 377 additions and 30 deletions

View File

@ -4,7 +4,6 @@ CREATE TABLE "User" (
"pubkey" TEXT NOT NULL, "pubkey" TEXT NOT NULL,
"username" TEXT, "username" TEXT,
"avatar" TEXT, "avatar" TEXT,
"roleId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
@ -14,7 +13,11 @@ CREATE TABLE "User" (
-- CreateTable -- CreateTable
CREATE TABLE "Role" ( CREATE TABLE "Role" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"subscribed" BOOLEAN NOT NULL DEFAULT false, "subscribed" BOOLEAN NOT NULL DEFAULT false,
"subscriptionStartDate" TIMESTAMP(3),
"lastPaymentAt" TIMESTAMP(3),
"nwc" TEXT,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id") CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
); );
@ -121,6 +124,9 @@ CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Role_userId_key" ON "Role"("userId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "Course_noteId_key" ON "Course"("noteId"); CREATE UNIQUE INDEX "Course_noteId_key" ON "Course"("noteId");
@ -128,7 +134,7 @@ CREATE UNIQUE INDEX "Course_noteId_key" ON "Course"("noteId");
CREATE UNIQUE INDEX "Resource_noteId_key" ON "Resource"("noteId"); CREATE UNIQUE INDEX "Resource_noteId_key" ON "Resource"("noteId");
-- AddForeignKey -- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE SET NULL ON UPDATE CASCADE; ALTER TABLE "Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -17,16 +17,19 @@ model User {
resources Resource[] resources Resource[]
courseDrafts CourseDraft[] courseDrafts CourseDraft[]
drafts Draft[] drafts Draft[]
role Role? @relation(fields: [roleId], references: [id]) role Role?
roleId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Role { model Role {
id String @id @default(uuid()) id String @id @default(uuid())
user User @relation(fields: [userId], references: [id])
userId String @unique
subscribed Boolean @default(false) subscribed Boolean @default(false)
users User[] subscriptionStartDate DateTime?
lastPaymentAt DateTime?
nwc String?
} }
model Purchase { model Purchase {

View File

@ -0,0 +1,158 @@
import React, { useState, useEffect } from 'react';
import { Button } from 'primereact/button';
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 dynamic from 'next/dynamic';
const PaymentModal = dynamic(
() => import('@getalby/bitcoin-connect-react').then((mod) => mod.Payment),
{ ssr: false }
);
const SubscriptionPaymentButtons = ({ onSuccess, onError }) => {
const [invoice, setInvoice] = useState(null);
const [paid, setPaid] = useState(null);
const [nwcUrl, setNwcUrl] = useState(null);
const { showToast } = useToast();
const { data: session } = useSession();
const lnAddress = process.env.NEXT_PUBLIC_LIGHTNING_ADDRESS;
const amount = 25;
useEffect(() => {
initializeBitcoinConnect();
}, []);
useEffect(() => {
let intervalId;
if (invoice) {
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();
}
}, 1000);
} else {
console.log('no invoice');
}
return () => {
if (intervalId) {
clearInterval(intervalId);
}
};
}, [invoice]);
const fetchInvoice = async () => {
try {
const ln = new LightningAddress(lnAddress);
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.');
if (onError) onError(error);
return null;
}
};
const handlePaymentSuccess = async (response) => {
console.log('Payment successful', response);
clearInterval(checkPaymentInterval);
};
const handlePaymentError = async (error) => {
console.error('Payment error', error);
clearInterval(checkPaymentInterval);
};
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
}
},
});
launchModal();
// 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
});
// Clean up the listener when the component unmounts
return () => {
unsubscribe();
};
};
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>
)
}
</>
);
};
export default SubscriptionPaymentButtons;

View File

@ -34,6 +34,8 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec
const router = useRouter(); const router = useRouter();
const {ndk, addSigner} = useNDKContext(); const {ndk, addSigner} = useNDKContext();
const lnAddress = process.env.NEXT_PUBLIC_LN_ADDRESS;
useEffect(() => { useEffect(() => {
console.log("processedEvent", processedEvent); console.log("processedEvent", processedEvent);
}, [processedEvent]); }, [processedEvent]);
@ -146,7 +148,7 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec
<div className='w-full flex justify-between items-center'> <div className='w-full flex justify-between items-center'>
{paidCourse && !decryptionPerformed && ( {paidCourse && !decryptionPerformed && (
<CoursePaymentButton <CoursePaymentButton
lnAddress={'bitcoinplebdev@stacker.news'} lnAddress={lnAddress}
amount={processedEvent.price} amount={processedEvent.price}
onSuccess={handlePaymentSuccess} onSuccess={handlePaymentSuccess}
onError={handlePaymentError} onError={handlePaymentError}

View File

@ -9,6 +9,8 @@ import { useZapsSubscription } from "@/hooks/nostrQueries/zaps/useZapsSubscripti
import { getTotalFromZaps } from "@/utils/lightning"; import { getTotalFromZaps } from "@/utils/lightning";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
const lnAddress = process.env.NEXT_PUBLIC_LN_ADDRESS;
const ResourceDetails = ({processedEvent, topics, title, summary, image, price, author, paidResource, decryptedContent, handlePaymentSuccess, handlePaymentError}) => { const ResourceDetails = ({processedEvent, topics, title, summary, image, price, author, paidResource, decryptedContent, handlePaymentSuccess, handlePaymentError}) => {
const [zapAmount, setZapAmount] = useState(0); const [zapAmount, setZapAmount] = useState(0);
@ -67,7 +69,7 @@ const ResourceDetails = ({processedEvent, topics, title, summary, image, price,
/> />
<div className='w-full flex flex-row justify-between'> <div className='w-full flex flex-row justify-between'>
{paidResource && !decryptedContent && <ResourcePaymentButton {paidResource && !decryptedContent && <ResourcePaymentButton
lnAddress={'bitcoinplebdev@stacker.news'} lnAddress={lnAddress}
amount={price} amount={price}
onSuccess={handlePaymentSuccess} onSuccess={handlePaymentSuccess}
onError={handlePaymentError} onError={handlePaymentError}

View File

@ -0,0 +1,67 @@
import React from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
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';
const SubscribeModal = ({ visible, onHide }) => {
const { data: session, update } = useSession();
const { showToast } = useToast();
const router = useRouter();
const handleSubscriptionSuccess = async () => {
try {
const response = await axios.put('/api/users/subscription', {
userId: session.user.id,
isSubscribed: true,
});
if (response.data) {
await update();
showToast('success', 'Subscription successful', 'success');
onHide();
router.reload();
}
} catch (error) {
console.error('Subscription update error:', error);
showToast('error', 'Subscription failed', 'error');
}
};
const handleSubscriptionError = (error) => {
console.error('Subscription error:', error);
showToast('error', 'Subscription failed', 'error');
};
return (
<Dialog
header="Subscribe"
visible={visible}
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}
/>
</Dialog>
);
};
export default SubscribeModal;

View File

@ -98,3 +98,29 @@ export const deleteUser = async (id) => {
where: { id }, where: { id },
}); });
}; };
export const updateUserSubscription = async (userId, isSubscribed) => {
const now = new Date();
return await prisma.user.update({
where: { id: userId },
data: {
role: {
upsert: {
create: {
subscribed: isSubscribed,
subscriptionStartDate: isSubscribed ? now : null,
lastPaymentAt: isSubscribed ? now : null,
},
update: {
subscribed: isSubscribed,
subscriptionStartDate: isSubscribed ? { set: now } : { set: null },
lastPaymentAt: isSubscribed ? now : { set: null },
},
},
},
},
include: {
role: true,
},
});
};

View File

@ -0,0 +1,45 @@
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

@ -0,0 +1,18 @@
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);
res.status(200).json(updatedUser);
} catch (error) {
res.status(500).json({ error: error.message });
}
} else {
res.setHeader('Allow', ['PUT']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@ -3,6 +3,7 @@ import { Button } from "primereact/button";
import { DataTable } from "primereact/datatable"; import { DataTable } from "primereact/datatable";
import { Menu } from "primereact/menu"; import { Menu } from "primereact/menu";
import { Column } from "primereact/column"; import { Column } from "primereact/column";
import { Message } from "primereact/message";
import { useImageProxy } from "@/hooks/useImageProxy"; import { useImageProxy } from "@/hooks/useImageProxy";
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { ProgressSpinner } from "primereact/progressspinner"; import { ProgressSpinner } from "primereact/progressspinner";
@ -12,10 +13,13 @@ import { formatDateTime } from "@/utils/time";
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/bitcoinConnect/BitcoinConnect"; import BitcoinConnectButton from "@/components/bitcoinConnect/BitcoinConnect";
import SubscribeModal from "@/components/profile/SubscribeModal";
const Profile = () => { const Profile = () => {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [bitcoinConnect, setBitcoinConnect] = useState(false); const [subscribeModalVisible, setSubscribeModalVisible] = useState(false); // Add this state
const [subscribed, setSubscribed] = useState(false);
const [subscribedUntil, setSubscribedUntil] = useState(null);
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const { returnImageProxy } = useImageProxy(); const { returnImageProxy } = useImageProxy();
@ -23,18 +27,15 @@ const Profile = () => {
const menu = useRef(null); const menu = useRef(null);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (session && session.user) {
const bitcoinConnectConfig = window.localStorage.getItem('bc:config');
if (bitcoinConnectConfig) {
setBitcoinConnect(true);
}
}, []);
useEffect(() => {
if (session) {
setUser(session.user); setUser(session.user);
if (session.user.role) {
setSubscribed(session.user.role.subscribed);
const subscribedAt = new Date(session.user.role.subscribedAt);
// The user is subscribed until the date in subscribedAt + 30 days
const subscribedUntil = new Date(subscribedAt.getTime() + 30 * 24 * 60 * 60 * 1000);
setSubscribedUntil(subscribedUntil);
}
} }
}, [session]); }, [session]);
@ -61,6 +62,10 @@ const Profile = () => {
</div> </div>
); );
const openSubscribeModal = () => {
setSubscribeModalVisible(true);
};
return ( return (
user && ( user && (
<div className="w-[90vw] mx-auto max-tab:w-[100vw] max-mob:w-[100vw]"> <div className="w-[90vw] mx-auto max-tab:w-[100vw] max-mob:w-[100vw]">
@ -87,16 +92,26 @@ const Profile = () => {
{user.pubkey} {user.pubkey}
</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 className="text-xl my-2">Connect Your Lightning Wallet</h2>
{bitcoinConnect ? <BitcoinConnectButton /> : <p>Connecting...</p>} <BitcoinConnectButton />
</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 border-2 border-gray-700 bg-[#121212] p-8 rounded-md">
<h2>Subscription</h2> {subscribed ? (
<p className="text-center">You currently have no active subscription</p> <>
<Message severity="success" text="Subscribed!" />
<p className="mt-8">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400">Pay-as-you-go subscription active until {subscribedUntil.toLocaleDateString()}</p>
</>
) : (
<>
<Message severity="info" text="You currently have no active subscription" />
<Button <Button
label="Subscribe" label="Subscribe"
className="p-button-raised p-button-success w-auto my-2 text-[#f8f8ff]" className="w-auto mt-8 text-[#f8f8ff]"
onClick={openSubscribeModal} // Add this onClick handler
/> />
</>
)}
</div> </div>
</div> </div>
{!session || !session?.user || !ndk ? ( {!session || !session?.user || !ndk ? (
@ -122,6 +137,11 @@ const Profile = () => {
)} )}
<UserContent /> <UserContent />
<SubscribeModal
visible={subscribeModalVisible}
onHide={() => setSubscribeModalVisible(false)}
/>
</div> </div>
) )
); );