Merge pull request #75 from AustinKelsay/refactor/subscriptions

Refactor/subscriptions
This commit is contained in:
Austin Kelsay 2025-05-15 10:08:55 -05:00 committed by GitHub
commit 2291591063
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 492 additions and 119 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Role" ADD COLUMN "subscriptionType" TEXT NOT NULL DEFAULT 'monthly';

View File

@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "postgresql"

View File

@ -83,6 +83,7 @@ model Role {
userId String @unique
subscribed Boolean @default(false)
admin Boolean @default(false)
subscriptionType String @default("monthly")
subscriptionStartDate DateTime?
lastPaymentAt DateTime?
subscriptionExpiredAt DateTime?

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import { Tooltip } from 'primereact/tooltip';
import useWindowWidth from '@/hooks/useWindowWidth';
@ -24,7 +24,7 @@ const MoreInfo = ({
/>
{!isMobile && <Tooltip target=".pi-question-circle" position={tooltipPosition} />}
<Dialog
<Modal
header={modalTitle}
visible={visible}
onHide={() => setVisible(false)}
@ -32,7 +32,7 @@ const MoreInfo = ({
breakpoints={{ '960px': '75vw', '641px': '90vw' }}
>
{typeof modalBody === 'string' ? <p className="text-gray-200">{modalBody}</p> : modalBody}
</Dialog>
</Modal>
</>
);
};

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import { LightningAddress } from '@getalby/lightning-tools';
import { track } from '@vercel/analytics';
import { useToast } from '@/hooks/useToast';
@ -227,11 +227,11 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId }
/>
</div>
)}
<Dialog
<Modal
visible={dialogVisible}
onHide={() => setDialogVisible(false)}
header="Make Payment"
style={{ width: isMobile ? '90vw' : '50vw' }}
width={isMobile ? '90vw' : '50vw'}
>
{invoice ? (
<Payment
@ -243,7 +243,7 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId }
) : (
<p>Loading payment details...</p>
)}
</Dialog>
</Modal>
</div>
);
};

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import { track } from '@vercel/analytics';
import { LightningAddress } from '@getalby/lightning-tools';
import { useToast } from '@/hooks/useToast';
@ -122,11 +122,11 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource
/>
</div>
)}
<Dialog
<Modal
visible={dialogVisible}
onHide={() => setDialogVisible(false)}
header="Make Payment"
style={{ width: isMobile ? '90vw' : '50vw' }}
width={isMobile ? '90vw' : '50vw'}
>
{invoice ? (
<Payment
@ -138,7 +138,7 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource
) : (
<p>Loading payment details...</p>
)}
</Dialog>
</Modal>
</>
);
};

View File

@ -25,6 +25,7 @@ const SubscriptionPaymentButtons = ({
oneTime = false,
recurring = false,
layout = 'row',
subscriptionType = 'monthly',
}) => {
const [invoice, setInvoice] = useState(null);
const [showRecurringOptions, setShowRecurringOptions] = useState(false);
@ -34,7 +35,13 @@ const SubscriptionPaymentButtons = ({
const router = useRouter();
const lnAddress = process.env.NEXT_PUBLIC_LIGHTNING_ADDRESS;
const amount = 50000;
// Calculate the amount based on the subscription type
const getAmount = () => {
return subscriptionType === 'yearly' ? 500000 : 50000;
};
const amount = getAmount();
useEffect(() => {
// Initialize Bitcoin Connect as early as possible
@ -74,7 +81,7 @@ const SubscriptionPaymentButtons = ({
await ln.fetch();
const newInvoice = await ln.requestInvoice({
satoshi: amount,
comment: `Subscription Purchase. User: ${session?.user?.id}`,
comment: `${subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1)} Subscription Purchase. User: ${session?.user?.id}`,
});
return newInvoice;
} catch (error) {
@ -86,7 +93,7 @@ const SubscriptionPaymentButtons = ({
};
const handlePaymentSuccess = async response => {
track('Subscription Payment', { method: 'pay_as_you_go', userId: session?.user?.id });
track('Subscription Payment', { method: 'pay_as_you_go', type: subscriptionType, userId: session?.user?.id });
showToast('success', 'Payment Successful', 'Your payment has been processed successfully.');
if (onSuccess) onSuccess(response);
};
@ -117,9 +124,9 @@ const SubscriptionPaymentButtons = ({
const initNwcOptions = {
name: 'plebdevs.com',
requestMethods: ['pay_invoice'],
maxAmount: 50000,
maxAmount: amount,
editable: false,
budgetRenewal: 'monthly',
budgetRenewal: subscriptionType === 'yearly' ? 'yearly' : 'monthly',
expiresAt: yearFromNow,
};
@ -164,10 +171,11 @@ const SubscriptionPaymentButtons = ({
userId: session.user.id,
isSubscribed: true,
nwc: newNWCUrl,
subscriptionType: subscriptionType,
});
if (subscriptionResponse.status === 200) {
track('Subscription Payment', { method: 'recurring', userId: session?.user?.id });
track('Subscription Payment', { method: 'recurring', type: subscriptionType, userId: session?.user?.id });
showToast('success', 'Subscription Setup', 'Recurring subscription setup successful!');
if (onRecurringSubscriptionSuccess) onRecurringSubscriptionSuccess();
} else {
@ -229,10 +237,11 @@ const SubscriptionPaymentButtons = ({
userId: session.user.id,
isSubscribed: true,
nwc: nwcInput,
subscriptionType: subscriptionType,
});
if (subscriptionResponse.status === 200) {
track('Subscription Payment', { method: 'recurring-manual', userId: session?.user?.id });
track('Subscription Payment', { method: 'recurring-manual', type: subscriptionType, userId: session?.user?.id });
showToast('success', 'NWC', 'Subscription setup successful!');
if (onRecurringSubscriptionSuccess) onRecurringSubscriptionSuccess();
} else {
@ -256,11 +265,11 @@ const SubscriptionPaymentButtons = ({
<>
{!invoice && (
<div
className={`w-full flex ${layout === 'row' ? 'flex-row justify-between' : 'flex-col items-center'}`}
className={`w-full flex ${layout === 'row' ? 'flex-row justify-between' : 'flex-col items-center gap-4'}`}
>
{(oneTime || (!oneTime && !recurring)) && (
<GenericButton
label="Pay as you go"
label={`Pay as you go (${(amount).toLocaleString()} sats)`}
icon="pi pi-bolt"
onClick={async () => {
if (status === 'unauthenticated') {
@ -272,12 +281,12 @@ const SubscriptionPaymentButtons = ({
}
}}
severity="primary"
className="w-fit mt-4 text-[#f8f8ff]"
className={`mt-4 text-[#f8f8ff] ${layout === 'col' ? 'w-full max-w-md' : 'w-fit'}`}
/>
)}
{(recurring || (!oneTime && !recurring)) && (
<GenericButton
label="Setup Recurring Subscription"
label={`Setup Recurring ${subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1)} Subscription`}
icon={
<Image
src="/images/nwc-logo.svg"
@ -288,7 +297,7 @@ const SubscriptionPaymentButtons = ({
/>
}
severity="help"
className="w-fit mt-4 text-[#f8f8ff] bg-purple-600"
className={`mt-4 text-[#f8f8ff] bg-purple-600 ${layout === 'col' ? 'w-full max-w-md' : 'w-fit'}`}
onClick={() => {
if (status === 'unauthenticated') {
console.log('unauthenticated');
@ -309,7 +318,7 @@ const SubscriptionPaymentButtons = ({
<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 50000 sats and set budget renewal to monthly
*make sure you set a budget of at least {(amount).toLocaleString()} sats and set budget renewal to {subscriptionType}
</span>
<input
type="text"
@ -333,7 +342,7 @@ const SubscriptionPaymentButtons = ({
onPaid={handlePaymentSuccess}
onError={handlePaymentError}
paymentMethods="external"
title={`Pay ${amount} sats`}
title={`Pay ${(amount).toLocaleString()} sats`}
/>
</div>
)}

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Dropdown } from 'primereact/dropdown';
import GenericButton from '@/components/buttons/GenericButton';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import { Accordion, AccordionTab } from 'primereact/accordion';
import EmbeddedDocumentForm from '@/components/forms/course/embedded/EmbeddedDocumentForm';
import EmbeddedVideoForm from '@/components/forms/course/embedded/EmbeddedVideoForm';
@ -233,23 +233,23 @@ const LessonSelector = ({
</Accordion>
<GenericButton label="Add New Lesson" onClick={addNewLesson} className="mt-4" type="button" />
<Dialog
<Modal
className="w-full max-w-screen-md"
visible={showDocumentForm}
onHide={() => setShowDocumentForm(false)}
header="Create New Document"
>
<EmbeddedDocumentForm onSave={handleNewDocumentSave} isPaid={isPaidCourse} />
</Dialog>
</Modal>
<Dialog
<Modal
className="w-full max-w-screen-md"
visible={showVideoForm}
onHide={() => setShowVideoForm(false)}
header="Create New Video"
>
<EmbeddedVideoForm onSave={handleNewVideoSave} isPaid={isPaidCourse} />
</Dialog>
</Modal>
</div>
);
};

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import { useRouter } from 'next/router';
const WelcomeModal = () => {
@ -26,10 +26,11 @@ const WelcomeModal = () => {
};
return (
<Dialog
<Modal
header="Welcome to PlebDevs!"
visible={visible}
style={{ width: '90vw', maxWidth: '600px' }}
width="90vw"
style={{ maxWidth: '600px' }}
onHide={onHide}
>
<div className="text-center mb-4">
@ -74,7 +75,7 @@ const WelcomeModal = () => {
<p className="font-bold text-lg">Let&apos;s start your coding journey! 🚀</p>
</div>
</div>
</Dialog>
</Modal>
);
};

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import Image from 'next/image';
import { useNDKContext } from '@/context/NDKContext';
import { useSession } from 'next-auth/react';
@ -99,7 +99,13 @@ const UserBadges = ({ visible, onHide }) => {
};
return (
<Dialog header="Your Badges" visible={visible} onHide={onHide} className="w-full max-w-3xl">
<Modal
header="Your Badges"
visible={visible}
onHide={onHide}
width="full"
className="max-w-3xl"
>
<div className="p-4">
{loading ? (
<div className="flex justify-center items-center h-40">
@ -147,7 +153,7 @@ const UserBadges = ({ visible, onHide }) => {
</div>
)}
</div>
</Dialog>
</Modal>
);
};

View File

@ -1,7 +1,7 @@
import React, { useRef, useState } from 'react';
import Image from 'next/image';
import { Menu } from 'primereact/menu';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import { nip19 } from 'nostr-tools';
import { useImageProxy } from '@/hooks/useImageProxy';
import { useToast } from '@/hooks/useToast';
@ -284,12 +284,12 @@ const UserProfileCard = ({ user }) => {
<>
{windowWidth <= 1440 ? <MobileProfileCard /> : <DesktopProfileCard />}
<UserBadges visible={showBadges} onHide={() => setShowBadges(false)} />
<Dialog
<Modal
visible={showRelaysModal}
onHide={() => setShowRelaysModal(false)}
header="Manage Relays"
className="w-[90vw] max-w-[800px]"
modal
width="full"
className="max-w-[800px]"
>
<UserRelaysTable
ndk={ndk}
@ -297,7 +297,7 @@ const UserProfileCard = ({ user }) => {
setUserRelays={setUserRelays}
reInitializeNDK={reInitializeNDK}
/>
</Dialog>
</Modal>
</>
);
};

View File

@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import { Button } from 'primereact/button';
import useWindowWidth from '@/hooks/useWindowWidth';
@ -25,10 +25,10 @@ const CalendlyEmbed = ({ visible, onHide, userId, userEmail, userName }) => {
);
return (
<Dialog
<Modal
header="Schedule a Meeting"
visible={visible}
style={{ width: windowWidth < 768 ? '100vw' : '50vw' }}
width={windowWidth < 768 ? '100vw' : '50vw'}
footer={dialogFooter}
onHide={onHide}
>
@ -37,7 +37,7 @@ const CalendlyEmbed = ({ visible, onHide, userId, userEmail, userName }) => {
data-url={`https://calendly.com/plebdevs/30min?hide_event_type_details=1&hide_gdpr_banner=1&email=${encodeURIComponent(userEmail)}&name=${encodeURIComponent(userName)}&custom_data=${encodeURIComponent(JSON.stringify({ user_id: userId }))}`}
style={{ minWidth: '320px', height: '700px' }}
/>
</Dialog>
</Modal>
);
};

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import axios from 'axios';
import { useSession } from 'next-auth/react';
import { useToast } from '@/hooks/useToast';
@ -31,7 +31,11 @@ const CancelSubscription = ({ visible, onHide }) => {
};
return (
<Dialog header="Cancel Subscription" visible={visible} onHide={onHide}>
<Modal
header="Cancel Subscription"
visible={visible}
onHide={onHide}
>
<p>Are you sure you want to cancel your subscription?</p>
<div className="flex flex-row justify-center mt-6">
{isProcessing ? (
@ -46,7 +50,7 @@ const CancelSubscription = ({ visible, onHide }) => {
/>
)}
</div>
</Dialog>
</Modal>
);
};

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import axios from 'axios';
import { useSession } from 'next-auth/react';
import { useToast } from '@/hooks/useToast';
@ -111,11 +111,11 @@ const LightningAddressForm = ({ visible, onHide }) => {
};
return (
<Dialog
<Modal
header="Lightning Address"
visible={visible}
onHide={onHide}
style={{ width: windowWidth < 768 ? '100vw' : '60vw' }}
width={windowWidth < 768 ? '100vw' : '60vw'}
>
{existingLightningAddress ? (
<p>Update your Lightning Address details</p>
@ -216,7 +216,7 @@ const LightningAddressForm = ({ visible, onHide }) => {
/>
</div>
)}
</Dialog>
</Modal>
);
};

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import axios from 'axios';
import { useSession } from 'next-auth/react';
import { useToast } from '@/hooks/useToast';
@ -82,11 +82,11 @@ const Nip05Form = ({ visible, onHide }) => {
};
return (
<Dialog
<Modal
header="NIP-05"
visible={visible}
onHide={onHide}
style={{ width: windowWidth < 768 ? '100vw' : '60vw' }}
width={windowWidth < 768 ? '100vw' : '60vw'}
>
{existingNip05 ? <p>Update your Pubkey and Name</p> : <p>Confirm your Pubkey and Name</p>}
<div className="flex flex-col gap-2 max-mob:min-w-[80vw] max-tab:min-w-[60vw] min-w-[40vw]">
@ -126,7 +126,7 @@ const Nip05Form = ({ visible, onHide }) => {
/>
</div>
)}
</Dialog>
</Modal>
);
};

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import { Card } from 'primereact/card';
import { ProgressSpinner } from 'primereact/progressspinner';
import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
@ -50,11 +50,10 @@ const RenewSubscription = ({ visible, onHide, subscribedUntil }) => {
};
return (
<Dialog
<Modal
header="Renew Your PlebDevs Subscription"
visible={visible}
onHide={onHide}
className="p-fluid pb-0 w-fit"
>
{isProcessing ? (
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
@ -81,7 +80,7 @@ const RenewSubscription = ({ visible, onHide, subscribedUntil }) => {
/>
</Card>
)}
</Dialog>
</Modal>
);
};

View File

@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { Dialog } from 'primereact/dialog';
import Modal from '@/components/ui/Modal';
import { ProgressSpinner } from 'primereact/progressspinner';
import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
import axios from 'axios';
@ -17,6 +17,8 @@ import LightningAddressForm from '@/components/profile/subscription/LightningAdd
import NostrIcon from '../../../../public/images/nostr.png';
import Image from 'next/image';
import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
import { SelectButton } from 'primereact/selectbutton';
import { calculateExpirationDate } from '@/constants/subscriptionPeriods';
const SubscribeModal = ({ user }) => {
const { data: session, update } = useSession();
@ -33,19 +35,37 @@ const SubscribeModal = ({ user }) => {
const [nip05Visible, setNip05Visible] = useState(false);
const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
const [subscriptionType, setSubscriptionType] = useState('monthly');
const subscriptionOptions = [
{ label: 'Monthly', value: 'monthly' },
{ label: 'Yearly', value: 'yearly' },
];
useEffect(() => {
if (user && user.role) {
setSubscribed(user.role.subscribed);
const subscribedAt = new Date(user.role.lastPaymentAt);
const subscribedUntil = new Date(subscribedAt.getTime() + 31 * 24 * 60 * 60 * 1000);
setSubscribedUntil(subscribedUntil);
setSubscriptionType(user.role.subscriptionType || 'monthly');
// Only calculate dates if lastPaymentAt exists
if (user.role.lastPaymentAt) {
const subscribedAt = new Date(user.role.lastPaymentAt);
// Use the shared helper to calculate expiration date
const subscribedUntil = calculateExpirationDate(subscribedAt, subscriptionType);
setSubscribedUntil(subscribedUntil);
} else {
// Reset the subscribedUntil value if no lastPaymentAt
setSubscribedUntil(null);
}
if (user.role.subscriptionExpiredAt) {
const expiredAt = new Date(user.role.subscriptionExpiredAt);
setSubscriptionExpiredAt(expiredAt);
}
}
}, [user]);
}, [user, subscriptionType]);
const handleSubscriptionSuccess = async response => {
setIsProcessing(true);
@ -53,6 +73,7 @@ const SubscribeModal = ({ user }) => {
const apiResponse = await axios.put('/api/users/subscription', {
userId: session.user.id,
isSubscribed: true,
subscriptionType: subscriptionType,
});
if (apiResponse.data) {
await update();
@ -161,7 +182,7 @@ const SubscribeModal = ({ user }) => {
<Message className="w-fit" severity="success" text="Subscribed!" />
<p className="mt-3">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400">
Pay-as-you-go subscription will renew on {subscribedUntil.toLocaleDateString()}
Pay-as-you-go {user?.role?.subscriptionType || 'monthly'} subscription will renew on {subscribedUntil ? subscribedUntil.toLocaleDateString() : 'N/A'}
</p>
</div>
)}
@ -170,7 +191,7 @@ const SubscribeModal = ({ user }) => {
<Message className="w-fit" severity="success" text="Subscribed!" />
<p className="mt-3">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400">
Recurring subscription will AUTO renew on {subscribedUntil.toLocaleDateString()}
Recurring {user?.role?.subscriptionType || 'monthly'} subscription will AUTO renew on {subscribedUntil ? subscribedUntil.toLocaleDateString() : 'N/A'}
</p>
</div>
)}
@ -203,11 +224,10 @@ const SubscribeModal = ({ user }) => {
</div>
)}
</Card>
<Dialog
<Modal
header="Subscribe to PlebDevs"
visible={visible}
onHide={onHide}
className="p-fluid pb-0 w-fit"
>
{isProcessing ? (
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
@ -217,40 +237,72 @@ const SubscribeModal = ({ user }) => {
<span className="ml-2">Processing subscription...</span>
</div>
) : (
<Card className="shadow-lg">
<div className="text-center mb-4">
<h2 className="text-2xl font-bold text-primary">Unlock Premium Benefits</h2>
<Card className="shadow-none">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-primary mb-2">Unlock Premium Benefits</h2>
<p className="text-gray-400">Subscribe now and elevate your development journey!</p>
</div>
<div className="flex flex-col gap-4 mb-4 w-[60%] mx-auto">
<div className="flex flex-col gap-6 mb-6 w-[75%] mx-auto">
<div className="flex items-center">
<i className="pi pi-book text-2xl text-primary mr-2 text-blue-400"></i>
<i className="pi pi-book text-2xl text-primary mr-3 text-blue-400"></i>
<span>Access ALL current and future PlebDevs content</span>
</div>
<div className="flex items-center">
<i className="pi pi-calendar text-2xl text-primary mr-2 text-red-400"></i>
<i className="pi pi-calendar text-2xl text-primary mr-3 text-red-400"></i>
<span>
Personal mentorship & guidance and access to exclusive 1:1 booking calendar
</span>
</div>
<div className="flex items-center">
<i className="pi pi-bolt text-2xl text-primary mr-2 text-yellow-500"></i>
<i className="pi pi-bolt text-2xl text-primary mr-3 text-yellow-500"></i>
<span>Claim your own personal plebdevs.com Lightning Address</span>
</div>
<div className="flex items-center">
<Image src={NostrIcon} alt="Nostr" width={26} height={26} className="mr-2" />
<Image src={NostrIcon} alt="Nostr" width={26} height={26} className="mr-3" />
<span>Claim your own personal plebdevs.com Nostr NIP-05 identity</span>
</div>
</div>
<SubscriptionPaymentButtons
onSuccess={handleSubscriptionSuccess}
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
onError={handleSubscriptionError}
setIsProcessing={setIsProcessing}
/>
<div className="subscription-plan-selector my-8">
<div className="flex flex-col items-center mb-4">
<h3 className="text-xl font-bold mb-4">Select Your Plan</h3>
<SelectButton
value={subscriptionType}
options={subscriptionOptions}
onChange={(e) => setSubscriptionType(e.value)}
className="mb-3 w-full max-w-[300px] mx-auto"
pt={{
button: { className: 'text-base px-8 py-2 text-white' },
root: { className: 'flex justify-center' }
}}
/>
{subscriptionType === 'yearly' && (
<div className="savings-message text-sm text-green-500 font-semibold mt-2">
Save ~17% with yearly subscription!
</div>
)}
<div className="price-display text-2xl font-bold mt-3">
{subscriptionType === 'yearly' ? '500,000' : '50,000'} sats
<span className="text-sm text-gray-400 ml-2">
({subscriptionType === 'yearly' ? 'yearly' : 'monthly'})
</span>
</div>
</div>
</div>
<div className="mt-6">
<SubscriptionPaymentButtons
onSuccess={handleSubscriptionSuccess}
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
onError={handleSubscriptionError}
setIsProcessing={setIsProcessing}
subscriptionType={subscriptionType}
layout="col"
/>
</div>
</Card>
)}
</Dialog>
</Modal>
<CalendlyEmbed
visible={calendlyVisible}
onHide={() => setCalendlyVisible(false)}

View File

@ -16,6 +16,8 @@ import CalendlyEmbed from '@/components/profile/subscription/CalendlyEmbed';
import Nip05Form from '@/components/profile/subscription/Nip05Form';
import LightningAddressForm from '@/components/profile/subscription/LightningAddressForm';
import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
import { SelectButton } from 'primereact/selectbutton';
import { SUBSCRIPTION_PERIODS, calculateExpirationDate } from '@/constants/subscriptionPeriods';
const UserSubscription = () => {
const { data: session, update } = useSession();
@ -32,19 +34,37 @@ const UserSubscription = () => {
const [nip05Visible, setNip05Visible] = useState(false);
const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
const [subscriptionType, setSubscriptionType] = useState('monthly');
const subscriptionOptions = [
{ label: 'Monthly', value: 'monthly' },
{ label: 'Yearly', value: 'yearly' },
];
useEffect(() => {
if (session && session?.user) {
setUser(session.user);
if (session.user.role?.subscriptionType) {
setSubscriptionType(session.user.role.subscriptionType);
}
}
}, [session]);
useEffect(() => {
if (user && user.role) {
setSubscribed(user.role.subscribed);
const subscribedAt = new Date(user.role.lastPaymentAt);
const subscribedUntil = new Date(subscribedAt.getTime() + 31 * 24 * 60 * 60 * 1000);
setSubscribedUntil(subscribedUntil);
if (user.role.lastPaymentAt) {
const subscribedAt = new Date(user.role.lastPaymentAt);
// Use the common helper to calculate expiration date
const subscribedUntil = calculateExpirationDate(subscribedAt, user.role.subscriptionType || 'monthly');
setSubscribedUntil(subscribedUntil);
} else {
setSubscribedUntil(null);
}
if (user.role.subscriptionExpiredAt) {
const expiredAt = new Date(user.role.subscriptionExpiredAt);
setSubscriptionExpiredAt(expiredAt);
@ -58,6 +78,7 @@ const UserSubscription = () => {
const apiResponse = await axios.put('/api/users/subscription', {
userId: session.user.id,
isSubscribed: true,
subscriptionType: subscriptionType,
});
if (apiResponse.data) {
await update();
@ -134,37 +155,66 @@ const UserSubscription = () => {
</div>
) : (
<div className="flex flex-col">
<div className="mb-2">
<div className="mb-4">
<p className="text-gray-400">
Subscribe now and elevate your development journey!
</p>
</div>
<div className="flex flex-col gap-4 mb-1">
<div className="flex flex-col gap-5 mb-5">
<div className="flex items-center">
<i className="pi pi-book text-2xl text-primary mr-2 text-blue-400"></i>
<i className="pi pi-book text-2xl text-primary mr-3 text-blue-400"></i>
<span>Access ALL current and future PlebDevs content</span>
</div>
<div className="flex items-center">
<i className="pi pi-calendar text-2xl text-primary mr-2 text-red-400"></i>
<i className="pi pi-calendar text-2xl text-primary mr-3 text-red-400"></i>
<span>
Personal mentorship & guidance and access to exclusive 1:1 booking calendar
</span>
</div>
<div className="flex items-center">
<i className="pi pi-bolt text-2xl text-primary mr-2 text-yellow-500"></i>
<i className="pi pi-bolt text-2xl text-primary mr-3 text-yellow-500"></i>
<span>Claim your own personal plebdevs.com Lightning Address</span>
</div>
<div className="flex items-center">
<Image src={NostrIcon} alt="Nostr" width={25} height={25} className="mr-2" />
<Image src={NostrIcon} alt="Nostr" width={25} height={25} className="mr-3" />
<span>Claim your own personal plebdevs.com Nostr NIP-05 identity</span>
</div>
</div>
<div className="subscription-plan-selector my-6">
<div className="flex flex-col items-center mb-4">
<h3 className="text-xl font-bold mb-3">Select Your Plan</h3>
<SelectButton
value={subscriptionType}
options={subscriptionOptions}
onChange={(e) => setSubscriptionType(e.value)}
className="mb-3 w-full max-w-[300px] mx-auto"
pt={{
button: { className: 'text-base px-8 py-2 text-white' },
root: { className: 'flex justify-center' }
}}
/>
{subscriptionType === 'yearly' && (
<div className="savings-message text-sm text-green-500 font-semibold mt-2">
Save ~17% with yearly subscription!
</div>
)}
<div className="price-display text-2xl font-bold mt-3">
{subscriptionType === 'yearly' ? '500,000' : '50,000'} sats
<span className="text-sm text-gray-400 ml-2">
({subscriptionType === 'yearly' ? 'yearly' : 'monthly'})
</span>
</div>
</div>
</div>
<SubscriptionPaymentButtons
onSuccess={handleSubscriptionSuccess}
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
onError={handleSubscriptionError}
setIsProcessing={setIsProcessing}
layout={windowWidth < 768 ? 'col' : 'row'}
subscriptionType={subscriptionType}
/>
</div>
)}
@ -176,6 +226,9 @@ const UserSubscription = () => {
<Card
title="Subscription Benefits"
className="h-[330px] border border-gray-700 rounded-lg"
pt={{
content: { className: 'py-0' },
}}
>
{isProcessing ? (
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
@ -186,6 +239,14 @@ const UserSubscription = () => {
</div>
) : (
<div className="flex flex-col">
<div className="mb-1">
<p className="text-gray-300 mb-1">
<span className="font-semibold">Current Plan:</span> {user?.role?.subscriptionType || 'monthly'} subscription
</p>
<p className="text-gray-300">
<span className="font-semibold">Renews on:</span> {subscribedUntil ? subscribedUntil.toLocaleDateString() : 'N/A'}
</p>
</div>
<div className="flex flex-col gap-4">
<GenericButton
severity="info"
@ -249,28 +310,35 @@ const UserSubscription = () => {
title="Frequently Asked Questions"
className="mt-2 border border-gray-700 rounded-lg"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-5">
<div>
<h3 className="text-lg font-semibold">How does the subscription work?</h3>
<p>
Think of the subscriptions as a paetreon type model. You pay a monthly fee and in
Think of the subscriptions as a paetreon type model. You pay a monthly or yearly fee and in
return you get access to premium features and all of the paid content. You can
cancel at any time.
</p>
</div>
<div>
<h3 className="text-lg font-semibold">What&apos;s the difference between monthly and yearly?</h3>
<p>
The yearly subscription offers a ~17% discount compared to paying monthly for a year.
Both plans give you the same access to all features and content.
</p>
</div>
<div>
<h3 className="text-lg font-semibold">How do I Subscribe? (Pay as you go)</h3>
<p>
The pay as you go subscription is a one-time payment that gives you access to all
of the premium features for one month. You will need to manually renew your
subscription every month.
of the premium features for one month or year, depending on your selected plan. You will need to manually renew your
subscription when it expires.
</p>
</div>
<div>
<h3 className="text-lg font-semibold">How do I Subscribe? (Recurring)</h3>
<p>
The recurring subscription option allows you to submit a Nostr Wallet Connect URI
that will be used to automatically send the subscription fee every month. You can
that will be used to automatically send the subscription fee on your chosen schedule. You can
cancel at any time.
</p>
</div>

112
src/components/ui/Modal.js Normal file
View File

@ -0,0 +1,112 @@
import React from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
/**
* A generic modal component based on PrimeReact Dialog with dark styling
* @param {Object} props - Component props
* @param {string} props.header - Modal header text
* @param {boolean} props.visible - Whether the modal is visible
* @param {Function} props.onHide - Function to call when modal is closed
* @param {React.ReactNode} props.children - Modal content
* @param {string} props.className - Additional CSS classes for the modal
* @param {Object} props.style - Additional inline styles for the modal
* @param {string} props.width - Width of the modal (fit, full, or pixel value)
* @param {React.ReactNode} props.footer - Custom footer content
* @param {Object} props.headerStyle - Additional styles for the header
* @param {Object} props.contentStyle - Additional styles for the content
* @param {Object} props.footerStyle - Additional styles for the footer
* @param {Object} props.breakpoints - Responsive breakpoints (e.g. {'960px': '75vw'})
* @param {boolean} props.modal - Whether the modal requires a click on the mask to hide
* @param {boolean} props.draggable - Whether the modal is draggable
* @param {boolean} props.resizable - Whether the modal is resizable
* @param {boolean} props.maximizable - Whether the modal can be maximized
* @param {boolean} props.dismissableMask - Whether clicking outside closes the modal
* @param {boolean} props.showCloseButton - Whether to show the default close button in the footer
*/
const Modal = ({
header,
visible,
onHide,
children,
className = '',
style = {},
width = 'fit',
footer,
headerStyle = {},
contentStyle = {},
footerStyle = {},
breakpoints,
modal,
draggable,
resizable,
maximizable,
dismissableMask,
showCloseButton = false,
...otherProps
}) => {
// Base dark styling
const baseStyle = { backgroundColor: '#1f2937' };
const baseHeaderStyle = { backgroundColor: '#1f2937', color: 'white' };
const baseContentStyle = { backgroundColor: '#1f2937' };
const baseFooterStyle = { backgroundColor: '#1f2937', borderTop: '1px solid #374151' };
// Determine width class
let widthClass = '';
if (width === 'fit') {
widthClass = 'w-fit';
} else if (width === 'full') {
widthClass = 'w-full';
} else {
// Custom width will be handled via style
style.width = width;
}
// Create footer with close button if requested
const footerContent = showCloseButton ? (
<div className="flex justify-end w-full">
<Button
label="Close"
icon="pi pi-times"
onClick={onHide}
className="p-button-text text-white"
/>
</div>
) : footer;
// Apply tailwind CSS to modify dialog elements
const dialogClassNames = `
.p-dialog-footer {
background-color: #1f2937 !important;
border-top: 1px solid #374151 !important;
}
`;
return (
<>
<style jsx global>{dialogClassNames}</style>
<Dialog
header={header}
visible={visible}
onHide={onHide}
className={`p-fluid pb-0 ${widthClass} ${className}`}
style={{ ...baseStyle, ...style }}
headerStyle={{ ...baseHeaderStyle, ...headerStyle }}
contentStyle={{ ...baseContentStyle, ...contentStyle }}
footerStyle={{ ...baseFooterStyle, ...footerStyle }}
footer={footerContent}
breakpoints={breakpoints}
modal={modal}
draggable={draggable}
resizable={resizable}
maximizable={maximizable}
dismissableMask={dismissableMask}
{...otherProps}
>
{children}
</Dialog>
</>
);
};
export default Modal;

View File

@ -0,0 +1,36 @@
// Constants for subscription periods to maintain consistency across the application
export const SUBSCRIPTION_PERIODS = {
MONTHLY: {
DAYS: 30,
BUFFER_HOURS: 1, // Buffer time for expiration checks
},
YEARLY: {
DAYS: 365,
BUFFER_HOURS: 1, // Buffer time for expiration checks
}
};
// Helper to calculate expiration date (for UI display)
export const calculateExpirationDate = (startDate, subscriptionType) => {
const periodDays = subscriptionType === 'yearly'
? SUBSCRIPTION_PERIODS.YEARLY.DAYS
: SUBSCRIPTION_PERIODS.MONTHLY.DAYS;
return new Date(startDate.getTime() + periodDays * 24 * 60 * 60 * 1000);
};
// Helper to check if subscription has expired (for backend logic)
export const hasSubscriptionExpired = (lastPaymentDate, subscriptionType) => {
if (!lastPaymentDate) return true;
const now = new Date();
const period = subscriptionType === 'yearly'
? SUBSCRIPTION_PERIODS.YEARLY
: SUBSCRIPTION_PERIODS.MONTHLY;
const expirationTime = lastPaymentDate.getTime() +
(period.DAYS * 24 * 60 * 60 * 1000) +
(period.BUFFER_HOURS * 60 * 60 * 1000);
return now.getTime() > expirationTime;
};

View File

@ -6,6 +6,7 @@ export const createRole = async data => {
user: { connect: { id: data.userId } },
admin: data.admin,
subscribed: data.subscribed,
subscriptionType: data.subscriptionType || 'monthly',
// Add other fields as needed, with default values or null if not provided
subscriptionStartDate: null,
lastPaymentAt: null,

View File

@ -1,4 +1,5 @@
import prisma from '../prisma';
import { SUBSCRIPTION_PERIODS } from '@/constants/subscriptionPeriods';
export const getAllUsers = async () => {
return await prisma.user.findMany({
@ -165,7 +166,7 @@ export const deleteUser = async id => {
});
};
export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
export const updateUserSubscription = async (userId, isSubscribed, nwc, subscriptionType = 'monthly') => {
try {
const now = new Date();
return await prisma.user.update({
@ -175,6 +176,7 @@ export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
upsert: {
create: {
subscribed: isSubscribed,
subscriptionType: subscriptionType,
subscriptionStartDate: isSubscribed ? now : null,
lastPaymentAt: isSubscribed ? now : null,
nwc: nwc ? nwc : null,
@ -182,6 +184,7 @@ export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
},
update: {
subscribed: isSubscribed,
subscriptionType: subscriptionType,
subscriptionStartDate: isSubscribed ? { set: now } : { set: null },
lastPaymentAt: isSubscribed ? now : { set: null },
nwc: nwc ? nwc : null,
@ -202,20 +205,38 @@ export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
export const findExpiredSubscriptions = async () => {
try {
const now = new Date();
const oneMonthAndOneHourAgo = new Date(
now.getTime() - 1 * 30 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000
// Use the constants for expiration periods
const monthlyExpiration = new Date(
now.getTime() -
(SUBSCRIPTION_PERIODS.MONTHLY.DAYS * 24 * 60 * 60 * 1000) -
(SUBSCRIPTION_PERIODS.MONTHLY.BUFFER_HOURS * 60 * 60 * 1000)
);
const yearlyExpiration = new Date(
now.getTime() -
(SUBSCRIPTION_PERIODS.YEARLY.DAYS * 24 * 60 * 60 * 1000) -
(SUBSCRIPTION_PERIODS.YEARLY.BUFFER_HOURS * 60 * 60 * 1000)
);
// Find expired subscriptions of both types
const result = await prisma.role.findMany({
where: {
subscribed: true,
lastPaymentAt: {
lt: oneMonthAndOneHourAgo,
},
OR: [
{
subscriptionType: 'monthly',
lastPaymentAt: { lt: monthlyExpiration }
},
{
subscriptionType: 'yearly',
lastPaymentAt: { lt: yearlyExpiration }
}
]
},
select: {
userId: true,
nwc: true,
subscriptionType: true,
subscriptionExpiredAt: true,
subscriptionStartDate: true,
admin: true,
@ -231,6 +252,24 @@ export const findExpiredSubscriptions = async () => {
export const expireUserSubscriptions = async userIds => {
try {
const now = new Date();
// First, get the subscription types for each userId
const subscriptions = await prisma.role.findMany({
where: {
userId: { in: userIds },
},
select: {
userId: true,
subscriptionType: true,
},
});
// Create a map of userId to subscription type
const subscriptionTypes = {};
subscriptions.forEach(sub => {
subscriptionTypes[sub.userId] = sub.subscriptionType || 'monthly';
});
const updatePromises = userIds.map(userId =>
prisma.role.update({
where: { userId },
@ -240,6 +279,8 @@ export const expireUserSubscriptions = async userIds => {
lastPaymentAt: null,
nwc: null,
subscriptionExpiredAt: now,
// Keep the subscription type for historical data and easy renewal
// subscriptionType: Don't change the existing value
},
})
);

View File

@ -18,6 +18,7 @@ import RenewSubscription from '@/components/profile/subscription/RenewSubscripti
import Nip05Form from '@/components/profile/subscription/Nip05Form';
import LightningAddressForm from '@/components/profile/subscription/LightningAddressForm';
import MoreInfo from '@/components/MoreInfo';
import { SUBSCRIPTION_PERIODS } from '@/constants/subscriptionPeriods';
const AboutPage = () => {
const { data: session, update } = useSession();
@ -117,7 +118,7 @@ const AboutPage = () => {
if (user && user.role) {
setSubscribed(user.role.subscribed);
const subscribedAt = new Date(user.role.lastPaymentAt);
const subscribedUntil = new Date(subscribedAt.getTime() + 31 * 24 * 60 * 60 * 1000);
const subscribedUntil = new Date(subscribedAt.getTime() + SUBSCRIPTION_PERIODS.MONTHLY.DAYS * 24 * 60 * 60 * 1000);
setSubscribedUntil(subscribedUntil);
if (user.role.subscriptionExpiredAt) {
const expiredAt = new Date(user.role.subscriptionExpiredAt);

View File

@ -7,18 +7,39 @@ import { webln } from '@getalby/sdk';
import { LightningAddress } from '@getalby/lightning-tools';
const lnAddress = process.env.LIGHTNING_ADDRESS;
const amount = 50000; // Set the subscription amount in satoshis
// Calculate subscription amount based on type
const getAmount = (subscriptionType) => {
// 500K for yearly (saves ~17% compared to monthly), 50K for monthly
return subscriptionType === 'yearly' ? 500000 : 50000;
};
export default async function handler(req, res) {
if (req.method === 'GET') {
try {
// Get all expired subscriptions (both monthly and yearly)
// The findExpiredSubscriptions function handles different expiration periods:
// - Monthly: 30 days + 1 hour
// - Yearly: 365 days + 1 hour
const expiredSubscriptions = await findExpiredSubscriptions();
console.log('expiredSubscriptions', expiredSubscriptions);
console.log(`Found ${expiredSubscriptions.length} expired subscriptions to process`);
// Track stats for reporting
const stats = {
monthly: { processed: 0, renewed: 0, expired: 0 },
yearly: { processed: 0, renewed: 0, expired: 0 },
};
const stillExpired = [];
for (const { userId, nwc } of expiredSubscriptions) {
for (const { userId, nwc, subscriptionType = 'monthly' } of expiredSubscriptions) {
// Track processed subscriptions by type
stats[subscriptionType].processed++;
if (nwc) {
try {
console.log(`Processing ${subscriptionType} subscription renewal for user ${userId}`);
const amount = getAmount(subscriptionType);
const nwcProvider = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: nwc,
});
@ -26,30 +47,49 @@ export default async function handler(req, res) {
const ln = new LightningAddress(lnAddress);
await ln.fetch();
const newInvoice = await ln.requestInvoice({ satoshi: amount });
const newInvoice = await ln.requestInvoice({
satoshi: amount,
comment: `${subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1)} Subscription Renewal for User: ${userId}`,
});
console.log(`Generated invoice for ${amount} sats for ${subscriptionType} subscription`);
const response = await nwcProvider.sendPayment(newInvoice?.paymentRequest);
if (response && response?.preimage) {
console.log(`SUBSCRIPTION AUTO-RENEWED`, response);
await updateUserSubscription(userId, true, nwc);
console.log(`SUBSCRIPTION AUTO-RENEWED (${subscriptionType}) for User: ${userId}`);
// Re-subscribe the user with the same subscription type
await updateUserSubscription(userId, true, nwc, subscriptionType);
// Track successful renewals
stats[subscriptionType].renewed++;
continue; // Skip adding to stillExpired list
} else {
console.log(`Payment failed for user ${userId}: (stillExpired)`, response);
console.log(`Payment failed for ${subscriptionType} subscription for user ${userId}: (stillExpired)`, response);
}
} catch (error) {
console.error(`Payment failed for user ${userId}:`, error);
console.error(`Payment failed for ${subscriptionType} subscription for user ${userId}:`, error);
}
} else {
console.log(`No NWC found for user ${userId}, marking as expired`);
}
// Track failed renewals that will be expired
stats[subscriptionType].expired++;
stillExpired.push(userId);
}
// Expire all subscriptions that couldn't be renewed
const expiredCount = await expireUserSubscriptions(stillExpired);
console.log(`Processed ${expiredSubscriptions.length} total subscriptions (${stats.monthly.processed} monthly, ${stats.yearly.processed} yearly)`);
console.log(`Renewed ${stats.monthly.renewed + stats.yearly.renewed} total subscriptions (${stats.monthly.renewed} monthly, ${stats.yearly.renewed} yearly)`);
console.log(`Expired ${expiredCount} total subscriptions (${stats.monthly.expired} monthly, ${stats.yearly.expired} yearly)`);
res.status(200).json({
message: `Cron job completed successfully.
Processed ${expiredSubscriptions.length} subscriptions.
Expired ${expiredCount} subscriptions.`,
Processed ${expiredSubscriptions.length} subscriptions (${stats.monthly.processed} monthly, ${stats.yearly.processed} yearly).
Renewed ${stats.monthly.renewed + stats.yearly.renewed} subscriptions (${stats.monthly.renewed} monthly, ${stats.yearly.renewed} yearly).
Expired ${expiredCount} subscriptions (${stats.monthly.expired} monthly, ${stats.yearly.expired} yearly).`,
stats
});
} catch (error) {
console.error('Cron job error:', error);

View File

@ -12,8 +12,8 @@ export default async function handler(req, res) {
if (req.method === 'PUT') {
try {
const { userId, isSubscribed, nwc } = req.body;
const updatedUser = await updateUserSubscription(userId, isSubscribed, nwc);
const { userId, isSubscribed, nwc, subscriptionType = 'monthly' } = req.body;
const updatedUser = await updateUserSubscription(userId, isSubscribed, nwc, subscriptionType);
res.status(200).json(updatedUser);
} catch (error) {