mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-04-19 19:01:19 +00:00
pay as you go subscriptions working and recurring subscriptions flow is halfway done, had to change schema.
This commit is contained in:
parent
e3cced22c6
commit
c8870bc1ff
@ -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;
|
@ -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 {
|
||||
|
158
src/components/bitcoinConnect/SubscriptionPaymentButton.js
Normal file
158
src/components/bitcoinConnect/SubscriptionPaymentButton.js
Normal 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;
|
@ -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}
|
||||
|
@ -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}
|
||||
|
67
src/components/profile/SubscribeModal.js
Normal file
67
src/components/profile/SubscribeModal.js
Normal 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'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;
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
45
src/hooks/useLocalStroage.js
Normal file
45
src/hooks/useLocalStroage.js
Normal 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;
|
18
src/pages/api/users/subscription.js
Normal file
18
src/pages/api/users/subscription.js
Normal 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`);
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user