mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-03 07:42:03 +00:00
Merge pull request #75 from AustinKelsay/refactor/subscriptions
Refactor/subscriptions
This commit is contained in:
commit
2291591063
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Role" ADD COLUMN "subscriptionType" TEXT NOT NULL DEFAULT 'monthly';
|
@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
@ -83,6 +83,7 @@ model Role {
|
|||||||
userId String @unique
|
userId String @unique
|
||||||
subscribed Boolean @default(false)
|
subscribed Boolean @default(false)
|
||||||
admin Boolean @default(false)
|
admin Boolean @default(false)
|
||||||
|
subscriptionType String @default("monthly")
|
||||||
subscriptionStartDate DateTime?
|
subscriptionStartDate DateTime?
|
||||||
lastPaymentAt DateTime?
|
lastPaymentAt DateTime?
|
||||||
subscriptionExpiredAt DateTime?
|
subscriptionExpiredAt DateTime?
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { Tooltip } from 'primereact/tooltip';
|
import { Tooltip } from 'primereact/tooltip';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ const MoreInfo = ({
|
|||||||
/>
|
/>
|
||||||
{!isMobile && <Tooltip target=".pi-question-circle" position={tooltipPosition} />}
|
{!isMobile && <Tooltip target=".pi-question-circle" position={tooltipPosition} />}
|
||||||
|
|
||||||
<Dialog
|
<Modal
|
||||||
header={modalTitle}
|
header={modalTitle}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onHide={() => setVisible(false)}
|
onHide={() => setVisible(false)}
|
||||||
@ -32,7 +32,7 @@ const MoreInfo = ({
|
|||||||
breakpoints={{ '960px': '75vw', '641px': '90vw' }}
|
breakpoints={{ '960px': '75vw', '641px': '90vw' }}
|
||||||
>
|
>
|
||||||
{typeof modalBody === 'string' ? <p className="text-gray-200">{modalBody}</p> : modalBody}
|
{typeof modalBody === 'string' ? <p className="text-gray-200">{modalBody}</p> : modalBody}
|
||||||
</Dialog>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { LightningAddress } from '@getalby/lightning-tools';
|
import { LightningAddress } from '@getalby/lightning-tools';
|
||||||
import { track } from '@vercel/analytics';
|
import { track } from '@vercel/analytics';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
@ -227,11 +227,11 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId }
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Dialog
|
<Modal
|
||||||
visible={dialogVisible}
|
visible={dialogVisible}
|
||||||
onHide={() => setDialogVisible(false)}
|
onHide={() => setDialogVisible(false)}
|
||||||
header="Make Payment"
|
header="Make Payment"
|
||||||
style={{ width: isMobile ? '90vw' : '50vw' }}
|
width={isMobile ? '90vw' : '50vw'}
|
||||||
>
|
>
|
||||||
{invoice ? (
|
{invoice ? (
|
||||||
<Payment
|
<Payment
|
||||||
@ -243,7 +243,7 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId }
|
|||||||
) : (
|
) : (
|
||||||
<p>Loading payment details...</p>
|
<p>Loading payment details...</p>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { track } from '@vercel/analytics';
|
import { track } from '@vercel/analytics';
|
||||||
import { LightningAddress } from '@getalby/lightning-tools';
|
import { LightningAddress } from '@getalby/lightning-tools';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
@ -122,11 +122,11 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Dialog
|
<Modal
|
||||||
visible={dialogVisible}
|
visible={dialogVisible}
|
||||||
onHide={() => setDialogVisible(false)}
|
onHide={() => setDialogVisible(false)}
|
||||||
header="Make Payment"
|
header="Make Payment"
|
||||||
style={{ width: isMobile ? '90vw' : '50vw' }}
|
width={isMobile ? '90vw' : '50vw'}
|
||||||
>
|
>
|
||||||
{invoice ? (
|
{invoice ? (
|
||||||
<Payment
|
<Payment
|
||||||
@ -138,7 +138,7 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource
|
|||||||
) : (
|
) : (
|
||||||
<p>Loading payment details...</p>
|
<p>Loading payment details...</p>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -25,6 +25,7 @@ const SubscriptionPaymentButtons = ({
|
|||||||
oneTime = false,
|
oneTime = false,
|
||||||
recurring = false,
|
recurring = false,
|
||||||
layout = 'row',
|
layout = 'row',
|
||||||
|
subscriptionType = 'monthly',
|
||||||
}) => {
|
}) => {
|
||||||
const [invoice, setInvoice] = useState(null);
|
const [invoice, setInvoice] = useState(null);
|
||||||
const [showRecurringOptions, setShowRecurringOptions] = useState(false);
|
const [showRecurringOptions, setShowRecurringOptions] = useState(false);
|
||||||
@ -34,7 +35,13 @@ const SubscriptionPaymentButtons = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const lnAddress = process.env.NEXT_PUBLIC_LIGHTNING_ADDRESS;
|
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(() => {
|
useEffect(() => {
|
||||||
// Initialize Bitcoin Connect as early as possible
|
// Initialize Bitcoin Connect as early as possible
|
||||||
@ -74,7 +81,7 @@ const SubscriptionPaymentButtons = ({
|
|||||||
await ln.fetch();
|
await ln.fetch();
|
||||||
const newInvoice = await ln.requestInvoice({
|
const newInvoice = await ln.requestInvoice({
|
||||||
satoshi: amount,
|
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;
|
return newInvoice;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -86,7 +93,7 @@ const SubscriptionPaymentButtons = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePaymentSuccess = async response => {
|
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.');
|
showToast('success', 'Payment Successful', 'Your payment has been processed successfully.');
|
||||||
if (onSuccess) onSuccess(response);
|
if (onSuccess) onSuccess(response);
|
||||||
};
|
};
|
||||||
@ -117,9 +124,9 @@ const SubscriptionPaymentButtons = ({
|
|||||||
const initNwcOptions = {
|
const initNwcOptions = {
|
||||||
name: 'plebdevs.com',
|
name: 'plebdevs.com',
|
||||||
requestMethods: ['pay_invoice'],
|
requestMethods: ['pay_invoice'],
|
||||||
maxAmount: 50000,
|
maxAmount: amount,
|
||||||
editable: false,
|
editable: false,
|
||||||
budgetRenewal: 'monthly',
|
budgetRenewal: subscriptionType === 'yearly' ? 'yearly' : 'monthly',
|
||||||
expiresAt: yearFromNow,
|
expiresAt: yearFromNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -164,10 +171,11 @@ const SubscriptionPaymentButtons = ({
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
nwc: newNWCUrl,
|
nwc: newNWCUrl,
|
||||||
|
subscriptionType: subscriptionType,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (subscriptionResponse.status === 200) {
|
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!');
|
showToast('success', 'Subscription Setup', 'Recurring subscription setup successful!');
|
||||||
if (onRecurringSubscriptionSuccess) onRecurringSubscriptionSuccess();
|
if (onRecurringSubscriptionSuccess) onRecurringSubscriptionSuccess();
|
||||||
} else {
|
} else {
|
||||||
@ -229,10 +237,11 @@ const SubscriptionPaymentButtons = ({
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
nwc: nwcInput,
|
nwc: nwcInput,
|
||||||
|
subscriptionType: subscriptionType,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (subscriptionResponse.status === 200) {
|
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!');
|
showToast('success', 'NWC', 'Subscription setup successful!');
|
||||||
if (onRecurringSubscriptionSuccess) onRecurringSubscriptionSuccess();
|
if (onRecurringSubscriptionSuccess) onRecurringSubscriptionSuccess();
|
||||||
} else {
|
} else {
|
||||||
@ -256,11 +265,11 @@ const SubscriptionPaymentButtons = ({
|
|||||||
<>
|
<>
|
||||||
{!invoice && (
|
{!invoice && (
|
||||||
<div
|
<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)) && (
|
{(oneTime || (!oneTime && !recurring)) && (
|
||||||
<GenericButton
|
<GenericButton
|
||||||
label="Pay as you go"
|
label={`Pay as you go (${(amount).toLocaleString()} sats)`}
|
||||||
icon="pi pi-bolt"
|
icon="pi pi-bolt"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (status === 'unauthenticated') {
|
if (status === 'unauthenticated') {
|
||||||
@ -272,12 +281,12 @@ const SubscriptionPaymentButtons = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
severity="primary"
|
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)) && (
|
{(recurring || (!oneTime && !recurring)) && (
|
||||||
<GenericButton
|
<GenericButton
|
||||||
label="Setup Recurring Subscription"
|
label={`Setup Recurring ${subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1)} Subscription`}
|
||||||
icon={
|
icon={
|
||||||
<Image
|
<Image
|
||||||
src="/images/nwc-logo.svg"
|
src="/images/nwc-logo.svg"
|
||||||
@ -288,7 +297,7 @@ const SubscriptionPaymentButtons = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
severity="help"
|
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={() => {
|
onClick={() => {
|
||||||
if (status === 'unauthenticated') {
|
if (status === 'unauthenticated') {
|
||||||
console.log('unauthenticated');
|
console.log('unauthenticated');
|
||||||
@ -309,7 +318,7 @@ const SubscriptionPaymentButtons = ({
|
|||||||
<span className="my-4 text-lg font-bold">or</span>
|
<span className="my-4 text-lg font-bold">or</span>
|
||||||
<p className="text-lg font-bold">Manually enter NWC URL</p>
|
<p className="text-lg font-bold">Manually enter NWC URL</p>
|
||||||
<span className="text-sm text-gray-500">
|
<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>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -333,7 +342,7 @@ const SubscriptionPaymentButtons = ({
|
|||||||
onPaid={handlePaymentSuccess}
|
onPaid={handlePaymentSuccess}
|
||||||
onError={handlePaymentError}
|
onError={handlePaymentError}
|
||||||
paymentMethods="external"
|
paymentMethods="external"
|
||||||
title={`Pay ${amount} sats`}
|
title={`Pay ${(amount).toLocaleString()} sats`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Dropdown } from 'primereact/dropdown';
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
import GenericButton from '@/components/buttons/GenericButton';
|
import GenericButton from '@/components/buttons/GenericButton';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { Accordion, AccordionTab } from 'primereact/accordion';
|
import { Accordion, AccordionTab } from 'primereact/accordion';
|
||||||
import EmbeddedDocumentForm from '@/components/forms/course/embedded/EmbeddedDocumentForm';
|
import EmbeddedDocumentForm from '@/components/forms/course/embedded/EmbeddedDocumentForm';
|
||||||
import EmbeddedVideoForm from '@/components/forms/course/embedded/EmbeddedVideoForm';
|
import EmbeddedVideoForm from '@/components/forms/course/embedded/EmbeddedVideoForm';
|
||||||
@ -233,23 +233,23 @@ const LessonSelector = ({
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
<GenericButton label="Add New Lesson" onClick={addNewLesson} className="mt-4" type="button" />
|
<GenericButton label="Add New Lesson" onClick={addNewLesson} className="mt-4" type="button" />
|
||||||
|
|
||||||
<Dialog
|
<Modal
|
||||||
className="w-full max-w-screen-md"
|
className="w-full max-w-screen-md"
|
||||||
visible={showDocumentForm}
|
visible={showDocumentForm}
|
||||||
onHide={() => setShowDocumentForm(false)}
|
onHide={() => setShowDocumentForm(false)}
|
||||||
header="Create New Document"
|
header="Create New Document"
|
||||||
>
|
>
|
||||||
<EmbeddedDocumentForm onSave={handleNewDocumentSave} isPaid={isPaidCourse} />
|
<EmbeddedDocumentForm onSave={handleNewDocumentSave} isPaid={isPaidCourse} />
|
||||||
</Dialog>
|
</Modal>
|
||||||
|
|
||||||
<Dialog
|
<Modal
|
||||||
className="w-full max-w-screen-md"
|
className="w-full max-w-screen-md"
|
||||||
visible={showVideoForm}
|
visible={showVideoForm}
|
||||||
onHide={() => setShowVideoForm(false)}
|
onHide={() => setShowVideoForm(false)}
|
||||||
header="Create New Video"
|
header="Create New Video"
|
||||||
>
|
>
|
||||||
<EmbeddedVideoForm onSave={handleNewVideoSave} isPaid={isPaidCourse} />
|
<EmbeddedVideoForm onSave={handleNewVideoSave} isPaid={isPaidCourse} />
|
||||||
</Dialog>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
const WelcomeModal = () => {
|
const WelcomeModal = () => {
|
||||||
@ -26,10 +26,11 @@ const WelcomeModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Modal
|
||||||
header="Welcome to PlebDevs!"
|
header="Welcome to PlebDevs!"
|
||||||
visible={visible}
|
visible={visible}
|
||||||
style={{ width: '90vw', maxWidth: '600px' }}
|
width="90vw"
|
||||||
|
style={{ maxWidth: '600px' }}
|
||||||
onHide={onHide}
|
onHide={onHide}
|
||||||
>
|
>
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
@ -74,7 +75,7 @@ const WelcomeModal = () => {
|
|||||||
<p className="font-bold text-lg">Let's start your coding journey! 🚀</p>
|
<p className="font-bold text-lg">Let's start your coding journey! 🚀</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useNDKContext } from '@/context/NDKContext';
|
import { useNDKContext } from '@/context/NDKContext';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
@ -99,7 +99,13 @@ const UserBadges = ({ visible, onHide }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="p-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center items-center h-40">
|
<div className="flex justify-center items-center h-40">
|
||||||
@ -147,7 +153,7 @@ const UserBadges = ({ visible, onHide }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Menu } from 'primereact/menu';
|
import { Menu } from 'primereact/menu';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
@ -284,12 +284,12 @@ const UserProfileCard = ({ user }) => {
|
|||||||
<>
|
<>
|
||||||
{windowWidth <= 1440 ? <MobileProfileCard /> : <DesktopProfileCard />}
|
{windowWidth <= 1440 ? <MobileProfileCard /> : <DesktopProfileCard />}
|
||||||
<UserBadges visible={showBadges} onHide={() => setShowBadges(false)} />
|
<UserBadges visible={showBadges} onHide={() => setShowBadges(false)} />
|
||||||
<Dialog
|
<Modal
|
||||||
visible={showRelaysModal}
|
visible={showRelaysModal}
|
||||||
onHide={() => setShowRelaysModal(false)}
|
onHide={() => setShowRelaysModal(false)}
|
||||||
header="Manage Relays"
|
header="Manage Relays"
|
||||||
className="w-[90vw] max-w-[800px]"
|
width="full"
|
||||||
modal
|
className="max-w-[800px]"
|
||||||
>
|
>
|
||||||
<UserRelaysTable
|
<UserRelaysTable
|
||||||
ndk={ndk}
|
ndk={ndk}
|
||||||
@ -297,7 +297,7 @@ const UserProfileCard = ({ user }) => {
|
|||||||
setUserRelays={setUserRelays}
|
setUserRelays={setUserRelays}
|
||||||
reInitializeNDK={reInitializeNDK}
|
reInitializeNDK={reInitializeNDK}
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
|
|
||||||
@ -25,10 +25,10 @@ const CalendlyEmbed = ({ visible, onHide, userId, userEmail, userName }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Modal
|
||||||
header="Schedule a Meeting"
|
header="Schedule a Meeting"
|
||||||
visible={visible}
|
visible={visible}
|
||||||
style={{ width: windowWidth < 768 ? '100vw' : '50vw' }}
|
width={windowWidth < 768 ? '100vw' : '50vw'}
|
||||||
footer={dialogFooter}
|
footer={dialogFooter}
|
||||||
onHide={onHide}
|
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 }))}`}
|
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' }}
|
style={{ minWidth: '320px', height: '700px' }}
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
@ -31,7 +31,11 @@ const CancelSubscription = ({ visible, onHide }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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>
|
<p>Are you sure you want to cancel your subscription?</p>
|
||||||
<div className="flex flex-row justify-center mt-6">
|
<div className="flex flex-row justify-center mt-6">
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
@ -46,7 +50,7 @@ const CancelSubscription = ({ visible, onHide }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
@ -111,11 +111,11 @@ const LightningAddressForm = ({ visible, onHide }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Modal
|
||||||
header="Lightning Address"
|
header="Lightning Address"
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onHide={onHide}
|
onHide={onHide}
|
||||||
style={{ width: windowWidth < 768 ? '100vw' : '60vw' }}
|
width={windowWidth < 768 ? '100vw' : '60vw'}
|
||||||
>
|
>
|
||||||
{existingLightningAddress ? (
|
{existingLightningAddress ? (
|
||||||
<p>Update your Lightning Address details</p>
|
<p>Update your Lightning Address details</p>
|
||||||
@ -216,7 +216,7 @@ const LightningAddressForm = ({ visible, onHide }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
@ -82,11 +82,11 @@ const Nip05Form = ({ visible, onHide }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Modal
|
||||||
header="NIP-05"
|
header="NIP-05"
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onHide={onHide}
|
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>}
|
{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]">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { Card } from 'primereact/card';
|
import { Card } from 'primereact/card';
|
||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
|
import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
|
||||||
@ -50,11 +50,10 @@ const RenewSubscription = ({ visible, onHide, subscribedUntil }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Modal
|
||||||
header="Renew Your PlebDevs Subscription"
|
header="Renew Your PlebDevs Subscription"
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onHide={onHide}
|
onHide={onHide}
|
||||||
className="p-fluid pb-0 w-fit"
|
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
|
<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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
|
import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@ -17,6 +17,8 @@ import LightningAddressForm from '@/components/profile/subscription/LightningAdd
|
|||||||
import NostrIcon from '../../../../public/images/nostr.png';
|
import NostrIcon from '../../../../public/images/nostr.png';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
|
import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
|
||||||
|
import { SelectButton } from 'primereact/selectbutton';
|
||||||
|
import { calculateExpirationDate } from '@/constants/subscriptionPeriods';
|
||||||
|
|
||||||
const SubscribeModal = ({ user }) => {
|
const SubscribeModal = ({ user }) => {
|
||||||
const { data: session, update } = useSession();
|
const { data: session, update } = useSession();
|
||||||
@ -33,19 +35,37 @@ const SubscribeModal = ({ user }) => {
|
|||||||
const [nip05Visible, setNip05Visible] = useState(false);
|
const [nip05Visible, setNip05Visible] = useState(false);
|
||||||
const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
|
const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
|
||||||
const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
|
const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
|
||||||
|
const [subscriptionType, setSubscriptionType] = useState('monthly');
|
||||||
|
|
||||||
|
const subscriptionOptions = [
|
||||||
|
{ label: 'Monthly', value: 'monthly' },
|
||||||
|
{ label: 'Yearly', value: 'yearly' },
|
||||||
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && user.role) {
|
if (user && user.role) {
|
||||||
setSubscribed(user.role.subscribed);
|
setSubscribed(user.role.subscribed);
|
||||||
const subscribedAt = new Date(user.role.lastPaymentAt);
|
setSubscriptionType(user.role.subscriptionType || 'monthly');
|
||||||
const subscribedUntil = new Date(subscribedAt.getTime() + 31 * 24 * 60 * 60 * 1000);
|
|
||||||
setSubscribedUntil(subscribedUntil);
|
// 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) {
|
if (user.role.subscriptionExpiredAt) {
|
||||||
const expiredAt = new Date(user.role.subscriptionExpiredAt);
|
const expiredAt = new Date(user.role.subscriptionExpiredAt);
|
||||||
setSubscriptionExpiredAt(expiredAt);
|
setSubscriptionExpiredAt(expiredAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user, subscriptionType]);
|
||||||
|
|
||||||
const handleSubscriptionSuccess = async response => {
|
const handleSubscriptionSuccess = async response => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
@ -53,6 +73,7 @@ const SubscribeModal = ({ user }) => {
|
|||||||
const apiResponse = await axios.put('/api/users/subscription', {
|
const apiResponse = await axios.put('/api/users/subscription', {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
|
subscriptionType: subscriptionType,
|
||||||
});
|
});
|
||||||
if (apiResponse.data) {
|
if (apiResponse.data) {
|
||||||
await update();
|
await update();
|
||||||
@ -161,7 +182,7 @@ const SubscribeModal = ({ user }) => {
|
|||||||
<Message className="w-fit" severity="success" text="Subscribed!" />
|
<Message className="w-fit" severity="success" text="Subscribed!" />
|
||||||
<p className="mt-3">Thank you for your support 🎉</p>
|
<p className="mt-3">Thank you for your support 🎉</p>
|
||||||
<p className="text-sm text-gray-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -170,7 +191,7 @@ const SubscribeModal = ({ user }) => {
|
|||||||
<Message className="w-fit" severity="success" text="Subscribed!" />
|
<Message className="w-fit" severity="success" text="Subscribed!" />
|
||||||
<p className="mt-3">Thank you for your support 🎉</p>
|
<p className="mt-3">Thank you for your support 🎉</p>
|
||||||
<p className="text-sm text-gray-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -203,11 +224,10 @@ const SubscribeModal = ({ user }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
<Dialog
|
<Modal
|
||||||
header="Subscribe to PlebDevs"
|
header="Subscribe to PlebDevs"
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onHide={onHide}
|
onHide={onHide}
|
||||||
className="p-fluid pb-0 w-fit"
|
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
|
<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>
|
<span className="ml-2">Processing subscription...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="shadow-lg">
|
<Card className="shadow-none">
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-6">
|
||||||
<h2 className="text-2xl font-bold text-primary">Unlock Premium Benefits</h2>
|
<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>
|
<p className="text-gray-400">Subscribe now and elevate your development journey!</p>
|
||||||
</div>
|
</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">
|
<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>
|
<span>Access ALL current and future PlebDevs content</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<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>
|
<span>
|
||||||
Personal mentorship & guidance and access to exclusive 1:1 booking calendar
|
Personal mentorship & guidance and access to exclusive 1:1 booking calendar
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<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>
|
<span>Claim your own personal plebdevs.com Lightning Address</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<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>
|
<span>Claim your own personal plebdevs.com Nostr NIP-05 identity</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SubscriptionPaymentButtons
|
|
||||||
onSuccess={handleSubscriptionSuccess}
|
<div className="subscription-plan-selector my-8">
|
||||||
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
|
<div className="flex flex-col items-center mb-4">
|
||||||
onError={handleSubscriptionError}
|
<h3 className="text-xl font-bold mb-4">Select Your Plan</h3>
|
||||||
setIsProcessing={setIsProcessing}
|
<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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Modal>
|
||||||
<CalendlyEmbed
|
<CalendlyEmbed
|
||||||
visible={calendlyVisible}
|
visible={calendlyVisible}
|
||||||
onHide={() => setCalendlyVisible(false)}
|
onHide={() => setCalendlyVisible(false)}
|
||||||
|
@ -16,6 +16,8 @@ import CalendlyEmbed from '@/components/profile/subscription/CalendlyEmbed';
|
|||||||
import Nip05Form from '@/components/profile/subscription/Nip05Form';
|
import Nip05Form from '@/components/profile/subscription/Nip05Form';
|
||||||
import LightningAddressForm from '@/components/profile/subscription/LightningAddressForm';
|
import LightningAddressForm from '@/components/profile/subscription/LightningAddressForm';
|
||||||
import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
|
import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
|
||||||
|
import { SelectButton } from 'primereact/selectbutton';
|
||||||
|
import { SUBSCRIPTION_PERIODS, calculateExpirationDate } from '@/constants/subscriptionPeriods';
|
||||||
|
|
||||||
const UserSubscription = () => {
|
const UserSubscription = () => {
|
||||||
const { data: session, update } = useSession();
|
const { data: session, update } = useSession();
|
||||||
@ -32,19 +34,37 @@ const UserSubscription = () => {
|
|||||||
const [nip05Visible, setNip05Visible] = useState(false);
|
const [nip05Visible, setNip05Visible] = useState(false);
|
||||||
const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
|
const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
|
||||||
const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
|
const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
|
||||||
|
const [subscriptionType, setSubscriptionType] = useState('monthly');
|
||||||
|
|
||||||
|
const subscriptionOptions = [
|
||||||
|
{ label: 'Monthly', value: 'monthly' },
|
||||||
|
{ label: 'Yearly', value: 'yearly' },
|
||||||
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session && session?.user) {
|
if (session && session?.user) {
|
||||||
setUser(session.user);
|
setUser(session.user);
|
||||||
|
if (session.user.role?.subscriptionType) {
|
||||||
|
setSubscriptionType(session.user.role.subscriptionType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && user.role) {
|
if (user && user.role) {
|
||||||
setSubscribed(user.role.subscribed);
|
setSubscribed(user.role.subscribed);
|
||||||
const subscribedAt = new Date(user.role.lastPaymentAt);
|
|
||||||
const subscribedUntil = new Date(subscribedAt.getTime() + 31 * 24 * 60 * 60 * 1000);
|
if (user.role.lastPaymentAt) {
|
||||||
setSubscribedUntil(subscribedUntil);
|
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) {
|
if (user.role.subscriptionExpiredAt) {
|
||||||
const expiredAt = new Date(user.role.subscriptionExpiredAt);
|
const expiredAt = new Date(user.role.subscriptionExpiredAt);
|
||||||
setSubscriptionExpiredAt(expiredAt);
|
setSubscriptionExpiredAt(expiredAt);
|
||||||
@ -58,6 +78,7 @@ const UserSubscription = () => {
|
|||||||
const apiResponse = await axios.put('/api/users/subscription', {
|
const apiResponse = await axios.put('/api/users/subscription', {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
|
subscriptionType: subscriptionType,
|
||||||
});
|
});
|
||||||
if (apiResponse.data) {
|
if (apiResponse.data) {
|
||||||
await update();
|
await update();
|
||||||
@ -134,37 +155,66 @@ const UserSubscription = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="mb-2">
|
<div className="mb-4">
|
||||||
<p className="text-gray-400">
|
<p className="text-gray-400">
|
||||||
Subscribe now and elevate your development journey!
|
Subscribe now and elevate your development journey!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 mb-1">
|
<div className="flex flex-col gap-5 mb-5">
|
||||||
<div className="flex items-center">
|
<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>
|
<span>Access ALL current and future PlebDevs content</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<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>
|
<span>
|
||||||
Personal mentorship & guidance and access to exclusive 1:1 booking calendar
|
Personal mentorship & guidance and access to exclusive 1:1 booking calendar
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<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>
|
<span>Claim your own personal plebdevs.com Lightning Address</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<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>
|
<span>Claim your own personal plebdevs.com Nostr NIP-05 identity</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<SubscriptionPaymentButtons
|
||||||
onSuccess={handleSubscriptionSuccess}
|
onSuccess={handleSubscriptionSuccess}
|
||||||
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
|
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
|
||||||
onError={handleSubscriptionError}
|
onError={handleSubscriptionError}
|
||||||
setIsProcessing={setIsProcessing}
|
setIsProcessing={setIsProcessing}
|
||||||
layout={windowWidth < 768 ? 'col' : 'row'}
|
layout={windowWidth < 768 ? 'col' : 'row'}
|
||||||
|
subscriptionType={subscriptionType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -176,6 +226,9 @@ const UserSubscription = () => {
|
|||||||
<Card
|
<Card
|
||||||
title="Subscription Benefits"
|
title="Subscription Benefits"
|
||||||
className="h-[330px] border border-gray-700 rounded-lg"
|
className="h-[330px] border border-gray-700 rounded-lg"
|
||||||
|
pt={{
|
||||||
|
content: { className: 'py-0' },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
|
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
|
||||||
@ -186,6 +239,14 @@ const UserSubscription = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col">
|
<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">
|
<div className="flex flex-col gap-4">
|
||||||
<GenericButton
|
<GenericButton
|
||||||
severity="info"
|
severity="info"
|
||||||
@ -249,28 +310,35 @@ const UserSubscription = () => {
|
|||||||
title="Frequently Asked Questions"
|
title="Frequently Asked Questions"
|
||||||
className="mt-2 border border-gray-700 rounded-lg"
|
className="mt-2 border border-gray-700 rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">How does the subscription work?</h3>
|
<h3 className="text-lg font-semibold">How does the subscription work?</h3>
|
||||||
<p>
|
<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
|
return you get access to premium features and all of the paid content. You can
|
||||||
cancel at any time.
|
cancel at any time.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">What'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>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">How do I Subscribe? (Pay as you go)</h3>
|
<h3 className="text-lg font-semibold">How do I Subscribe? (Pay as you go)</h3>
|
||||||
<p>
|
<p>
|
||||||
The pay as you go subscription is a one-time payment that gives you access to all
|
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
|
of the premium features for one month or year, depending on your selected plan. You will need to manually renew your
|
||||||
subscription every month.
|
subscription when it expires.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">How do I Subscribe? (Recurring)</h3>
|
<h3 className="text-lg font-semibold">How do I Subscribe? (Recurring)</h3>
|
||||||
<p>
|
<p>
|
||||||
The recurring subscription option allows you to submit a Nostr Wallet Connect URI
|
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.
|
cancel at any time.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
112
src/components/ui/Modal.js
Normal file
112
src/components/ui/Modal.js
Normal 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;
|
36
src/constants/subscriptionPeriods.js
Normal file
36
src/constants/subscriptionPeriods.js
Normal 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;
|
||||||
|
};
|
@ -6,6 +6,7 @@ export const createRole = async data => {
|
|||||||
user: { connect: { id: data.userId } },
|
user: { connect: { id: data.userId } },
|
||||||
admin: data.admin,
|
admin: data.admin,
|
||||||
subscribed: data.subscribed,
|
subscribed: data.subscribed,
|
||||||
|
subscriptionType: data.subscriptionType || 'monthly',
|
||||||
// Add other fields as needed, with default values or null if not provided
|
// Add other fields as needed, with default values or null if not provided
|
||||||
subscriptionStartDate: null,
|
subscriptionStartDate: null,
|
||||||
lastPaymentAt: null,
|
lastPaymentAt: null,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import prisma from '../prisma';
|
import prisma from '../prisma';
|
||||||
|
import { SUBSCRIPTION_PERIODS } from '@/constants/subscriptionPeriods';
|
||||||
|
|
||||||
export const getAllUsers = async () => {
|
export const getAllUsers = async () => {
|
||||||
return await prisma.user.findMany({
|
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 {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return await prisma.user.update({
|
return await prisma.user.update({
|
||||||
@ -175,6 +176,7 @@ export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
|
|||||||
upsert: {
|
upsert: {
|
||||||
create: {
|
create: {
|
||||||
subscribed: isSubscribed,
|
subscribed: isSubscribed,
|
||||||
|
subscriptionType: subscriptionType,
|
||||||
subscriptionStartDate: isSubscribed ? now : null,
|
subscriptionStartDate: isSubscribed ? now : null,
|
||||||
lastPaymentAt: isSubscribed ? now : null,
|
lastPaymentAt: isSubscribed ? now : null,
|
||||||
nwc: nwc ? nwc : null,
|
nwc: nwc ? nwc : null,
|
||||||
@ -182,6 +184,7 @@ export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
|
|||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
subscribed: isSubscribed,
|
subscribed: isSubscribed,
|
||||||
|
subscriptionType: subscriptionType,
|
||||||
subscriptionStartDate: isSubscribed ? { set: now } : { set: null },
|
subscriptionStartDate: isSubscribed ? { set: now } : { set: null },
|
||||||
lastPaymentAt: isSubscribed ? now : { set: null },
|
lastPaymentAt: isSubscribed ? now : { set: null },
|
||||||
nwc: nwc ? nwc : null,
|
nwc: nwc ? nwc : null,
|
||||||
@ -202,20 +205,38 @@ export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
|
|||||||
export const findExpiredSubscriptions = async () => {
|
export const findExpiredSubscriptions = async () => {
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
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({
|
const result = await prisma.role.findMany({
|
||||||
where: {
|
where: {
|
||||||
subscribed: true,
|
subscribed: true,
|
||||||
lastPaymentAt: {
|
OR: [
|
||||||
lt: oneMonthAndOneHourAgo,
|
{
|
||||||
},
|
subscriptionType: 'monthly',
|
||||||
|
lastPaymentAt: { lt: monthlyExpiration }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subscriptionType: 'yearly',
|
||||||
|
lastPaymentAt: { lt: yearlyExpiration }
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
userId: true,
|
userId: true,
|
||||||
nwc: true,
|
nwc: true,
|
||||||
|
subscriptionType: true,
|
||||||
subscriptionExpiredAt: true,
|
subscriptionExpiredAt: true,
|
||||||
subscriptionStartDate: true,
|
subscriptionStartDate: true,
|
||||||
admin: true,
|
admin: true,
|
||||||
@ -231,6 +252,24 @@ export const findExpiredSubscriptions = async () => {
|
|||||||
export const expireUserSubscriptions = async userIds => {
|
export const expireUserSubscriptions = async userIds => {
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
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 =>
|
const updatePromises = userIds.map(userId =>
|
||||||
prisma.role.update({
|
prisma.role.update({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@ -240,6 +279,8 @@ export const expireUserSubscriptions = async userIds => {
|
|||||||
lastPaymentAt: null,
|
lastPaymentAt: null,
|
||||||
nwc: null,
|
nwc: null,
|
||||||
subscriptionExpiredAt: now,
|
subscriptionExpiredAt: now,
|
||||||
|
// Keep the subscription type for historical data and easy renewal
|
||||||
|
// subscriptionType: Don't change the existing value
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -18,6 +18,7 @@ import RenewSubscription from '@/components/profile/subscription/RenewSubscripti
|
|||||||
import Nip05Form from '@/components/profile/subscription/Nip05Form';
|
import Nip05Form from '@/components/profile/subscription/Nip05Form';
|
||||||
import LightningAddressForm from '@/components/profile/subscription/LightningAddressForm';
|
import LightningAddressForm from '@/components/profile/subscription/LightningAddressForm';
|
||||||
import MoreInfo from '@/components/MoreInfo';
|
import MoreInfo from '@/components/MoreInfo';
|
||||||
|
import { SUBSCRIPTION_PERIODS } from '@/constants/subscriptionPeriods';
|
||||||
|
|
||||||
const AboutPage = () => {
|
const AboutPage = () => {
|
||||||
const { data: session, update } = useSession();
|
const { data: session, update } = useSession();
|
||||||
@ -117,7 +118,7 @@ const AboutPage = () => {
|
|||||||
if (user && user.role) {
|
if (user && user.role) {
|
||||||
setSubscribed(user.role.subscribed);
|
setSubscribed(user.role.subscribed);
|
||||||
const subscribedAt = new Date(user.role.lastPaymentAt);
|
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);
|
setSubscribedUntil(subscribedUntil);
|
||||||
if (user.role.subscriptionExpiredAt) {
|
if (user.role.subscriptionExpiredAt) {
|
||||||
const expiredAt = new Date(user.role.subscriptionExpiredAt);
|
const expiredAt = new Date(user.role.subscriptionExpiredAt);
|
||||||
|
@ -7,18 +7,39 @@ import { webln } from '@getalby/sdk';
|
|||||||
import { LightningAddress } from '@getalby/lightning-tools';
|
import { LightningAddress } from '@getalby/lightning-tools';
|
||||||
|
|
||||||
const lnAddress = process.env.LIGHTNING_ADDRESS;
|
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) {
|
export default async function handler(req, res) {
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
try {
|
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();
|
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 = [];
|
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) {
|
if (nwc) {
|
||||||
try {
|
try {
|
||||||
|
console.log(`Processing ${subscriptionType} subscription renewal for user ${userId}`);
|
||||||
|
const amount = getAmount(subscriptionType);
|
||||||
const nwcProvider = new webln.NostrWebLNProvider({
|
const nwcProvider = new webln.NostrWebLNProvider({
|
||||||
nostrWalletConnectUrl: nwc,
|
nostrWalletConnectUrl: nwc,
|
||||||
});
|
});
|
||||||
@ -26,30 +47,49 @@ export default async function handler(req, res) {
|
|||||||
|
|
||||||
const ln = new LightningAddress(lnAddress);
|
const ln = new LightningAddress(lnAddress);
|
||||||
await ln.fetch();
|
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);
|
const response = await nwcProvider.sendPayment(newInvoice?.paymentRequest);
|
||||||
|
|
||||||
if (response && response?.preimage) {
|
if (response && response?.preimage) {
|
||||||
console.log(`SUBSCRIPTION AUTO-RENEWED`, response);
|
console.log(`SUBSCRIPTION AUTO-RENEWED (${subscriptionType}) for User: ${userId}`);
|
||||||
await updateUserSubscription(userId, true, nwc);
|
// 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
|
continue; // Skip adding to stillExpired list
|
||||||
} else {
|
} else {
|
||||||
console.log(`Payment failed for user ${userId}: (stillExpired)`, response);
|
console.log(`Payment failed for ${subscriptionType} subscription for user ${userId}: (stillExpired)`, response);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
stillExpired.push(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expire all subscriptions that couldn't be renewed
|
||||||
const expiredCount = await expireUserSubscriptions(stillExpired);
|
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({
|
res.status(200).json({
|
||||||
message: `Cron job completed successfully.
|
message: `Cron job completed successfully.
|
||||||
Processed ${expiredSubscriptions.length} subscriptions.
|
Processed ${expiredSubscriptions.length} subscriptions (${stats.monthly.processed} monthly, ${stats.yearly.processed} yearly).
|
||||||
Expired ${expiredCount} subscriptions.`,
|
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) {
|
} catch (error) {
|
||||||
console.error('Cron job error:', error);
|
console.error('Cron job error:', error);
|
||||||
|
@ -12,8 +12,8 @@ export default async function handler(req, res) {
|
|||||||
|
|
||||||
if (req.method === 'PUT') {
|
if (req.method === 'PUT') {
|
||||||
try {
|
try {
|
||||||
const { userId, isSubscribed, nwc } = req.body;
|
const { userId, isSubscribed, nwc, subscriptionType = 'monthly' } = req.body;
|
||||||
const updatedUser = await updateUserSubscription(userId, isSubscribed, nwc);
|
const updatedUser = await updateUserSubscription(userId, isSubscribed, nwc, subscriptionType);
|
||||||
|
|
||||||
res.status(200).json(updatedUser);
|
res.status(200).json(updatedUser);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user