diff --git a/prisma/migrations/20250514143724_add_subscription_type/migration.sql b/prisma/migrations/20250514143724_add_subscription_type/migration.sql new file mode 100644 index 0000000..b36d0fe --- /dev/null +++ b/prisma/migrations/20250514143724_add_subscription_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Role" ADD COLUMN "subscriptionType" TEXT NOT NULL DEFAULT 'monthly'; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 648c57f..044d57c 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 23486b9..a5bc0e9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/src/components/MoreInfo.js b/src/components/MoreInfo.js index aed142a..eacd40b 100644 --- a/src/components/MoreInfo.js +++ b/src/components/MoreInfo.js @@ -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 && } - setVisible(false)} @@ -32,7 +32,7 @@ const MoreInfo = ({ breakpoints={{ '960px': '75vw', '641px': '90vw' }} > {typeof modalBody === 'string' ?

{modalBody}

: modalBody} -
+ ); }; diff --git a/src/components/bitcoinConnect/CoursePaymentButton.js b/src/components/bitcoinConnect/CoursePaymentButton.js index 49bdb1f..98cb732 100644 --- a/src/components/bitcoinConnect/CoursePaymentButton.js +++ b/src/components/bitcoinConnect/CoursePaymentButton.js @@ -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 } /> )} - setDialogVisible(false)} header="Make Payment" - style={{ width: isMobile ? '90vw' : '50vw' }} + width={isMobile ? '90vw' : '50vw'} > {invoice ? ( Loading payment details...

)} -
+ ); }; diff --git a/src/components/bitcoinConnect/ResourcePaymentButton.js b/src/components/bitcoinConnect/ResourcePaymentButton.js index 75617b3..455319b 100644 --- a/src/components/bitcoinConnect/ResourcePaymentButton.js +++ b/src/components/bitcoinConnect/ResourcePaymentButton.js @@ -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 /> )} - setDialogVisible(false)} header="Make Payment" - style={{ width: isMobile ? '90vw' : '50vw' }} + width={isMobile ? '90vw' : '50vw'} > {invoice ? ( Loading payment details...

)} -
+ ); }; diff --git a/src/components/bitcoinConnect/SubscriptionPaymentButton.js b/src/components/bitcoinConnect/SubscriptionPaymentButton.js index 8ee2819..505004c 100644 --- a/src/components/bitcoinConnect/SubscriptionPaymentButton.js +++ b/src/components/bitcoinConnect/SubscriptionPaymentButton.js @@ -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 && (
{(oneTime || (!oneTime && !recurring)) && ( { 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)) && ( } 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 = ({ or

Manually enter NWC URL

- *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}
)} diff --git a/src/components/forms/course/LessonSelector.js b/src/components/forms/course/LessonSelector.js index 52fd7aa..639d6a3 100644 --- a/src/components/forms/course/LessonSelector.js +++ b/src/components/forms/course/LessonSelector.js @@ -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 = ({ - setShowDocumentForm(false)} header="Create New Document" > - + - setShowVideoForm(false)} header="Create New Video" > - + ); }; diff --git a/src/components/onboarding/WelcomeModal.js b/src/components/onboarding/WelcomeModal.js index 1a49bae..fce0514 100644 --- a/src/components/onboarding/WelcomeModal.js +++ b/src/components/onboarding/WelcomeModal.js @@ -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 ( -
@@ -74,7 +75,7 @@ const WelcomeModal = () => {

Let's start your coding journey! 🚀

-
+ ); }; diff --git a/src/components/profile/UserBadges.js b/src/components/profile/UserBadges.js index b779025..b406138 100644 --- a/src/components/profile/UserBadges.js +++ b/src/components/profile/UserBadges.js @@ -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 ( - +
{loading ? (
@@ -147,7 +153,7 @@ const UserBadges = ({ visible, onHide }) => {
)}
-
+ ); }; diff --git a/src/components/profile/UserProfileCard.js b/src/components/profile/UserProfileCard.js index 1414973..e8d2e79 100644 --- a/src/components/profile/UserProfileCard.js +++ b/src/components/profile/UserProfileCard.js @@ -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 ? : } setShowBadges(false)} /> - setShowRelaysModal(false)} header="Manage Relays" - className="w-[90vw] max-w-[800px]" - modal + width="full" + className="max-w-[800px]" > { setUserRelays={setUserRelays} reInitializeNDK={reInitializeNDK} /> - + ); }; diff --git a/src/components/profile/subscription/CalendlyEmbed.js b/src/components/profile/subscription/CalendlyEmbed.js index fc57588..77fcc71 100644 --- a/src/components/profile/subscription/CalendlyEmbed.js +++ b/src/components/profile/subscription/CalendlyEmbed.js @@ -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 ( - @@ -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' }} /> - + ); }; diff --git a/src/components/profile/subscription/CancelSubscription.js b/src/components/profile/subscription/CancelSubscription.js index 4392e06..1441219 100644 --- a/src/components/profile/subscription/CancelSubscription.js +++ b/src/components/profile/subscription/CancelSubscription.js @@ -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 ( - +

Are you sure you want to cancel your subscription?

{isProcessing ? ( @@ -46,7 +50,7 @@ const CancelSubscription = ({ visible, onHide }) => { /> )}
-
+ ); }; diff --git a/src/components/profile/subscription/LightningAddressForm.js b/src/components/profile/subscription/LightningAddressForm.js index 7256bb5..7a4823c 100644 --- a/src/components/profile/subscription/LightningAddressForm.js +++ b/src/components/profile/subscription/LightningAddressForm.js @@ -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 ( - {existingLightningAddress ? (

Update your Lightning Address details

@@ -216,7 +216,7 @@ const LightningAddressForm = ({ visible, onHide }) => { /> )} -
+ ); }; diff --git a/src/components/profile/subscription/Nip05Form.js b/src/components/profile/subscription/Nip05Form.js index 9e96be2..586d97d 100644 --- a/src/components/profile/subscription/Nip05Form.js +++ b/src/components/profile/subscription/Nip05Form.js @@ -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 ( - {existingNip05 ?

Update your Pubkey and Name

:

Confirm your Pubkey and Name

}
@@ -126,7 +126,7 @@ const Nip05Form = ({ visible, onHide }) => { />
)} -
+ ); }; diff --git a/src/components/profile/subscription/RenewSubscription.js b/src/components/profile/subscription/RenewSubscription.js index b26d222..56cb3f4 100644 --- a/src/components/profile/subscription/RenewSubscription.js +++ b/src/components/profile/subscription/RenewSubscription.js @@ -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 ( - {isProcessing ? (
@@ -81,7 +80,7 @@ const RenewSubscription = ({ visible, onHide, subscribedUntil }) => { /> )} -
+ ); }; diff --git a/src/components/profile/subscription/SubscribeModal.js b/src/components/profile/subscription/SubscribeModal.js index 8a20fdd..6c5dd33 100644 --- a/src/components/profile/subscription/SubscribeModal.js +++ b/src/components/profile/subscription/SubscribeModal.js @@ -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 }) => {

Thank you for your support 🎉

- 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'}

)} @@ -170,7 +191,7 @@ const SubscribeModal = ({ user }) => {

Thank you for your support 🎉

- Recurring subscription will AUTO renew on {subscribedUntil.toLocaleDateString()} + Recurring {user?.role?.subscriptionType || 'monthly'} subscription will AUTO renew on {subscribedUntil ? subscribedUntil.toLocaleDateString() : 'N/A'}

)} @@ -203,11 +224,10 @@ const SubscribeModal = ({ user }) => { )} - {isProcessing ? (
@@ -217,40 +237,72 @@ const SubscribeModal = ({ user }) => { Processing subscription...
) : ( - -
-

Unlock Premium Benefits

+ +
+

Unlock Premium Benefits

Subscribe now and elevate your development journey!

-
+
- + Access ALL current and future PlebDevs content
- + Personal mentorship & guidance and access to exclusive 1:1 booking calendar
- + Claim your own personal plebdevs.com Lightning Address
- Nostr + Nostr Claim your own personal plebdevs.com Nostr NIP-05 identity
- + +
+
+

Select Your Plan

+ 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' && ( +
+ Save ~17% with yearly subscription! +
+ )} +
+ {subscriptionType === 'yearly' ? '500,000' : '50,000'} sats + + ({subscriptionType === 'yearly' ? 'yearly' : 'monthly'}) + +
+
+
+ +
+ +
)} -
+ setCalendlyVisible(false)} diff --git a/src/components/profile/subscription/UserSubscription.js b/src/components/profile/subscription/UserSubscription.js index d3105fc..a49975f 100644 --- a/src/components/profile/subscription/UserSubscription.js +++ b/src/components/profile/subscription/UserSubscription.js @@ -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 = () => { ) : (
-
+

Subscribe now and elevate your development journey!

-
+
- + Access ALL current and future PlebDevs content
- + Personal mentorship & guidance and access to exclusive 1:1 booking calendar
- + Claim your own personal plebdevs.com Lightning Address
- Nostr + Nostr Claim your own personal plebdevs.com Nostr NIP-05 identity
+ +
+
+

Select Your Plan

+ 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' && ( +
+ Save ~17% with yearly subscription! +
+ )} +
+ {subscriptionType === 'yearly' ? '500,000' : '50,000'} sats + + ({subscriptionType === 'yearly' ? 'yearly' : 'monthly'}) + +
+
+
+
)} @@ -176,6 +226,9 @@ const UserSubscription = () => { {isProcessing ? (
@@ -186,6 +239,14 @@ const UserSubscription = () => {
) : (
+
+

+ Current Plan: {user?.role?.subscriptionType || 'monthly'} subscription +

+

+ Renews on: {subscribedUntil ? subscribedUntil.toLocaleDateString() : 'N/A'} +

+
{ title="Frequently Asked Questions" className="mt-2 border border-gray-700 rounded-lg" > -
+

How does the subscription work?

- 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.

+
+

What's the difference between monthly and yearly?

+

+ 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. +

+

How do I Subscribe? (Pay as you go)

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.

How do I Subscribe? (Recurring)

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.

diff --git a/src/components/ui/Modal.js b/src/components/ui/Modal.js new file mode 100644 index 0000000..5f35479 --- /dev/null +++ b/src/components/ui/Modal.js @@ -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 ? ( +
+
+ ) : footer; + + // Apply tailwind CSS to modify dialog elements + const dialogClassNames = ` + .p-dialog-footer { + background-color: #1f2937 !important; + border-top: 1px solid #374151 !important; + } + `; + + return ( + <> + + + {children} + + + ); +}; + +export default Modal; \ No newline at end of file diff --git a/src/constants/subscriptionPeriods.js b/src/constants/subscriptionPeriods.js new file mode 100644 index 0000000..76e5f45 --- /dev/null +++ b/src/constants/subscriptionPeriods.js @@ -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; +}; \ No newline at end of file diff --git a/src/db/models/roleModels.js b/src/db/models/roleModels.js index 51ed659..f8e011b 100644 --- a/src/db/models/roleModels.js +++ b/src/db/models/roleModels.js @@ -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, diff --git a/src/db/models/userModels.js b/src/db/models/userModels.js index 61a0952..1f97e91 100644 --- a/src/db/models/userModels.js +++ b/src/db/models/userModels.js @@ -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 }, }) ); diff --git a/src/pages/about.js b/src/pages/about.js index 2d66796..2bc1dd1 100644 --- a/src/pages/about.js +++ b/src/pages/about.js @@ -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); diff --git a/src/pages/api/users/subscription/cron.js b/src/pages/api/users/subscription/cron.js index 1b7012d..7bb8e87 100644 --- a/src/pages/api/users/subscription/cron.js +++ b/src/pages/api/users/subscription/cron.js @@ -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); diff --git a/src/pages/api/users/subscription/index.js b/src/pages/api/users/subscription/index.js index 8b828b6..a06e5c5 100644 --- a/src/pages/api/users/subscription/index.js +++ b/src/pages/api/users/subscription/index.js @@ -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) {