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,
"username" TEXT,
"avatar" TEXT,
"roleId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@ -14,7 +13,11 @@ CREATE TABLE "User" (
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"subscribed" BOOLEAN NOT NULL DEFAULT false,
"subscriptionStartDate" TIMESTAMP(3),
"lastPaymentAt" TIMESTAMP(3),
"nwc" TEXT,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
@ -121,6 +124,9 @@ CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Role_userId_key" ON "Role"("userId");
-- CreateIndex
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");
-- 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
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[]
courseDrafts CourseDraft[]
drafts Draft[]
role Role? @relation(fields: [roleId], references: [id])
roleId String?
role Role?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Role {
id String @id @default(uuid())
subscribed Boolean @default(false)
users User[]
id String @id @default(uuid())
user User @relation(fields: [userId], references: [id])
userId String @unique
subscribed Boolean @default(false)
subscriptionStartDate DateTime?
lastPaymentAt DateTime?
nwc String?
}
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 {ndk, addSigner} = useNDKContext();
const lnAddress = process.env.NEXT_PUBLIC_LN_ADDRESS;
useEffect(() => {
console.log("processedEvent", processedEvent);
}, [processedEvent]);
@ -146,7 +148,7 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec
<div className='w-full flex justify-between items-center'>
{paidCourse && !decryptionPerformed && (
<CoursePaymentButton
lnAddress={'bitcoinplebdev@stacker.news'}
lnAddress={lnAddress}
amount={processedEvent.price}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}

View File

@ -9,6 +9,8 @@ import { useZapsSubscription } from "@/hooks/nostrQueries/zaps/useZapsSubscripti
import { getTotalFromZaps } from "@/utils/lightning";
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 [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'>
{paidResource && !decryptedContent && <ResourcePaymentButton
lnAddress={'bitcoinplebdev@stacker.news'}
lnAddress={lnAddress}
amount={price}
onSuccess={handlePaymentSuccess}
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 },
});
};
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 { Menu } from "primereact/menu";
import { Column } from "primereact/column";
import { Message } from "primereact/message";
import { useImageProxy } from "@/hooks/useImageProxy";
import { useSession } from 'next-auth/react';
import { ProgressSpinner } from "primereact/progressspinner";
@ -12,10 +13,13 @@ import { formatDateTime } from "@/utils/time";
import UserContent from "@/components/profile/UserContent";
import Image from "next/image";
import BitcoinConnectButton from "@/components/bitcoinConnect/BitcoinConnect";
import SubscribeModal from "@/components/profile/SubscribeModal";
const Profile = () => {
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 { returnImageProxy } = useImageProxy();
@ -23,18 +27,15 @@ const Profile = () => {
const menu = useRef(null);
useEffect(() => {
if (typeof window === 'undefined') return;
const bitcoinConnectConfig = window.localStorage.getItem('bc:config');
if (bitcoinConnectConfig) {
setBitcoinConnect(true);
}
}, []);
useEffect(() => {
if (session) {
if (session && 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]);
@ -61,6 +62,10 @@ const Profile = () => {
</div>
);
const openSubscribeModal = () => {
setSubscribeModalVisible(true);
};
return (
user && (
<div className="w-[90vw] mx-auto max-tab:w-[100vw] max-mob:w-[100vw]">
@ -87,16 +92,26 @@ const Profile = () => {
{user.pubkey}
</h2>
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
<h2>Connect Your Lightning Wallet</h2>
{bitcoinConnect ? <BitcoinConnectButton /> : <p>Connecting...</p>}
<h2 className="text-xl my-2">Connect Your Lightning Wallet</h2>
<BitcoinConnectButton />
</div>
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
<h2>Subscription</h2>
<p className="text-center">You currently have no active subscription</p>
<Button
label="Subscribe"
className="p-button-raised p-button-success w-auto my-2 text-[#f8f8ff]"
/>
<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">
{subscribed ? (
<>
<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
label="Subscribe"
className="w-auto mt-8 text-[#f8f8ff]"
onClick={openSubscribeModal} // Add this onClick handler
/>
</>
)}
</div>
</div>
{!session || !session?.user || !ndk ? (
@ -122,6 +137,11 @@ const Profile = () => {
)}
<UserContent />
<SubscribeModal
visible={subscribeModalVisible}
onHide={() => setSubscribeModalVisible(false)}
/>
</div>
)
);