Merge pull request #48 from AustinKelsay/refactor/layout-and-sidebar

Refactor/layout and sidebar
This commit is contained in:
Austin Kelsay 2025-03-29 09:56:09 -07:00 committed by GitHub
commit 5e58518ad7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1365 additions and 978 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@ -20,6 +20,9 @@ const BottomBar = () => {
<div onClick={() => router.push('/feed?channel=global')} className={`hover:bg-gray-700 cursor-pointer px-4 py-2 rounded-lg ${isActive('/feed') ? 'bg-gray-700' : ''}`}>
<i className="pi pi-comments text-2xl" />
</div>
<div onClick={() => router.push('/about')} className={`hover:bg-gray-700 cursor-pointer px-4 py-2 rounded-lg ${isActive('/about') ? 'bg-gray-700' : ''}`}>
<i className="pi pi-info-circle text-2xl" />
</div>
</div>
);
};

View File

@ -3,7 +3,7 @@ import { Dialog } from 'primereact/dialog';
import { Tooltip } from 'primereact/tooltip';
import useWindowWidth from '@/hooks/useWindowWidth';
const MoreInfo = ({ tooltip, modalTitle, modalBody, className = '' }) => {
const MoreInfo = ({ tooltip, modalTitle, modalBody, className = '', tooltipPosition = 'right' }) => {
const [visible, setVisible] = useState(false);
const windowWidth = useWindowWidth();
const isMobile = windowWidth < 768;
@ -14,11 +14,9 @@ const MoreInfo = ({ tooltip, modalTitle, modalBody, className = '' }) => {
className={`pi pi-question-circle cursor-pointer ${className}`}
onClick={() => setVisible(true)}
data-pr-tooltip={tooltip}
data-pr-position="right"
data-pr-at="right+5 top"
data-pr-my="left center-2"
data-pr-position={tooltipPosition}
/>
{!isMobile && <Tooltip target=".pi-question-circle" />}
{!isMobile && <Tooltip target=".pi-question-circle" position={tooltipPosition} />}
<Dialog
header={modalTitle}

View File

@ -2,13 +2,11 @@ import React, { useEffect, useState, useRef } from 'react';
import useWindowWidth from '@/hooks/useWindowWidth';
import Image from 'next/image';
import { getSession, signIn, useSession } from 'next-auth/react';
import { useImageProxy } from '@/hooks/useImageProxy';
import { useRouter } from 'next/router';
import { Avatar } from 'primereact/avatar';
import { AvatarGroup } from 'primereact/avatargroup';
import GenericButton from '../buttons/GenericButton';
import HeroImage from '../../../public/images/hero-image.png';
import plebdevsGuy from '../../../public/images/plebdevs-guy.png';
const HeroBanner = () => {
const [currentTech, setCurrentTech] = useState('Bitcoin');
@ -16,7 +14,6 @@ const HeroBanner = () => {
const techs = ['Bitcoin', 'Lightning', 'Nostr'];
const windowWidth = useWindowWidth();
const router = useRouter();
const { returnImageProxy } = useImageProxy();
const { data: session } = useSession();
const isTabView = windowWidth <= 1360;
@ -49,10 +46,10 @@ const HeroBanner = () => {
};
const getHeroHeight = () => {
if (isSuperWideScreen) return 'h-[900px]';
if (isWideScreen) return 'h-[700px]';
if (isMobile) return 'h-[450px]';
return 'h-[600px]';
if (isSuperWideScreen) return 'h-[700px]';
if (isWideScreen) return 'h-[550px]';
if (isMobile) return 'h-[400px]';
return 'h-[500px]';
};
const handleLearnToCode = async () => {
@ -106,7 +103,7 @@ const HeroBanner = () => {
};
return (
<div className={`${getHeroHeight()} ${isTabView ? 'mx-0' : 'm-14'} relative flex justify-center items-center overflow-hidden drop-shadow-2xl`}>
<div className={`${getHeroHeight()} ${isTabView ? 'mx-0 w-full' : 'mt-4 mx-12'} relative flex justify-center items-center overflow-hidden drop-shadow-2xl rounded-lg`}>
<Image
src={HeroImage}
alt="Banner"
@ -118,7 +115,7 @@ const HeroBanner = () => {
<div className="absolute inset-0 bg-gradient-to-br from-black via-black/20 to-transparent rounded-lg" />
{!isTabView && (
<div className="absolute right-0 top-24 bottom-0 w-1/2 overflow-hidden rounded-l-lg opacity-100 p-2 rounded-lg shadow-lg mr-2">
<div className="absolute right-0 top-auto bottom-auto w-1/2 overflow-hidden opacity-100 p-4 shadow-lg">
<video
className="w-full object-cover rounded-lg shadow-lg"
autoPlay
@ -133,8 +130,8 @@ const HeroBanner = () => {
</div>
)}
<div className={`absolute inset-0 flex flex-col justify-center ${isTabView ? 'items-center text-center' : 'items-start pl-8'}`}>
<h1 className={`text-4xl sm:text-4xl lg:text-6xl font-bold leading-tight mb-6 ${isTabView ? 'px-4' : 'max-w-[50%]'}`}>
<div className={`absolute inset-0 flex flex-col justify-center ${isTabView ? 'items-center text-center px-4' : 'items-start pl-8'}`}>
<h1 className={`text-4xl sm:text-4xl lg:text-6xl font-bold leading-tight mb-4 ${isTabView ? 'px-4' : 'max-w-[50%]'}`}>
<span className="block">Learn to code</span>
<span className={`block ${isTabView ? `transition-opacity duration-500 ${isAnimating ? 'opacity-0' : 'opacity-100'}` : ''}`}>
Build on{' '}
@ -145,16 +142,16 @@ const HeroBanner = () => {
<span className="block">Become a dev</span>
</h1>
{isMobile ? (
<h3 className="text-[#f8f8ff] mb-8 font-semibold">
<h3 className="text-[#f8f8ff] mb-6 font-semibold">
A one of a kind developer education, content, and community platform built on Nostr and fully Lightning integrated.
</h3>
) : (
<h2 className="text-[#f8f8ff] mb-8 font-semibold max-w-[42%]">
<h2 className="text-[#f8f8ff] mb-6 font-semibold max-w-[42%]">
A one of a kind developer education, content, and community platform built on Nostr and fully Lightning integrated.
</h2>
)}
<div
className="mb-8 flex flex-row hover:opacity-70 cursor-pointer"
className="mb-6 flex flex-row hover:opacity-70 cursor-pointer"
onClick={() => !isMobile && window.open('https://www.udemy.com/user/austin-james-kelsay/', '_blank')}
style={{ cursor: isMobile ? 'default' : 'pointer' }}
>
@ -181,21 +178,10 @@ const HeroBanner = () => {
label="Learn To Code"
icon={<i className="pi pi-book pr-2 text-2xl" />}
rounded
severity="info"
className="border-2"
size={isMobile ? null : "large"}
outlined
onClick={handleLearnToCode}
/>
<GenericButton
label="Level Up"
icon={<i className="pi pi-video pr-2 text-2xl" />}
rounded
size={isMobile ? null : "large"}
severity="success"
className="border-2"
outlined
onClick={() => router.push('/content?tag=all')}
onClick={handleLearnToCode}
/>
</div>
</div>

View File

@ -18,7 +18,7 @@ const responsiveOptions = [
numScroll: 1
},
{
breakpoint: '575px',
breakpoint: '675px',
numVisible: 1,
numScroll: 1
}
@ -56,18 +56,18 @@ export default function CoursesCarousel() {
return (
<>
<h3 className={`ml-[6%] mt-4 max-mob:text-3xl max-mob:ml-10`}>Courses</h3>
<Divider className={`${isMobileView ? '' : 'hidden'}`} />
<h3 className={`ml-[3%] mt-4 max-mob:text-2xl max-tab:ml-10 max-mob:ml-5`}>Courses</h3>
<Divider className='w-[95%] mx-auto max-tab:hidden max-mob:w-[100%]' />
<div className={"min-h-[384px]"}>
<Carousel
value={coursesLoading || !processedCourses.length ? [{}, {}, {}] : [...processedCourses]}
numVisible={2}
pt={{
previousButton: {
className: isMobileView ? 'm-0' : ''
className: 'm-0'
},
nextButton: {
className: isMobileView ? 'm-0' : ''
className: 'm-0'
}
}}
itemTemplate={(item) =>

View File

@ -20,7 +20,7 @@ const responsiveOptions = [
numScroll: 1
},
{
breakpoint: '575px',
breakpoint: '675px',
numVisible: 1,
numScroll: 1
}
@ -82,17 +82,17 @@ export default function DocumentsCarousel() {
return (
<>
<h3 className={`ml-[6%] mt-4 max-mob:text-3xl max-mob:ml-10`}>Documents</h3>
<Divider className={`${isMobileView ? '' : 'hidden'}`} />
<h3 className={`ml-[3%] mt-4 max-mob:text-2xl max-tab:ml-10 max-mob:ml-5`}>Documents</h3>
<Divider className='w-[95%] mx-auto max-tab:hidden max-mob:w-[100%]' />
<Carousel
value={documentsLoading || !processedDocuments.length ? [{}, {}, {}] : [...processedDocuments]}
numVisible={2}
pt={{
previousButton: {
className: isMobileView ? 'm-0' : ''
className: 'm-0'
},
nextButton: {
className: isMobileView ? 'm-0' : ''
className: 'm-0'
}
}}
itemTemplate={(item) =>

View File

@ -27,6 +27,7 @@ export default function GenericCarousel({items, selectedTopic, title}) {
return `${type}-${baseKey}-${index}`;
};
// todo: max sizing for single peice of content being shown
const renderItem = (item, index) => {
if (!item) return <TemplateSkeleton key={generateUniqueTemplateKey(item, index, 'skeleton')} />;
@ -58,7 +59,7 @@ export default function GenericCarousel({items, selectedTopic, title}) {
};
return (
<div className="w-full px-4 mb-4">
<div className="w-full mb-4">
<div className="grid grid-cols-2 gap-4 max-w-full max-tab:grid-cols-1 lg:grid-cols-3">
{items.map((item, index) => (
<div key={generateUniqueTemplateKey(item, index, 'container')} className="w-full min-w-0">

View File

@ -105,7 +105,7 @@ const InteractivePromotionalCarousel = () => {
}, [selectedPromotion]);
return (
<div className={`flex ${isTabView ? 'flex-col' : 'flex-row'} bg-gray-900 text-white m-4 mx-0 rounded-lg ${isTabView ? 'h-auto' : 'h-[620px]'} ${isTabView ? 'w-full mx-0 ml-0 mt-0' : null}`}>
<div className={`flex ${isTabView ? 'flex-col' : 'flex-row'} bg-gray-900 text-white m-4 mb-2 mx-0 rounded-lg ${isTabView ? 'h-auto' : 'h-[620px]'} ${isTabView ? 'w-full mx-0 ml-0 mt-0' : null}`}>
<div className={isTabView ? 'w-full' : 'lg:w-2/3 relative'}>
{selectedPromotion.video ? (
<video
@ -142,7 +142,7 @@ const InteractivePromotionalCarousel = () => {
case "PLEBDEVS":
return (
<div className="flex flex-row gap-2">
<GenericButton onClick={() => router.push('/subscribe')} severity="warning" icon={<i className="pi pi-star pr-2 pb-1" />} label="Subscribe" className="w-fit py-2 font-semibold" size="small" outlined />
<GenericButton onClick={() => router.push('/about')} severity="warning" icon={<i className="pi pi-star pr-2 pb-1" />} label="Subscribe" className="w-fit py-2 font-semibold" size="small" outlined />
<GenericButton onClick={() => router.push('/content?tag=all')} severity="primary" icon={<i className="pi pi-eye pr-2" />} label="All content" className="w-fit py-2 font-semibold" size="small" outlined />
</div>
);
@ -160,7 +160,7 @@ const InteractivePromotionalCarousel = () => {
);
case "LIGHTNING / NOSTR":
return (
<GenericButton onClick={() => router.push('/subscribe')} severity="warning" icon={<i className="pi pi-star pr-2 pb-1" />} label="Subscribe" className="w-fit py-2 font-semibold" size="small" outlined />
<GenericButton onClick={() => router.push('/about')} severity="warning" icon={<i className="pi pi-star pr-2 pb-1" />} label="Subscribe" className="w-fit py-2 font-semibold" size="small" outlined />
);
default:
return null;
@ -185,7 +185,7 @@ const InteractivePromotionalCarousel = () => {
case "PLEBDEVS":
return (
<div className="flex flex-row gap-4 mt-4">
<GenericButton onClick={() => router.push('/subscribe')} severity="warning" icon={<i className="pi pi-star pr-2 pb-1" />} label="Subscribe" className="py-2 font-semibold" size="small" outlined />
<GenericButton onClick={() => router.push('/about')} severity="warning" icon={<i className="pi pi-star pr-2 pb-1" />} label="Subscribe" className="py-2 font-semibold" size="small" outlined />
<GenericButton onClick={() => router.push('/content?tag=all')} severity="primary" icon={<i className="pi pi-eye pr-2" />} label="All content" className="py-2 font-semibold" size="small" outlined />
<GenericButton onClick={() => copyToClipboard()} icon={<i className="pi pi-bolt pr-2" />} label="Donate" className={`py-2 font-semibold text-yellow-300 ${yellowFocusOutlineStyle}`} size="small" outlined />
</div>
@ -204,7 +204,7 @@ const InteractivePromotionalCarousel = () => {
);
case "LIGHTNING / NOSTR":
return (
<GenericButton onClick={() => router.push('/subscribe')} severity="warning" icon={<i className="pi pi-star pr-2 pb-1" />} label="Subscribe" className="py-2 font-semibold" size="small" outlined />
<GenericButton onClick={() => router.push('/about')} severity="warning" icon={<i className="pi pi-star pr-2 pb-1" />} label="Subscribe" className="py-2 font-semibold" size="small" outlined />
);
default:
return null;

View File

@ -20,7 +20,7 @@ const responsiveOptions = [
numScroll: 1
},
{
breakpoint: '575px',
breakpoint: '675px',
numVisible: 1,
numScroll: 1
}
@ -83,17 +83,17 @@ export default function VideosCarousel() {
return (
<>
<h3 className={`ml-[6%] mt-4 max-mob:text-3xl max-mob:ml-10`}>Videos</h3>
<Divider className={`${isMobileView ? '' : 'hidden'}`} />
<h3 className={`ml-[3%] mt-4 max-mob:text-2xl max-tab:ml-10 max-mob:ml-5`}>Videos</h3>
<Divider className='w-[95%] mx-auto max-tab:hidden max-mob:w-[100%]' />
<Carousel
value={videosLoading || !processedVideos.length ? [{}, {}, {}] : [...processedVideos]}
numVisible={2}
pt={{
previousButton: {
className: isMobileView ? 'm-0' : ''
className: 'm-0'
},
nextButton: {
className: isMobileView ? 'm-0' : ''
className: 'm-0'
}
}}
itemTemplate={(item) =>

View File

@ -1,47 +1,81 @@
import React, {useEffect} from "react";
import React from "react";
import Image from "next/image";
import { useImageProxy } from "@/hooks/useImageProxy";
import { formatUnixTimestamp } from "@/utils/time";
import { Tag } from "primereact/tag";
import { Message } from "primereact/message";
import GenericButton from "@/components/buttons/GenericButton";
import useWindowWidth from "@/hooks/useWindowWidth";
import { BookOpen } from "lucide-react";
const ContentDropdownItem = ({ content, onSelect }) => {
const { returnImageProxy } = useImageProxy();
const windowWidth = useWindowWidth();
const isMobile = windowWidth <= 600;
return (
<div className="w-full border-t-2 border-gray-700 py-4">
<div className="flex flex-row gap-4 p-2">
<Image
alt="content thumbnail"
src={returnImageProxy(content?.image)}
width={50}
height={50}
className="w-[100px] h-[100px] object-cover object-center border-round"
/>
<div className="flex-1 max-w-[80vw]">
<div className="text-lg text-900 font-bold">{content?.title || content?.name}</div>
<div className="w-full text-sm text-600 text-wrap line-clamp-2">{content?.summary || content?.description && (
<div className="text-xl mt-4">
{content?.summary?.split('\n').map((line, index) => (
<p key={index}>{line}</p>
))}
</div>
)}
</div>
{content?.price && <div className="text-sm pt-6 text-gray-500">Price: {content.price}</div>}
{content?.topics?.length > 0 && (
<div className="text-sm pt-6 text-gray-500">
{content.topics.map((topic) => (
<Tag key={topic} value={topic} size="small" className="mr-2 text-[#f8f8ff]" />
))}
</div>
)}
<div className="text-sm pt-6 text-gray-500">
{(content?.published_at || content?.created_at) ? `Published: ${formatUnixTimestamp(content?.published_at || content?.created_at)}` : "not yet published"}
<div
className="px-6 py-6 border-b border-gray-700 cursor-pointer"
onClick={() => onSelect(content)}
>
<div className={`flex ${isMobile ? 'flex-col' : 'flex-row'} gap-4`}>
<div className={`relative ${isMobile ? 'w-full h-40' : 'w-[160px] h-[90px]'} flex-shrink-0 overflow-hidden rounded-md`}>
<Image
alt="content thumbnail"
src={returnImageProxy(content?.image)}
width={isMobile ? 600 : 160}
height={isMobile ? 240 : 90}
className="w-full h-full object-cover object-center"
/>
<div className="absolute inset-0 bg-gradient-to-br from-primary/80 to-primary-foreground/50 opacity-60" />
<div className="absolute bottom-2 left-2 flex gap-2">
<BookOpen className="w-5 h-5 text-white" />
</div>
</div>
<div className="flex flex-col justify-end">
<GenericButton outlined size="small" label="Select" onClick={() => onSelect(content)} className="py-2" />
<div className="flex-1">
<div className="flex justify-between items-center mb-2">
<h3 className="text-xl font-bold text-[#f8f8ff]">{content?.title || content?.name}</h3>
{content?.price > 0 ? (
<Message severity="info" text={`${content.price} sats`} className="py-1 text-xs whitespace-nowrap" />
) : (
<Message severity="success" text="Free" className="py-1 text-xs whitespace-nowrap" />
)}
</div>
{content?.summary && (
<p className="text-neutral-50/90 line-clamp-2 mb-3 text-sm">{content.summary}</p>
)}
<div className="flex flex-wrap gap-2 mt-2">
{content?.topics?.map((topic) => (
<Tag key={topic} value={topic} className="px-2 py-1 text-sm text-[#f8f8ff]" />
))}
</div>
<div className="flex justify-between items-center mt-3">
<div className="text-sm text-gray-300">
{(content?.published_at || content?.created_at)
? `Published: ${formatUnixTimestamp(content?.published_at || content?.created_at)}`
: "Not yet published"}
</div>
{!isMobile && (
<GenericButton
outlined
size="small"
label="Start Learning"
icon="pi pi-chevron-right"
iconPos="right"
onClick={(e) => {
e.stopPropagation();
onSelect(content);
}}
className="items-center py-1"
/>
)}
</div>
</div>
</div>
</div>

View File

@ -1,87 +1,229 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import CommunityMessage from "@/components/feeds/messages/CommunityMessage";
import { parseMessageEvent, findKind0Fields } from "@/utils/nostr";
import { parseMessageEvent } from "@/utils/nostr";
import { ProgressSpinner } from 'primereact/progressspinner';
import { useNDKContext } from "@/context/NDKContext";
import useWindowWidth from "@/hooks/useWindowWidth";
import Image from "next/image";
import { formatTimestampToHowLongAgo } from "@/utils/time";
import NostrIcon from '/public/images/nostr.png';
import { useImageProxy } from "@/hooks/useImageProxy";
const MessageDropdownItem = ({ message, onSelect }) => {
const { ndk, addSigner } = useNDKContext();
const [author, setAuthor] = useState(null);
const [formattedMessage, setFormattedMessage] = useState(null);
const [messageWithAuthor, setMessageWithAuthor] = useState(null);
const { ndk } = useNDKContext();
const [messageData, setMessageData] = useState(null);
const [loading, setLoading] = useState(true);
const [platform, setPlatform] = useState(null);
const windowWidth = useWindowWidth();
const isMobile = windowWidth <= 600;
const { returnImageProxy } = useImageProxy();
const determinePlatform = () => {
if (message?.channel) {
return "discord";
} else if (message?.kind) {
return "nostr";
} else {
return "stackernews";
// Stable reference to message to prevent infinite loops
const messageRef = useMemo(() => message, [message?.id]);
// Determine the platform once when component mounts or message changes
const determinePlatform = useCallback(() => {
if (messageRef?.channel) return "discord";
if (messageRef?.kind) return "nostr";
return "stackernews";
}, [messageRef]);
// Memoize the fetchNostrAuthor function
const fetchNostrAuthor = useCallback(async (pubkey) => {
if (!ndk || !pubkey) return null;
try {
await ndk.connect();
const user = await ndk.getUser({ pubkey });
const profile = await user.fetchProfile();
// Return the parsed profile data directly - it already contains what we need
return profile;
} catch (error) {
console.error("Error fetching Nostr author:", error);
return null;
}
}
}, [ndk]);
// Process message based on platform type
useEffect(() => {
setPlatform(determinePlatform(message));
}, [message]);
// Prevent execution if no message data or already loaded
if (!messageRef || messageData) return;
useEffect(() => {
if (platform === "nostr") {
const event = parseMessageEvent(message);
setFormattedMessage(event);
const getAuthor = async () => {
try {
await ndk.connect();
const author = await ndk.getUser({ pubkey: message.pubkey });
if (author && author?.content) {
const authorFields = findKind0Fields(JSON.parse(author.content));
if (authorFields) {
setAuthor(authorFields);
}
} else if (author?._pubkey) {
setAuthor(author?._pubkey);
const currentPlatform = determinePlatform();
setPlatform(currentPlatform);
let isMounted = true;
const processMessage = async () => {
try {
if (currentPlatform === "nostr") {
// Format Nostr message
const parsedMessage = parseMessageEvent(messageRef);
// Fetch author data for Nostr messages
let authorData = null;
if (messageRef?.pubkey) {
authorData = await fetchNostrAuthor(messageRef.pubkey);
}
} catch (error) {
console.error(error);
// Extract author details with fallbacks
const authorName = authorData?.name || authorData?.displayName || "Unknown User";
const authorPicture = authorData?.picture || authorData?.image || null;
// Only update state if component is still mounted
if (isMounted) {
setMessageData({
...parsedMessage,
timestamp: messageRef.created_at || Math.floor(Date.now() / 1000),
channel: "plebdevs",
author: authorName,
avatar: authorPicture,
avatarProxy: authorPicture ? returnImageProxy(authorPicture) : null,
type: "nostr",
id: messageRef.id
});
}
} else if (currentPlatform === "discord") {
const avatarUrl = messageRef?.author?.avatar
? `https://cdn.discordapp.com/avatars/${messageRef.author.id}/${messageRef.author.avatar}.png`
: null;
if (isMounted) {
setMessageData({
content: messageRef?.content,
author: messageRef?.author?.username || "Unknown User",
timestamp: messageRef?.timestamp ? Math.floor(messageRef.timestamp / 1000) : Math.floor(Date.now() / 1000),
avatar: avatarUrl,
avatarProxy: avatarUrl ? returnImageProxy(avatarUrl) : null,
channel: messageRef?.channel || "discord",
type: "discord",
id: messageRef.id
});
}
} else if (currentPlatform === "stackernews") {
if (isMounted) {
setMessageData({
content: messageRef?.title,
author: messageRef?.user?.name || "Unknown User",
timestamp: messageRef?.created_at ? Math.floor(Date.parse(messageRef.created_at) / 1000) : Math.floor(Date.now() / 1000),
avatar: "https://pbs.twimg.com/profile_images/1403162883941359619/oca7LMQ2_400x400.png",
avatarProxy: returnImageProxy("https://pbs.twimg.com/profile_images/1403162883941359619/oca7LMQ2_400x400.png"),
channel: "~devs",
type: "stackernews",
id: messageRef.id
});
}
}
} catch (error) {
console.error("Error processing message:", error);
} finally {
if (isMounted) {
setLoading(false);
}
}
getAuthor();
} else if (platform === "stackernews") {
setMessageWithAuthor({
content: message?.title,
author: message?.user?.name,
timestamp: message?.created_at ? Date.parse(message.created_at) : null,
avatar: "https://pbs.twimg.com/profile_images/1403162883941359619/oca7LMQ2_400x400.png",
channel: "~devs",
...message
})
}
else {
setLoading(false);
}
}, [ndk, message, platform]);
};
processMessage();
// Cleanup function to prevent state updates after unmount
return () => {
isMounted = false;
};
}, [messageRef, determinePlatform, fetchNostrAuthor, returnImageProxy, messageData]);
useEffect(() => {
if (author && formattedMessage && platform === 'nostr') {
const body = {
...formattedMessage,
timestamp: formattedMessage.created_at || message.created_at || message.timestamp,
channel: "plebdevs",
author: author
};
setMessageWithAuthor(body);
setLoading(false);
const getPlatformIcon = useCallback(() => {
switch (platform) {
case 'nostr':
return <Image src={NostrIcon} alt="Nostr" width={16} height={16} className="mr-1" />;
case 'discord':
return <i className="pi pi-discord mr-1" />;
case 'stackernews':
return <i className="pi pi-bolt mr-1" />;
default:
return <i className="pi pi-globe mr-1" />;
}
}, [author, formattedMessage, message, platform]);
}, [platform]);
// Create a simplified view for mobile search results
const renderSimplifiedMessage = useCallback(() => {
if (!messageData) return null;
const authorName = messageData.author || "Unknown User";
const avatarUrl = messageData.avatarProxy || returnImageProxy(messageData.avatar);
const messageDate = messageData.timestamp ? formatTimestampToHowLongAgo(messageData.timestamp) : '';
return (
<div className="flex flex-col">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full overflow-hidden bg-gray-700 flex-shrink-0 mt-1">
{avatarUrl ? (
<Image
src={avatarUrl}
alt="avatar"
width={40}
height={40}
className="object-cover w-full h-full"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<i className="pi pi-user text-gray-400 text-xl" />
</div>
)}
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<div className="font-medium text-[#f8f8ff]">
{authorName}
</div>
<div className="text-xs text-gray-400">
{messageDate}
</div>
</div>
<p className="text-neutral-50/90 whitespace-pre-wrap mb-3 line-clamp-3">{messageData.content}</p>
<div className="flex flex-wrap gap-2">
<div className="px-3 py-1 bg-blue-500 text-white rounded-full text-sm flex items-center">
{getPlatformIcon()}
{platform}
</div>
<div className="px-3 py-1 bg-gray-700 text-white rounded-full text-sm">
plebdevs
</div>
</div>
</div>
</div>
</div>
);
}, [messageData, returnImageProxy, getPlatformIcon, platform]);
// Memoize the final message object to pass to CommunityMessage
const finalMessage = useMemo(() => {
if (!messageData) return null;
return {
...messageData,
avatar: messageData?.avatarProxy || returnImageProxy(messageData?.avatar)
};
}, [messageData, returnImageProxy]);
return (
<div className="w-full border-t-2 border-gray-700 py-4">
<div
className="px-6 py-6 border-b border-gray-700 cursor-pointer"
onClick={() => !loading && onSelect(messageData || messageRef)}
>
{loading ? (
<div className='w-full h-full flex items-center justify-center'><ProgressSpinner /></div>
<div className='w-full h-[100px] flex items-center justify-center'>
<ProgressSpinner style={{ width: '40px', height: '40px' }} strokeWidth="4" />
</div>
) : (
<CommunityMessage message={messageWithAuthor ? messageWithAuthor : message} platform={platform} />
isMobile ? renderSimplifiedMessage() :
<CommunityMessage
message={finalMessage}
platform={platform}
/>
)}
</div>
);

View File

@ -15,7 +15,7 @@ const DiscordFeed = ({ searchQuery }) => {
if (!data) return [];
return data
.filter(message =>
message.content.toLowerCase().includes(searchQuery.toLowerCase())
searchQuery ? message.content.toLowerCase().includes(searchQuery.toLowerCase()) : true
)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
}, [data, searchQuery]);
@ -33,8 +33,8 @@ const DiscordFeed = ({ searchQuery }) => {
}
return (
<div className="h-full w-full min-bottom-bar:w-[86vw]">
<div className="mx-4">
<div className="h-full w-full">
<div className="mx-0">
{filteredData.length > 0 ? (
filteredData.map(message => (
<CommunityMessage

View File

@ -98,6 +98,7 @@ const GlobalFeed = ({searchQuery}) => {
const dateB = b.type === 'nostr' ? b.created_at * 1000 : new Date(b.timestamp || b.createdAt);
return dateB - dateA;
}).filter(item => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
if (item.type === 'discord' || item.type === 'nostr') {
return item.content.toLowerCase().includes(searchLower);
@ -108,8 +109,8 @@ const GlobalFeed = ({searchQuery}) => {
});
return (
<div className="h-full w-full min-bottom-bar:w-[86vw]">
<div className="mx-4 mt-4">
<div className="h-full w-full">
<div className="mx-0 mt-4">
{combinedFeed.length > 0 ? (
combinedFeed.map(item => (
<CommunityMessage

View File

@ -1,7 +1,6 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { InputTextarea } from 'primereact/inputtextarea';
import GenericButton from '@/components/buttons/GenericButton';
import { Panel } from 'primereact/panel';
import { useNDKContext } from "@/context/NDKContext";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'
@ -12,21 +11,53 @@ import { useSession } from 'next-auth/react';
const MessageInput = () => {
const [message, setMessage] = useState('');
const [collapsed, setCollapsed] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const { ndk, addSigner } = useNDKContext();
const { showToast } = useToast();
const { data: session } = useSession();
const pool = useRef(null);
// Initialize pool when needed
const getPool = async () => {
if (!pool.current) {
pool.current = new SimplePool();
}
return pool.current;
};
const publishToRelay = async (relay, event, currentPool) => {
try {
// Wait for relay connection
await currentPool.ensureRelay(relay);
await currentPool.publish([relay], event);
return true;
} catch (err) {
console.warn(`Failed to publish to ${relay}:`, err);
return false;
}
};
const handleSubmit = async () => {
if (session && session?.user && session.user?.privkey) {
handleManualSubmit(session.user.privkey);
} else {
handleExtensionSubmit();
if (!message.trim()) return;
if (isSubmitting) return;
try {
setIsSubmitting(true);
if (session && session?.user && session.user?.privkey) {
await handleManualSubmit(session.user.privkey);
} else {
await handleExtensionSubmit();
}
} catch (error) {
console.error("Error submitting message:", error);
showToast('error', 'Error', 'There was an error sending your message. Please try again.');
} finally {
setIsSubmitting(false);
}
}
const handleExtensionSubmit = async () => {
if (!message.trim() || !ndk) return;
if (!ndk) return;
try {
if (!ndk.signer) {
@ -39,10 +70,10 @@ const MessageInput = () => {
await event.publish();
showToast('success', 'Message Sent', 'Your message has been sent to the PlebDevs community.');
setMessage(''); // Clear the input after successful publish
setMessage('');
} catch (error) {
console.error("Error publishing message:", error);
showToast('error', 'Error', 'There was an error sending your message. Please try again.');
throw error;
}
};
@ -55,66 +86,62 @@ const MessageInput = () => {
['t', 'plebdevs']
],
content: message,
}, privkey)
}, privkey);
let isGood = verifyEvent(event);
if (!isGood) {
throw new Error('Event verification failed');
}
if (isGood) {
const pool = new SimplePool();
const published = await pool.publish(appConfig.defaultRelayUrls, event);
if (published) {
try {
const currentPool = await getPool();
let publishedToAny = false;
// Try to publish to each relay sequentially
for (const relay of appConfig.defaultRelayUrls) {
const success = await publishToRelay(relay, event, currentPool);
if (success) {
publishedToAny = true;
break; // Stop after first successful publish
}
}
if (publishedToAny) {
showToast('success', 'Message Sent', 'Your message has been sent to the PlebDevs community.');
setMessage('');
} else {
showToast('error', 'Error', 'There was an error sending your message. Please try again.');
throw new Error('Failed to publish to any relay');
}
} else {
showToast('error', 'Error', 'There was an error sending your message. Please try again.');
} catch (err) {
console.error("Publishing error:", err);
throw err;
}
} catch (error) {
console.error("Error finalizing event:", error);
showToast('error', 'Error', 'There was an error sending your message. Please try again.');
throw error;
}
}
const headerTemplate = (options) => {
return (
<div className="flex align-items-center justify-content-between my-1 py-2">
<GenericButton outlined severity="primary" size="small" className="py-0" onClick={options.onTogglerClick} icon={options.collapsed ? 'pi pi-chevron-down' : 'pi pi-chevron-up'} />
<h2 className="m-0 ml-2">New Message</h2>
</div>
);
};
return (
<Panel
headerTemplate={headerTemplate}
toggleable
collapsed={collapsed}
onToggle={(e) => setCollapsed(e.value)}
className="w-full"
>
<div className="w-full flex flex-col">
<InputTextarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
cols={10}
autoResize
placeholder="Type your message here..."
className="w-full"
/>
</div>
<div className="w-full flex flex-row justify-end mt-4">
<GenericButton
label="Send"
icon="pi pi-send"
outlined
onClick={handleSubmit}
className="w-fit py-2"
/>
</div>
</Panel>
<div className="flex flex-row items-center gap-2">
<InputTextarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={1}
autoResize
placeholder="Type your message here..."
className="flex-1 bg-[#1e2732] border-[#2e3b4e] rounded-lg"
disabled={isSubmitting}
/>
<GenericButton
icon="pi pi-send"
outlined
onClick={handleSubmit}
className="h-full"
disabled={isSubmitting || !message.trim()}
loading={isSubmitting}
/>
</div>
);
};

View File

@ -72,13 +72,13 @@ const NostrFeed = ({ searchQuery }) => {
const filteredNotes = communityNotes
.filter(message =>
message.content.toLowerCase().includes(searchQuery.toLowerCase())
searchQuery ? message.content.toLowerCase().includes(searchQuery.toLowerCase()) : true
)
.sort((a, b) => b.created_at - a.created_at);
return (
<div className="h-full w-full min-bottom-bar:w-[86vw]">
<div className="mx-4 mt-4">
<div className="h-full w-full">
<div className="mx-0 mt-4">
{filteredNotes.length > 0 ? (
filteredNotes.map(message => (
<CommunityMessage

View File

@ -36,13 +36,13 @@ const StackerNewsFeed = ({ searchQuery }) => {
const filteredItems = items
.filter(item =>
item.title.toLowerCase().includes(searchQuery.toLowerCase())
searchQuery ? item.title.toLowerCase().includes(searchQuery.toLowerCase()) : true
)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return (
<div className="h-full w-full min-bottom-bar:w-[86vw]">
<div className="mx-4 mt-4">
<div className="h-full w-full">
<div className="mx-0 mt-4">
{filteredItems && filteredItems.length > 0 ? (
filteredItems.map(item => (
<CommunityMessage

View File

@ -144,7 +144,9 @@ const CommunityMessage = ({ message, searchQuery, windowWidth, platform }) => {
}
}}
>
<p className="m-0 text-lg text-gray-200 break-words">{highlightText(message.content, searchQuery)}</p>
<p className="m-0 text-lg text-gray-200 break-words">
{searchQuery ? highlightText(message.content, searchQuery) : message.content}
</p>
</Card>
);
};

View File

@ -52,9 +52,9 @@ const CommunityMenuTab = ({ selectedTopic, onTabChange }) => {
activeIndex={allItems.indexOf(selectedTopic)}
onTabChange={(e) => onTabChange(allItems[e.index])}
pt={{
menu: { className: 'bg-transparent border-none ml-2 my-4 py-1' },
menu: { className: 'bg-transparent border-none my-1 py-1' },
action: ({ context, parent }) => ({
className: 'cursor-pointer select-none flex items-center relative no-underline overflow-hidden border-b-2 p-2 font-bold rounded-t-lg',
className: 'cursor-pointer select-none flex items-center relative no-underline overflow-hidden border-b-2 p-2 pl-[4px] font-bold rounded-t-lg',
style: { top: '2px' }
}),
menuitem: { className: 'mr-0' }

View File

@ -1,48 +1,142 @@
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import UserAvatar from './user/UserAvatar';
import { Menubar } from 'primereact/menubar';
import { Menu } from 'primereact/menu';
import { useRouter } from 'next/router';
import SearchBar from '../search/SearchBar';
import { useSession } from 'next-auth/react';
import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
import { useNDKContext } from '@/context/NDKContext';
import useWindowWidth from '@/hooks/useWindowWidth';
const Navbar = () => {
const router = useRouter();
const windowWidth = useWindowWidth();
const navbarHeight = '60px';
const {ndk} = useNDKContext();
const { data: session } = useSession();
const [isHovered, setIsHovered] = useState(false);
const [showMobileSearch, setShowMobileSearch] = useState(false);
const menu = useRef(null);
const start = (
<div className='flex items-center'>
<div onClick={() => router.push('/')} className="flex flex-row items-center justify-center cursor-pointer">
<Image
alt="logo"
src="/images/plebdevs-guy.png"
width={50}
height={50}
className="rounded-full mr-2 max-tab:hidden max-mob:hidden"
/>
<h1 className="text-white text-xl font-semibold max-tab:text-2xl max-mob:text-2xl">PlebDevs</h1>
</div>
{ndk && windowWidth > 600 && <SearchBar />}
</div>
);
// Lock/unlock body scroll when mobile search is shown/hidden
useEffect(() => {
if (showMobileSearch && windowWidth <= 600) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
// Cleanup effect
return () => {
document.body.style.overflow = 'unset';
};
}, [showMobileSearch, windowWidth]);
const menuItems = [
{
label: 'Content',
icon: 'pi pi-play-circle',
command: () => router.push('/content?tag=all')
},
{
label: 'Feeds',
icon: 'pi pi-comments',
command: () => router.push('/feed?channel=global')
},
{
label: 'Subscribe',
icon: 'pi pi-star',
command: () => session?.user ? router.push('/profile?tab=subscribe') : router.push('/about')
},
{
label: 'About',
icon: 'pi pi-info-circle',
command: () => router.push('/about')
}
];
return (
<>
<div className='w-[100vw] h-fit z-20'>
<Menubar
start={start}
end={UserAvatar}
className='px-6 py-8 bg-gray-800 border-t-0 border-l-0 border-r-0 rounded-none fixed z-10 w-[100vw] max-tab:px-[5%] max-mob:px-[5%]'
style={{ height: navbarHeight }}
/>
<div className='px-10 py-8 bg-gray-800 border-t-0 border-l-0 border-r-0 rounded-none fixed z-10 w-[100vw] max-tab:px-[5%] max-mob:px-[5%] flex justify-between' style={{ height: navbarHeight }}>
{/* Left section */}
<div className='flex items-center flex-1'>
<div onClick={() => router.push('/')} className="flex flex-row items-center justify-center cursor-pointer hover:opacity-80">
<Image
alt="logo"
src="/images/plebdevs-icon.png"
width={50}
height={50}
className="rounded-full max-tab:hidden max-mob:hidden"
/>
<h1 className="text-white text-xl font-semibold max-tab:text-2xl max-mob:text-2xl pb-1 pl-2">PlebDevs</h1>
</div>
{windowWidth > 600 ? (
<div
className={`ml-2 p-2 cursor-pointer transition-all duration-300 flex items-center justify-center ${isHovered ? 'bg-gray-700 rounded-full' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={(e) => menu.current.toggle(e)}
style={{ width: '40px', height: '40px' }}
>
<div className="flex flex-col items-center justify-center">
<i className="pi pi-angle-up text-white text-base" />
<i className="pi pi-angle-down text-white text-base" />
</div>
</div>
) : (
<div
className="ml-2 p-2 cursor-pointer transition-all duration-300 flex items-center justify-center hover:bg-gray-700 rounded-full"
onClick={() => setShowMobileSearch(!showMobileSearch)}
style={{ width: '40px', height: '40px' }}
>
<i className="pi pi-search text-white text-xl" />
</div>
)}
<Menu model={menuItems} popup ref={menu} />
</div>
{/* Center section - Search */}
{windowWidth > 600 && (
<div className="flex items-center justify-center flex-1">
<SearchBar isDesktopNav={true} />
</div>
)}
{/* Right section - User Avatar */}
<div className="flex items-center justify-end flex-1">
<UserAvatar />
</div>
</div>
</div>
{/* Placeholder div with the same height as the Navbar */}
<div style={{ height: navbarHeight }}></div>
{/* Mobile Search Overlay */}
{showMobileSearch && windowWidth <= 600 && (
<div className="fixed inset-0 bg-gray-900 z-50 overflow-hidden navbar-mobile-search">
<div className="h-full">
<div className="sticky top-0 z-10 bg-gray-900">
<div className="px-6 py-4 flex items-center justify-between border-b border-gray-700">
<h2 className="text-white text-2xl font-semibold">Search</h2>
<button
onClick={() => setShowMobileSearch(false)}
className="text-white hover:text-gray-300 p-2"
>
<i className="pi pi-times text-2xl" />
</button>
</div>
<div className="px-6 pt-4 pb-2">
<SearchBar
isMobileSearch={true}
onCloseSearch={() => setShowMobileSearch(false)}
/>
</div>
</div>
</div>
</div>
)}
</>
);
};

View File

@ -10,7 +10,6 @@
.logo {
border-radius: 50%;
margin-right: 8px;
height: 50px;
width: 50px;
}

View File

@ -1,13 +1,11 @@
import React, { useRef, useState, useEffect } from 'react';
import Image from 'next/image';
import { Avatar } from 'primereact/avatar';
import { useRouter } from 'next/router';
import { useImageProxy } from '@/hooks/useImageProxy';
import GenericButton from '@/components/buttons/GenericButton';
import { Menu } from 'primereact/menu';
import useWindowWidth from '@/hooks/useWindowWidth';
import { useSession, signOut } from 'next-auth/react';
import { ProgressSpinner } from 'primereact/progressspinner';
import { useIsAdmin } from '@/hooks/useIsAdmin';
import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
@ -70,7 +68,7 @@ const UserAvatar = () => {
// Only show the "Create" option for admin users
...(isAdmin ? [{
label: 'Create',
icon: 'pi pi-book',
icon: 'pi pi-file-edit',
command: () => router.push('/create')
}] : []),
{
@ -84,14 +82,6 @@ const UserAvatar = () => {
userAvatar = (
<>
<div className='flex flex-row items-center justify-between'>
<GenericButton
outlined
rounded
label="About"
className='mr-4'
onClick={() => router.push('/about')}
size={windowWidth < 768 ? 'small' : 'normal'}
/>
<div onClick={(event) => menu.current.toggle(event)} className={`flex flex-row items-center justify-between cursor-pointer hover:opacity-75`}>
<Image
alt="logo"
@ -112,14 +102,6 @@ const UserAvatar = () => {
} else {
userAvatar = (
<div className='flex flex-row items-center justify-between'>
<GenericButton
outlined
rounded
label="About"
className='mr-4'
onClick={() => router.push('/about')}
size={windowWidth < 768 ? 'small' : 'normal'}
/>
<GenericButton
label="Login"
icon="pi pi-user"

View File

@ -58,10 +58,10 @@ const UserRelaysTable = ({ ndk, userRelays, setUserRelays, reInitializeNDK }) =>
<div className="text-[#f8f8ff]">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">Relays</h2>
<p className="text-gray-400">Manage your connected relays</p>
</div>
<GenericButton
outlined
icon="pi pi-plus"
label="Add Relay"
severity="success"
@ -78,7 +78,7 @@ const UserRelaysTable = ({ ndk, userRelays, setUserRelays, reInitializeNDK }) =>
className="flex-1"
/>
<GenericButton
label="Add"
label="+"
severity="success"
outlined
onClick={addRelay}
@ -122,7 +122,7 @@ const UserRelaysTable = ({ ndk, userRelays, setUserRelays, reInitializeNDK }) =>
};
return (
<div className="bg-gray-800 rounded-lg border border-gray-700 w-full">
<div className="w-full">
<DataTable
value={userRelays}
className="border-none"

View File

@ -113,7 +113,7 @@ const UserContent = () => {
const isError = coursesError || documentsError || videosError || draftsError || contentIdsError || courseDraftsError;
return (
<div className="p-4">
<div className="py-4 px-1">
{
windowWidth < 768 && (
<h1 className="text-3xl font-bold mb-6">My Content</h1>

View File

@ -10,6 +10,7 @@ import UserProgress from "@/components/profile/progress/UserProgress";
import UserProgressTable from '@/components/profile/DataTables/UserProgressTable';
import UserPurchaseTable from '@/components/profile/DataTables/UserPurchaseTable';
import BitcoinLightningCard from '@/components/profile/BitcoinLightningCard';
import UserAccountLinking from "@/components/profile/UserAccountLinking";
const UserProfile = () => {
const windowWidth = useWindowWidth();
@ -32,7 +33,7 @@ const UserProfile = () => {
return (
user && (
<div className="p-4">
<div className="py-4 px-1">
{
windowWidth < 768 && (
<h1 className="text-3xl font-bold mb-6">Profile</h1>
@ -42,6 +43,7 @@ const UserProfile = () => {
<div className="w-[22%] h-full max-lap:w-full">
{user && <UserProfileCard user={user} />}
<BitcoinLightningCard />
{user && <UserAccountLinking session={session} />}
</div>
<div className="w-[78%] flex flex-col justify-center mx-auto max-lap:w-full">

View File

@ -2,19 +2,24 @@ import React, { useRef, useState } from 'react';
import Image from 'next/image';
import { Menu } from 'primereact/menu';
import { Tooltip } from 'primereact/tooltip';
import { Dialog } from 'primereact/dialog';
import { nip19 } from 'nostr-tools';
import { useImageProxy } from '@/hooks/useImageProxy';
import { useToast } from '@/hooks/useToast';
import UserBadges from '@/components/profile/UserBadges';
import useWindowWidth from '@/hooks/useWindowWidth';
import MoreInfo from '@/components/MoreInfo';
import UserRelaysTable from '@/components/profile/DataTables/UserRelaysTable';
import { useNDKContext } from "@/context/NDKContext";
const UserProfileCard = ({ user }) => {
const [showBadges, setShowBadges] = useState(false);
const [showRelaysModal, setShowRelaysModal] = useState(false);
const menu = useRef(null);
const { showToast } = useToast();
const { returnImageProxy } = useImageProxy();
const windowWidth = useWindowWidth();
const { ndk, userRelays, setUserRelays, reInitializeNDK } = useNDKContext();
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
@ -43,6 +48,11 @@ const UserProfileCard = ({ user }) => {
label: 'Open Nostr Profile',
icon: 'pi pi-external-link',
command: () => window.open(`https://nostr.com/${nip19.npubEncode(user?.pubkey)}`, '_blank')
},
{
label: 'Manage Relays',
icon: 'pi pi-server',
command: () => setShowRelaysModal(true)
}
];
@ -282,6 +292,20 @@ const UserProfileCard = ({ user }) => {
visible={showBadges}
onHide={() => setShowBadges(false)}
/>
<Dialog
visible={showRelaysModal}
onHide={() => setShowRelaysModal(false)}
header="Manage Relays"
className="w-[90vw] max-w-[800px]"
modal
>
<UserRelaysTable
ndk={ndk}
userRelays={userRelays}
setUserRelays={setUserRelays}
reInitializeNDK={reInitializeNDK}
/>
</Dialog>
</>
);
};

View File

@ -1,47 +0,0 @@
import React, { useState, useEffect } from "react";
import UserProfileCard from "@/components/profile/UserProfileCard";
import { useSession } from 'next-auth/react';
import { useNDKContext } from "@/context/NDKContext";
import useWindowWidth from "@/hooks/useWindowWidth";
import UserRelaysTable from "@/components/profile/DataTables/UserRelaysTable";
import UserAccountLinking from "@/components/profile/UserAccountLinking";
const UserSettings = () => {
const [user, setUser] = useState(null);
const { ndk, userRelays, setUserRelays, reInitializeNDK } = useNDKContext();
const { data: session } = useSession();
const windowWidth = useWindowWidth();
useEffect(() => {
if (session?.user) {
setUser(session.user);
}
}, [session]);
return (
user && (
<div className="p-4">
{windowWidth < 768 && (
<h1 className="text-3xl font-bold mb-6">Settings</h1>
)}
<div className="w-full flex flex-row max-lap:flex-col">
<div className="w-[22%] h-full max-lap:w-full">
<UserProfileCard user={user} />
{user && <UserAccountLinking session={session} />}
</div>
<div className="w-[78%] flex flex-col justify-center mx-2 max-lap:mx-0 max-lap:w-full">
<UserRelaysTable
ndk={ndk}
userRelays={userRelays}
setUserRelays={setUserRelays}
reInitializeNDK={reInitializeNDK}
/>
</div>
</div>
</div>
)
);
};
export default UserSettings;

View File

@ -93,7 +93,7 @@ const UserSubscription = () => {
};
return (
<div className="p-4">
<div className="py-4 px-1">
{windowWidth < 768 && (
<h1 className="text-3xl font-bold mb-6">Subscription Management</h1>
)}
@ -117,7 +117,7 @@ const UserSubscription = () => {
{!subscribed && (
<Card
title="Subscribe to PlebDevs"
className="mb-2 max-lap:h-auto border border-gray-700"
className="max-lap:h-auto border border-gray-700"
pt={{
body: { className: 'py-2' },
content: { className: 'pt-0' }

View File

@ -1,7 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { InputText } from 'primereact/inputtext';
import { InputIcon } from 'primereact/inputicon';
import { IconField } from 'primereact/iconfield';
import { Dropdown } from 'primereact/dropdown';
import { OverlayPanel } from 'primereact/overlaypanel';
import ContentDropdownItem from '@/components/content/dropdowns/ContentDropdownItem';
@ -10,9 +8,9 @@ import { useContentSearch } from '@/hooks/useContentSearch';
import { useCommunitySearch } from '@/hooks/useCommunitySearch';
import { useRouter } from 'next/router';
import useWindowWidth from '@/hooks/useWindowWidth';
import styles from './searchbar.module.css';
import { useNDKContext } from "@/context/NDKContext";
const SearchBar = () => {
const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
const { searchContent, searchResults: contentResults } = useContentSearch();
const { searchCommunity, searchResults: communityResults } = useCommunitySearch();
const router = useRouter();
@ -25,8 +23,18 @@ const SearchBar = () => {
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState([]);
const op = useRef(null);
const { ndk, reInitializeNDK } = useNDKContext();
const selectedOptionTemplate = (option, props) => {
if (isDesktopNav) {
// For desktop nav bar, just show the icon
return (
<div className="flex items-center justify-center">
<i className={option.icon + " text-white text-lg"} />
</div>
);
}
if (!props?.placeholder) {
return (
<div className="flex items-center">
@ -45,14 +53,14 @@ const SearchBar = () => {
if (selectedSearchOption.code === 'content') {
searchContent(term);
setSearchResults(contentResults);
} else if (selectedSearchOption.code === 'community') {
} else if (selectedSearchOption.code === 'community' && ndk) {
searchCommunity(term);
setSearchResults(communityResults);
}
if (term.length > 2) {
if (!isMobileSearch && term.length > 2) {
op.current.show(e);
} else {
} else if (!isMobileSearch) {
op.current.hide();
}
};
@ -65,81 +73,230 @@ const SearchBar = () => {
}
}, [selectedSearchOption, contentResults, communityResults]);
useEffect(() => {
const handleError = (event) => {
if (event.message && event.message.includes('wss://relay.devs.tools')) {
console.warn('Nostr relay connection error detected, reinitializing NDK');
reInitializeNDK();
}
};
window.addEventListener('error', handleError);
return () => {
window.removeEventListener('error', handleError);
};
}, [reInitializeNDK]);
const handleContentSelect = (content) => {
if (content?.type === 'course') {
router.push(`/course/${content.id}`);
} else {
router.push(`/details/${content.id}`);
if (selectedSearchOption.code === 'content') {
if (content?.type === 'course') {
router.push(`/course/${content?.d || content?.id}`);
} else {
router.push(`/details/${content.id}`);
}
} else if (selectedSearchOption.code === 'community') {
if (content.type === 'discord') {
router.push('/feed?channel=discord');
} else if (content.type === 'nostr') {
router.push('/feed?channel=nostr');
} else if (content.type === 'stackernews') {
router.push('/feed?channel=stackernews');
} else {
router.push('/feed?channel=global');
}
}
setSearchTerm('');
searchContent('');
op.current.hide();
searchCommunity('');
setSearchResults([]);
if (op.current) {
op.current.hide();
}
if (isMobileSearch && onCloseSearch) {
onCloseSearch();
} else if (isMobileSearch && window.parent) {
const navbar = document.querySelector('.navbar-mobile-search');
if (navbar) {
const closeButton = navbar.querySelector('button');
if (closeButton) {
closeButton.click();
}
}
}
}
const renderSearchResults = () => {
if (searchResults.length === 0 && searchTerm.length > 2) {
return <div className="p-4 text-center text-gray-400">No results found</div>;
}
return searchResults.map((item, index) => (
item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? (
<MessageDropdownItem
key={index}
message={item}
onSelect={handleContentSelect}
/>
) : (
<ContentDropdownItem
key={index}
content={item}
onSelect={handleContentSelect}
/>
)
));
};
return (
<div className={`absolute ${windowWidth < 700 ? "left-[40%]" : "left-[50%]"} transform -translate-x-[50%]`}>
<IconField iconPosition="left">
<InputIcon className="pi pi-search"> </InputIcon>
<InputText
className={`${windowWidth > 845 ? 'w-[300px]' : 'w-[160px]'}`}
value={searchTerm}
onChange={handleSearch}
placeholder={`Search ${selectedSearchOption.name.toLowerCase()}`}
pt={{
root: {
className: 'border-none rounded-tr-none rounded-br-none focus:border-none focus:ring-0 pr-0'
}
}}
/>
<Dropdown
pt={{
root: {
className: 'border-none rounded-tl-none rounded-bl-none bg-gray-900/55 hover:bg-gray-900/30'
},
input: {
className: 'mx-0 px-0 shadow-lg'
}
}}
className={styles.dropdown}
value={selectedSearchOption}
onChange={(e) => setSelectedSearchOption(e.value)}
options={searchOptions}
optionLabel="name"
placeholder="Search"
dropdownIcon={
<div className='w-full pr-2 flex flex-row items-center justify-between'>
<i className={selectedSearchOption.icon + " text-white"} />
<i className="pi pi-chevron-down" />
<>
<div className={`${isDesktopNav ? 'w-full max-w-md' : 'w-full'}`}>
{isDesktopNav ? (
// Desktop navbar search with integrated dropdown
<div className="flex items-center bg-gray-900/55 rounded-lg">
<div className="relative flex-1 flex items-center">
<i className="pi pi-search text-gray-400 absolute left-4 z-10" />
<InputText
className="w-full bg-transparent pl-10"
value={searchTerm}
onChange={handleSearch}
placeholder={`Search ${selectedSearchOption.name.toLowerCase()}`}
pt={{
root: {
className: "border-none focus:ring-0 rounded-lg"
}
}}
/>
</div>
<div className="flex items-center px-2 border-l border-gray-700 h-full">
<Dropdown
pt={{
root: {
className: 'border-none bg-transparent'
},
input: {
className: 'mx-0 px-0'
},
trigger: {
className: 'p-0'
},
panel: {
className: 'min-w-[150px]'
}
}}
value={selectedSearchOption}
onChange={(e) => setSelectedSearchOption(e.value)}
options={searchOptions}
optionLabel="name"
dropdownIcon={
<i className="pi pi-chevron-down text-gray-400 ml-1" />
}
valueTemplate={selectedOptionTemplate}
itemTemplate={(option) => (
<div className="flex items-center py-1">
<i className={option.icon + ' mr-2 text-lg'}></i>
<span>{option.name}</span>
</div>
)}
/>
</div>
</div>
) : (
// Original search for other views
<div className={`flex flex-col ${isMobileSearch ? 'gap-4' : ''}`}>
<div className={`relative flex items-center ${isMobileSearch ? 'bg-gray-800 rounded-lg p-2' : ''}`}>
<i className="pi pi-search text-gray-400 absolute left-4 z-10" />
<InputText
className={`
${windowWidth > 845 ? 'w-[300px]' : (isMobileSearch || windowWidth <= 600) ? 'w-full' : 'w-[160px]'}
${isMobileSearch ? 'bg-transparent border-none pl-12 text-lg' : ''}
`}
value={searchTerm}
onChange={handleSearch}
placeholder={`Search ${selectedSearchOption.name.toLowerCase()}`}
pt={{
root: {
className: `${isMobileSearch ? 'focus:ring-0' : 'border-none rounded-tr-none rounded-br-none focus:border-none focus:ring-0 pr-0'}`
}
}}
/>
</div>
}
valueTemplate={selectedOptionTemplate}
itemTemplate={selectedOptionTemplate}
required
/>
</IconField>
<OverlayPanel ref={op} className="w-[600px] max-h-[70vh] overflow-y-auto">
{searchResults.map((item, index) => (
item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? (
<MessageDropdownItem
key={index}
message={item}
onSelect={handleContentSelect}
/>
) : (
<ContentDropdownItem
key={index}
content={item}
onSelect={handleContentSelect}
/>
)
))}
{searchResults.length === 0 && searchTerm.length > 2 && (
<div className="p-4 text-center">No results found</div>
{isMobileSearch && (
<div className="flex items-center gap-2 mb-3">
{searchOptions.map((option) => (
<button
key={option.code}
onClick={() => setSelectedSearchOption(option)}
className={`flex items-center gap-2 px-4 py-2 rounded-full ${
selectedSearchOption.code === option.code
? 'bg-gray-700 text-white'
: 'bg-gray-800 text-gray-400'
}`}
>
<i className={option.icon} />
<span>{option.name}</span>
</button>
))}
</div>
)}
{!isMobileSearch && (
<Dropdown
pt={{
root: {
className: 'border-none rounded-tl-none rounded-bl-none bg-gray-900/55 hover:bg-gray-900/30'
},
input: {
className: 'mx-0 px-0 shadow-lg'
}
}}
value={selectedSearchOption}
onChange={(e) => setSelectedSearchOption(e.value)}
options={searchOptions}
optionLabel="name"
placeholder="Search"
dropdownIcon={
<div className='w-full pr-2 flex flex-row items-center justify-between'>
<i className={selectedSearchOption.icon + " text-white"} />
<i className="pi pi-chevron-down" />
</div>
}
valueTemplate={selectedOptionTemplate}
itemTemplate={selectedOptionTemplate}
required
/>
)}
</div>
)}
</OverlayPanel>
</div>
</div>
{/* Desktop Search Results */}
{!isMobileSearch && (
<OverlayPanel
ref={op}
className="w-[600px] max-h-[70vh] overflow-y-auto"
>
{renderSearchResults()}
</OverlayPanel>
)}
{/* Mobile Search Results */}
{isMobileSearch && searchTerm.length > 2 && (
<div
className="fixed inset-x-0 bottom-0 top-[165px] bg-gray-900 overflow-y-auto"
style={{ touchAction: 'pan-y' }}
>
<div className="pb-20">
{renderSearchResults()}
</div>
</div>
)}
</>
);
};

View File

@ -1,19 +0,0 @@
/* .dropdown {
border: none !important;
outline: none !important;
box-shadow: none !important;
}
.dropdown:focus,
.dropdown:active,
.dropdown:hover {
border: none !important;
outline: none !important;
box-shadow: none !important;
} */
/* Override any potential global styles */
/* .dropdown * {
outline: none !important;
box-shadow: none !important;
} */

View File

@ -1,151 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/router';
import { useSession, signOut } from 'next-auth/react';
import { useIsAdmin } from '@/hooks/useIsAdmin';
import { nip19 } from 'nostr-tools';
import { useToast } from '@/hooks/useToast';
import { useNDKContext } from '@/context/NDKContext';
import 'primeicons/primeicons.css';
import styles from "./sidebar.module.css";
import { Divider } from 'primereact/divider';
const Sidebar = ({ course = false }) => {
const { isAdmin } = useIsAdmin();
const [lessons, setLessons] = useState([]);
const router = useRouter();
const { showToast } = useToast();
const { ndk, addSigner } = useNDKContext();
// Helper function to determine if the path matches the current route
const isActive = (path) => {
if (path === '/content') {
return router.pathname === '/content';
}
if (path === '/feed') {
return router.pathname === '/feed';
}
return router.asPath === path;
};
const { data: session } = useSession();
useEffect(() => {
if (router.isReady) {
const { slug } = router.query;
try {
if (slug && course) {
const { data } = nip19.decode(slug)
if (!data) {
showToast('error', 'Error', 'Course not found');
return;
}
const id = data?.identifier;
const fetchCourse = async (id) => {
try {
await ndk.connect();
const filter = {
'#d': [id]
}
const event = await ndk.fetchEvent(filter);
if (event) {
// all a tags are lessons
const lessons = event.tags.filter(tag => tag[0] === 'a');
const uniqueLessons = [...new Set(lessons.map(lesson => lesson[1]))];
setLessons(uniqueLessons);
}
} catch (error) {
console.error('Error fetching event:', error);
}
};
if (ndk && id) {
fetchCourse(id);
}
}
} catch (err) {
console.error(err);
}
}
}, [router.isReady, router.query, ndk, course]);
const scrollToLesson = useCallback((index) => {
const lessonElement = document.getElementById(`lesson-${index}`);
if (lessonElement) {
lessonElement.scrollIntoView({ behavior: 'smooth' });
}
}, []);
useEffect(() => {
if (router.isReady && router.query.active) {
const activeIndex = parseInt(router.query.active);
scrollToLesson(activeIndex);
}
}, [router.isReady, router.query.active, scrollToLesson]);
return (
<div className="max-sidebar:hidden bg-gray-800 p-2 fixed h-[100%] flex flex-col w-[14vw]">
<div className="flex-grow overflow-y-auto">
{course && lessons.length > 0 && (
<div className="flex-grow overflow-y-auto">
<div onClick={() => router.push('/')} className="w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg">
<i className="pi pi-arrow-left pl-5" /> <p className="pl-2 rounded-md font-bold text-lg">Home</p>
</div>
{lessons.map((lesson, index) => (
<div
key={lesson}
onClick={() => {
router.push(`/course/${router?.query?.slug}?active=${index}`, undefined, { shallow: true });
scrollToLesson(index);
}}
className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive(`/course/${router?.query?.slug}?active=${index}`) ? 'bg-gray-700' : ''}`}
>
<i className="pi pi-lightbulb text-sm pl-5" /> <p className="pl-2 rounded-md font-bold text-lg">Lesson {index + 1}</p>
</div>
))}
</div>
)}
{!course && (
<div className="flex-grow overflow-y-auto">
<div onClick={() => router.push('/')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/') ? 'bg-gray-700' : ''}`}>
<i className="pi pi-home pl-5" /> <p className="pl-2 rounded-md font-bold text-lg">Home</p>
</div>
<div onClick={() => router.push('/content?tag=all')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/content') || router.pathname === '/content' ? 'bg-gray-700' : ''}`}>
<i className="pi pi-play-circle pl-5" /> <p className="pl-2 rounded-md font-bold text-lg">Content</p>
</div>
<div onClick={() => router.push('/feed?channel=global')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/feed') ? 'bg-gray-700' : ''}`}>
<i className="pi pi-comments pl-5" /> <p className="pl-2 rounded-md font-bold text-lg">Feeds</p>
</div>
{isAdmin && (
<div onClick={() => router.push('/create')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/create') ? 'bg-gray-700' : ''}`}>
<i className="pi pi-plus pl-5 text-sm" /> <p className="pl-2 rounded-md font-bold text-lg">Create</p>
</div>
)}
<div onClick={() => session ? router.push('/profile?tab=subscribe') : router.push('/subscribe')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/profile?tab=subscribe') || isActive('/subscribe') ? 'bg-gray-700' : ''}`}>
<i className="pi pi-star pl-5 text-sm" /> <p className="pl-2 rounded-md font-bold text-lg">Subscribe</p>
</div>
</div>
)}
</div>
<Divider className='pt-0 mt-0' />
<div className='mt-auto'>
<div onClick={() => router.push('/profile?tab=settings')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/profile?tab=settings') ? 'bg-gray-700' : ''}`}>
<i className="pi pi-cog pl-5 text-sm" /> <p className="pl-2 rounded-md font-bold text-lg">Settings</p>
</div>
<div onClick={() => session ? signOut() : router.push('/auth/signin')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/auth/signin') ? 'bg-gray-700' : ''}`}>
<i className={`pi ${session ? 'pi-sign-out' : 'pi-sign-in'} pl-5 text-sm`} /> <p className="pl-2 rounded-md font-bold text-lg">{session ? 'Logout' : 'Login'}</p>
</div>
{/* todo: have to add this extra button to push the sidebar to the right space but it doesnt seem to cause any negative side effects? */}
<div onClick={signOut} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg`}>
<i className="pi pi-sign-out pl-5 text-sm" /> <p className="pl-2 rounded-md font-bold text-lg">Logout</p>
</div>
</div>
</div>
);
};
export default Sidebar;

View File

@ -1,13 +0,0 @@
.p-accordion .p-accordion-content {
border: none !important;
padding-top: 0px !important;
padding-bottom: 0px !important;
}
.p-accordion .p-accordion-header-link {
border: none !important;
padding-bottom: 12px !important;
padding-top: 12px !important;
margin-bottom: 8px !important;
border-bottom-left-radius: 7px !important;
border-bottom-right-radius: 7px !important;
}

View File

@ -9,7 +9,7 @@ const appConfig = {
"wss://purplerelay.com/",
"wss://relay.devs.tools/"
],
authorPubkeys: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741", "c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345"],
authorPubkeys: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741", "c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345", "6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4"],
customLightningAddresses: [
{
// todo remove need for lowercase

View File

@ -11,7 +11,6 @@ import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
import "@uiw/react-md-editor/markdown-editor.css";
import "@uiw/react-markdown-preview/markdown.css";
import Sidebar from '@/components/sidebar/Sidebar';
import { useRouter } from 'next/router';
import { NDKProvider } from '@/context/NDKContext';
import { Analytics } from '@vercel/analytics/react';
@ -26,13 +25,8 @@ const queryClient = new QueryClient()
export default function MyApp({
Component, pageProps: { session, ...pageProps }
}) {
const [isCourseView, setIsCourseView] = useState(false);
const router = useRouter();
useEffect(() => {
setIsCourseView(router.pathname.includes('course') && !router.pathname.includes('draft'));
}, [router.pathname]);
return (
<PrimeReactProvider>
<SessionProvider session={session}>
@ -42,13 +36,10 @@ export default function MyApp({
<Layout>
<div className="flex flex-col min-h-screen">
<Navbar />
<div className='flex'>
<Sidebar course={isCourseView} />
<div className='w-[100vw] pl-[14vw] max-sidebar:pl-0 pb-16 max-sidebar:pb-20'>
<Component {...pageProps} />
<Analytics />
</div>
</div>
<main>
<Component {...pageProps} />
<Analytics />
</main>
<BottomBar />
</div>
</Layout>

View File

@ -1,19 +1,169 @@
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/router';
import Image from 'next/image';
import NostrIcon from '../../public/images/nostr.png';
import { Card } from 'primereact/card';
import { Message } from 'primereact/message';
import { useToast } from "@/hooks/useToast";
import useWindowWidth from "@/hooks/useWindowWidth";
import GenericButton from '@/components/buttons/GenericButton';
import InteractivePromotionalCarousel from '@/components/content/carousels/InteractivePromotionalCarousel';
import axios from 'axios';
import { Menu } from "primereact/menu";
import { ProgressSpinner } from 'primereact/progressspinner';
import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
import CalendlyEmbed from '@/components/profile/subscription/CalendlyEmbed';
import CancelSubscription from '@/components/profile/subscription/CancelSubscription';
import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
import Nip05Form from '@/components/profile/subscription/Nip05Form';
import LightningAddressForm from '@/components/profile/subscription/LightningAddressForm';
import MoreInfo from '@/components/MoreInfo';
const AboutPage = () => {
const { data: session, update } = useSession();
const { showToast } = useToast();
const router = useRouter();
const windowWidth = useWindowWidth();
const menu = useRef(null);
const isTabView = windowWidth <= 1360;
const isMobile = windowWidth < 768;
const [user, setUser] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [subscribed, setSubscribed] = useState(false);
const [subscribedUntil, setSubscribedUntil] = useState(null);
const [subscriptionExpiredAt, setSubscriptionExpiredAt] = useState(null);
const [calendlyVisible, setCalendlyVisible] = useState(false);
const [lightningAddressVisible, setLightningAddressVisible] = useState(false);
const [nip05Visible, setNip05Visible] = useState(false);
const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
const isTabView = windowWidth <= 1160;
const isMobile = windowWidth < 668;
// FAQ content for the modal
const faqContent = (
<div className="flex flex-col gap-4">
<div>
<h3 className="text-lg font-semibold">How does the subscription work?</h3>
<p>Think of the subscriptions as a Patreon-type model. You pay a monthly fee and in return you get access to premium features and all of the paid content. You can cancel at any time.</p>
</div>
<div className='flex flex-col gap-2'>
<h3 className="text-lg font-semibold">What are the benefits of a subscription?</h3>
<p>The subscription gives you access to all of the premium features and all of the paid content. You can cancel at any time.</p>
</div>
<div className='flex flex-col gap-2'>
<h3 className="text-lg font-semibold">How much does the subscription cost?</h3>
<p>The subscription is 50,000 sats per month.</p>
</div>
<div>
<h3 className="text-lg font-semibold">How do I Subscribe? (Pay as you go)</h3>
<p>The pay as you go subscription is a one-time payment that gives you access to all of the premium features for one month. You will need to manually renew your subscription every month.</p>
</div>
<div>
<h3 className="text-lg font-semibold">How do I Subscribe? (Recurring)</h3>
<p>The recurring subscription option allows you to submit a Nostr Wallet Connect URI that will be used to automatically send the subscription fee every month. You can cancel at any time.</p>
</div>
<div>
<h3 className="text-lg font-semibold">Can I cancel my subscription?</h3>
<p>Yes, you can cancel your subscription at any time. Your access will remain active until the end of the current billing period.</p>
</div>
<div>
<h3 className="text-lg font-semibold">What happens if I don&apos;t renew my subscription?</h3>
<p>If you don&apos;t renew your subscription, your access to 1:1 calendar and paid content will be removed. However, you will still have access to your PlebDevs Lightning Address, NIP-05, and any content that you paid for.</p>
</div>
<div>
<h3 className="text-lg font-semibold">What is Nostr Wallet Connect?</h3>
<p>Nostr Wallet Connect is a Nostr-based authentication method that allows you to connect your Nostr wallet to the PlebDevs platform. This will allow you to subscribe to the platform in an auto recurring manner which still gives you full control over your wallet and the ability to cancel at any time from your wallet.</p>
</div>
</div>
);
useEffect(() => {
if (session && session?.user) {
setUser(session.user);
}
}, [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.subscriptionExpiredAt) {
const expiredAt = new Date(user.role.subscriptionExpiredAt)
setSubscriptionExpiredAt(expiredAt);
}
}
}, [user]);
const handleSubscriptionSuccess = async (response) => {
setIsProcessing(true);
try {
const apiResponse = await axios.put('/api/users/subscription', {
userId: session.user.id,
isSubscribed: true,
});
if (apiResponse.data) {
await update();
showToast('success', 'Subscription Successful', 'Your subscription has been activated.');
} else {
throw new Error('Failed to update subscription status');
}
} catch (error) {
console.error('Subscription update error:', error);
showToast('error', 'Subscription Update Failed', `Error: ${error.message}`);
} finally {
setIsProcessing(false);
}
};
const handleSubscriptionError = (error) => {
console.error('Subscription error:', error);
showToast('error', 'Subscription Failed', `An error occurred: ${error.message}`);
setIsProcessing(false);
};
const handleRecurringSubscriptionSuccess = async () => {
setIsProcessing(true);
try {
await update();
showToast('success', 'Recurring Subscription Activated', 'Your recurring subscription has been set up successfully.');
} catch (error) {
console.error('Session update error:', error);
showToast('error', 'Session Update Failed', `Error: ${error.message}`);
} finally {
setIsProcessing(false);
}
};
const menuItems = [
{
label: "Schedule 1:1",
icon: "pi pi-calendar",
command: () => setCalendlyVisible(true),
},
{
label: session?.user?.platformLightningAddress ? "Update PlebDevs Lightning Address" : "Claim PlebDevs Lightning Address",
icon: "pi pi-bolt",
command: () => setLightningAddressVisible(true),
},
{
label: session?.user?.platformNip05?.name ? "Update PlebDevs Nostr NIP-05" : "Claim PlebDevs Nostr NIP-05",
icon: "pi pi-at",
command: () => setNip05Visible(true),
},
{
label: "Renew Subscription",
icon: "pi pi-sync",
command: () => setRenewSubscriptionVisible(true),
},
{
label: "Cancel Subscription",
icon: "pi pi-trash",
command: () => setCancelSubscriptionVisible(true),
},
];
const copyToClipboard = async (text) => {
try {
@ -32,9 +182,171 @@ const AboutPage = () => {
};
return (
<div className={`${isTabView ? 'w-full' : 'w-[83vw]'} ${isMobile ? 'p-0' : 'p-4'} mx-auto`}>
<div className={`${isTabView ? 'w-full' : 'w-full px-12'} ${isMobile ? 'p-0' : 'p-4'} mx-auto`}>
<InteractivePromotionalCarousel />
<Card title="Key Features" className={`mb-4 ${isMobile ? 'm-2' : null}`}>
{/* For non-logged in users */}
{!session?.user && (
<>
<Card title="Start Your PlebDevs Journey" className="mb-2">
<p className='mb-4 text-xl'>
The PlebDevs subscription unlocks all paid content, grants access to our 1:1 calendar for tutoring, support, and mentorship, and grants you your own personal plebdevs.com Lightning Address and Nostr NIP-05 identity.
</p>
<p className='text-xl mb-4'>
Subscribe monthly with a pay-as-you-go option or set up an auto-recurring subscription using Nostr Wallet Connect.
</p>
</Card>
<Card title="Ready to level up?" className="mb-2">
<p className='text-xl pb-4'>Login to start your subscription!</p>
<GenericButton label="Login" onClick={() => router.push('/auth/signin')} className='text-[#f8f8ff] w-fit' rounded icon="pi pi-user" />
</Card>
</>
)}
{/* Subscription Card */}
<Card
className={`mb-2 relative ${isMobile ? 'm-2' : null}`}
header={
<div className="flex justify-between items-center p-4 pb-0">
<h2 className="text-xl font-bold m-0">Subscribe to PlebDevs</h2>
<MoreInfo
tooltip="Subscription FAQ"
tooltipPosition='top'
modalTitle="Frequently Asked Questions"
modalBody={faqContent}
className="text-gray-400 hover:text-white"
/>
</div>
}
>
{!isProcessing ? (
<div className="flex flex-col">
{/* Only show premium benefits when not subscribed or session doesn't exist */}
{(!session?.user || (session?.user && !subscribed)) && (
<>
<div className="mb-4">
<h3 className="text-xl font-bold text-primary">Unlock Premium Benefits</h3>
<p className="text-gray-400">Subscribe now and elevate your development journey!</p>
</div>
<div className="flex flex-col gap-4 mb-4">
<div className="flex items-center">
<i className="pi pi-book text-2xl text-primary mr-2 text-blue-400"></i>
<span>Access ALL current and future PlebDevs content</span>
</div>
<div className="flex items-center">
<i className="pi pi-calendar text-2xl text-primary mr-2 text-red-400"></i>
<span>Personal mentorship & guidance and access to exclusive 1:1 booking calendar</span>
</div>
<div className="flex items-center">
<i className="pi pi-bolt text-2xl text-primary mr-2 text-yellow-500"></i>
<span>Claim your own personal plebdevs.com Lightning Address</span>
</div>
<div className="flex items-center">
<Image src={NostrIcon} alt="Nostr" width={25} height={25} className='mr-2' />
<span>Claim your own personal plebdevs.com Nostr NIP-05 identity</span>
</div>
</div>
</>
)}
<div className="mb-2 rounded-lg">
{/* Status Messages */}
{session && session?.user ? (
<>
{subscribed && !user?.role?.nwc && (
<div className="flex flex-col">
<div className="flex items-center bg-green-900/50 border border-green-700 rounded p-2 text-green-300 w-fit">
<i className="pi pi-check-circle mr-2"></i>
<span>Subscribed!</span>
</div>
<p className="mt-3 font-medium">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400 mt-1">Pay-as-you-go subscription must be manually renewed on {subscribedUntil?.toLocaleDateString()}</p>
</div>
)}
{subscribed && user?.role?.nwc && (
<div className="flex flex-col">
<div className="flex items-center bg-green-900/50 border border-green-700 rounded p-2 text-green-300 w-fit">
<i className="pi pi-check-circle mr-2"></i>
<span>Subscribed!</span>
</div>
<p className="mt-3 font-medium">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400 mt-1">Recurring subscription will AUTO renew on {subscribedUntil?.toLocaleDateString()}</p>
</div>
)}
{(!subscribed && !subscriptionExpiredAt) && (
<div className="flex flex-col">
<div className="flex items-center bg-blue-900/50 border border-blue-700 rounded p-2 text-blue-300 w-fit">
<i className="pi pi-info-circle mr-2"></i>
<span>You currently have no active subscription</span>
</div>
<p className="mt-3 text-gray-400">
Subscribe below to unlock all premium features and content.
</p>
</div>
)}
{subscriptionExpiredAt && (
<div className="flex flex-col">
<div className="flex items-center bg-yellow-900/50 border border-yellow-700 rounded p-2 text-yellow-300 w-fit">
<i className="pi pi-exclamation-triangle mr-2"></i>
<span>Your subscription expired on {subscriptionExpiredAt.toLocaleDateString()}</span>
</div>
<p className="mt-3 text-gray-400">
Renew below to continue enjoying all premium benefits.
</p>
</div>
)}
</>
) : (
<div className="flex flex-col">
<div className="flex items-center bg-blue-900/50 border border-blue-700 rounded p-2 text-blue-300 w-fit">
<i className="pi pi-info-circle mr-2"></i>
<span>Login to manage your subscription</span>
</div>
<p className="mt-3 text-gray-400">
Sign in to access subscription features and management.
</p>
</div>
)}
</div>
{/* Payment Buttons */}
{(!session?.user || (session?.user && !subscribed)) && (
<SubscriptionPaymentButtons
onSuccess={handleSubscriptionSuccess}
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
onError={handleSubscriptionError}
setIsProcessing={setIsProcessing}
layout={windowWidth < 768 ? "col" : "row"}
/>
)}
</div>
) : (
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
<div className='w-full h-full flex items-center justify-center'><ProgressSpinner /></div>
<span className="ml-2">Processing subscription...</span>
</div>
)}
</Card>
{/* Subscription Management */}
{session?.user && subscribed && (
<>
<Card title="Subscription Benefits" className="mb-2">
<div className="flex flex-col gap-4">
<GenericButton severity="info" outlined className="w-fit text-start" label="Schedule 1:1" icon="pi pi-calendar" onClick={() => setCalendlyVisible(true)} />
<GenericButton severity="help" outlined className="w-fit text-start" label={session?.user?.platformNip05?.name ? "Update Nostr NIP-05" : "Claim PlebDevs Nostr NIP-05"} icon="pi pi-at" onClick={() => setNip05Visible(true)} />
<GenericButton severity="warning" outlined className="w-fit text-start" label={session?.user?.platformLightningAddress ? "Update Lightning Address" : "Claim PlebDevs Lightning Address"} icon={<i style={{ color: "orange" }} className="pi pi-bolt mr-2"></i>} onClick={() => setLightningAddressVisible(true)} />
</div>
</Card>
<Card title="Manage Subscription" className="mb-2">
<div className='flex flex-col gap-4'>
<GenericButton outlined className="w-fit" label="Renew Subscription" icon="pi pi-sync" onClick={() => setRenewSubscriptionVisible(true)} />
<GenericButton severity="danger" outlined className="w-fit" label="Cancel Subscription" icon="pi pi-trash" onClick={() => setCancelSubscriptionVisible(true)} />
</div>
</Card>
</>
)}
<Card title="Key Features" className={`mb-2 ${isMobile ? 'm-2' : null}`}>
<div className="flex flex-col gap-4 max-w-[80%] max-mob:max-w-full">
<div className="flex flex-col items-start justify-center">
<div className='flex items-start'>
@ -134,7 +446,7 @@ const AboutPage = () => {
</div>
</Card>
<Card title="Connect with Us" className="mb-4 max-tab:mx-2">
<Card title="Connect with Us" className="max-tab:mx-2 mb-20 lg:mb-2">
<div className="flex flex-wrap gap-4 justify-center">
<GenericButton
severity="secondary"
@ -175,6 +487,32 @@ const AboutPage = () => {
/>
</div>
</Card>
{/* Dialog Components */}
<CalendlyEmbed
visible={calendlyVisible}
onHide={() => setCalendlyVisible(false)}
userId={session?.user?.id}
userName={session?.user?.username || user?.kind0?.username}
userEmail={session?.user?.email}
/>
<CancelSubscription
visible={cancelSubscriptionVisible}
onHide={() => setCancelSubscriptionVisible(false)}
/>
<RenewSubscription
visible={renewSubscriptionVisible}
onHide={() => setRenewSubscriptionVisible(false)}
subscribedUntil={subscribedUntil}
/>
<Nip05Form
visible={nip05Visible}
onHide={() => setNip05Visible(false)}
/>
<LightningAddressForm
visible={lightningAddressVisible}
onHide={() => setLightningAddressVisible(false)}
/>
</div>
);
};

View File

@ -58,9 +58,9 @@ const MenuTab = ({ items, selectedTopic, onTabChange }) => {
activeIndex={allItems.indexOf(selectedTopic)}
onTabChange={(e) => onTabChange(allItems[e.index])}
pt={{
menu: { className: 'bg-transparent border-none ml-2 my-4 py-1' },
menu: { className: 'bg-transparent border-none my-2 py-1' },
action: ({ context, parent }) => ({
className: 'cursor-pointer select-none flex items-center relative no-underline overflow-hidden border-b-2 p-2 font-bold rounded-t-lg',
className: 'cursor-pointer select-none flex items-center relative no-underline overflow-hidden border-b-2 p-2 pl-1 font-bold rounded-t-lg',
style: { top: '2px' }
}),
menuitem: { className: 'mr-0' }
@ -156,7 +156,7 @@ const ContentPage = () => {
router.push(`/content${queryParam}`, undefined, { shallow: true });
filterContent(newTopic, allContent);
};
const renderCarousels = () => {
return (
<GenericCarousel
@ -170,15 +170,14 @@ const ContentPage = () => {
};
return (
<div className="w-full px-4">
<div className="w-fit mx-4 mt-8 flex flex-col items-start">
<h1 className="text-3xl font-bold mb-4 ml-1">All Content</h1>
<div className="w-full px-10 max-mob:px-1">
<div className="w-fit mt-8 flex flex-col items-start">
<h1 className="text-3xl font-bold mb-4 ml-2">All Content</h1>
</div>
<MenuTab
items={allTopics}
selectedTopic={selectedTopic}
onTabChange={handleTopicChange}
className="max-w-[90%] mx-auto"
/>
{renderCarousels()}
</div>

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import Image from 'next/image';
import { InputText } from 'primereact/inputtext';
import CommunityMenuTab from '@/components/menutab/CommunityMenuTab';
import NostrFeed from '@/components/feeds/NostrFeed';
import DiscordFeed from '@/components/feeds/DiscordFeed';
@ -17,7 +16,6 @@ const Feed = () => {
const [selectedTopic, setSelectedTopic] = useState('global');
const [title, setTitle] = useState('Community');
const allTopics = ['global', 'nostr', 'discord', 'stackernews'];
const [searchQuery, setSearchQuery] = useState('');
const router = useRouter();
@ -30,7 +28,6 @@ const Feed = () => {
setTitle(router.query.channel);
}, [router.query.channel]);
// initialize the selected topic to the query parameter
useEffect(() => {
setSelectedTopic(router.query.channel);
}, [router.query.channel]);
@ -51,57 +48,44 @@ const Feed = () => {
};
return (
<div className="h-[100vh] w-[100vw] min-bottom-bar:w-[86vw]">
<div className="w-[100vw] min-bottom-bar:w-[86vw] px-4 pt-4 flex flex-col items-start">
<div className='mb-4 flex flex-row items-end'>
<h1 className="font-bold mb-0">Feeds</h1>
<GenericButton
icon={getTagIcon(title)}
className='ml-2 text-sm p-2 py-1 flex items-center cursor-default hover:bg-transparent'
outlined
severity={{
'global': 'success',
'discord': 'primary',
'stackernews': 'warning',
'nostr': 'help'
}[title] || 'info'}
label={`${title}`}
/>
</div>
<div className='w-full flex flex-row items-center justify-between mb-2'>
<InputText
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search"
icon="pi pi-search"
className="w-fit"
/>
<div className="w-full mx-auto px-10">
<div className="mb-2">
<div className='flex flex-col'>
<div className='my-4 flex flex-row items-end'>
<h1 className="font-bold mb-0">Feeds</h1>
<GenericButton
icon={getTagIcon(title)}
className='ml-2 text-sm p-2 py-1 flex items-center cursor-default hover:bg-transparent'
outlined
severity={{
'global': 'success',
'discord': 'primary',
'stackernews': 'warning',
'nostr': 'help'
}[title] || 'info'}
label={`${title}`}
/>
</div>
{selectedTopic === 'nostr' && (
<div className='w-full mt-2'>
<MessageInput />
</div>
)}
</div>
<Divider />
{selectedTopic === 'nostr' && (
<MessageInput
collapsed={false}
/>
)}
</div>
<CommunityMenuTab
items={allTopics}
selectedTopic={selectedTopic}
onTabChange={handleTopicChange}
className="max-w-[90%] mx-auto"
className="mb-4"
/>
{
selectedTopic === 'global' && <GlobalFeed searchQuery={searchQuery} />
}
{
selectedTopic === 'nostr' && <NostrFeed searchQuery={searchQuery} />
}
{
selectedTopic === 'discord' && <DiscordFeed searchQuery={searchQuery} />
}
{
selectedTopic === 'stackernews' && <StackerNewsFeed searchQuery={searchQuery} />
}
<div className="feed-content">
{selectedTopic === 'global' && <GlobalFeed />}
{selectedTopic === 'nostr' && <NostrFeed />}
{selectedTopic === 'discord' && <DiscordFeed />}
{selectedTopic === 'stackernews' && <StackerNewsFeed />}
</div>
</div>
);
};

View File

@ -1,25 +1,191 @@
import Head from 'next/head';
import React from 'react';
import React, { useEffect, useState } from 'react';
import CoursesCarousel from '@/components/content/carousels/CoursesCarousel';
import VideosCarousel from '@/components/content/carousels/VideosCarousel';
import DocumentsCarousel from '@/components/content/carousels/DocumentsCarousel';
import { parseEvent, parseCourseEvent } from '@/utils/nostr';
import { useDocuments } from '@/hooks/nostr/useDocuments';
import { useVideos } from '@/hooks/nostr/useVideos';
import { useCourses } from '@/hooks/nostr/useCourses';
import { TabMenu } from 'primereact/tabmenu';
import 'primeicons/primeicons.css';
import GenericButton from '@/components/buttons/GenericButton';
import { useRouter } from 'next/router';
import HeroBanner from '@/components/banner/HeroBanner';
const MenuTab = ({ selectedTopic, onTabChange, allTopics }) => {
const router = useRouter();
// Define the hardcoded priority items that always appear first
const priorityItems = ['Top', 'Courses', 'Videos', 'Documents', 'Free', 'Paid'];
// Items that should be filtered out from topics
const blacklistedItems = ['document', 'video', 'course'];
// Get dynamic topics, excluding hardcoded and blacklisted items
const otherItems = allTopics.filter(item =>
!priorityItems.includes(item) &&
!blacklistedItems.includes(item)
);
// Only take the first 4 dynamic topics to keep the menu clean
// Additional topics will be accessible through the "More" page
const limitedOtherItems = otherItems.slice(0, 8);
// Combine all menu items: priority items + up to 4 dynamic topics + More
const allItems = [...priorityItems, ...limitedOtherItems, 'More'];
const menuItems = allItems.map((item) => {
let icon = 'pi pi-tag';
if (item === 'Top') icon = 'pi pi-star';
else if (item === 'Documents') icon = 'pi pi-file';
else if (item === 'Videos') icon = 'pi pi-video';
else if (item === 'Courses') icon = 'pi pi-desktop';
else if (item === 'Free') icon = 'pi pi-lock-open';
else if (item === 'Paid') icon = 'pi pi-lock';
else if (item === 'More') icon = 'pi pi-ellipsis-h';
const isMore = item === 'More';
const path = isMore ? '/content?tag=all' : item === 'Top' ? '/' : `/content?tag=${item.toLowerCase()}`;
return {
label: (
<GenericButton
className={`${selectedTopic === item ? 'bg-primary text-white' : ''}`}
onClick={() => {
onTabChange(item);
router.push(path);
}}
outlined={selectedTopic !== item}
rounded
size='small'
label={item}
icon={icon}
/>
),
command: () => {
onTabChange(item);
router.push(path);
}
};
});
return (
<div className="w-full">
<TabMenu
model={menuItems}
activeIndex={allItems.indexOf(selectedTopic)}
onTabChange={(e) => onTabChange(allItems[e.index])}
pt={{
menu: { className: 'bg-transparent border-none my-2 mb-4' },
action: ({ context, parent }) => ({
className: 'cursor-pointer select-none flex items-center relative no-underline overflow-hidden border-b-2 p-2 font-bold rounded-t-lg',
style: { top: '2px' }
}),
menuitem: { className: 'mr-0' }
}}
/>
</div>
);
}
export default function Home() {
return (
<>
<Head>
<title>PlebDevs</title>
<meta name="description" content="Build on Bitcoin" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<HeroBanner />
<CoursesCarousel />
<VideosCarousel />
<DocumentsCarousel />
</main>
</>
);
const router = useRouter();
const { documents, documentsLoading } = useDocuments();
const { videos, videosLoading } = useVideos();
const { courses, coursesLoading } = useCourses();
const [processedDocuments, setProcessedDocuments] = useState([]);
const [processedVideos, setProcessedVideos] = useState([]);
const [processedCourses, setProcessedCourses] = useState([]);
const [allContent, setAllContent] = useState([]);
const [allTopics, setAllTopics] = useState([]);
const [selectedTopic, setSelectedTopic] = useState('Top');
useEffect(() => {
if (documents && !documentsLoading) {
const processedDocuments = documents.map(document => ({...parseEvent(document), type: 'document'}));
setProcessedDocuments(processedDocuments);
}
}, [documents, documentsLoading]);
useEffect(() => {
if (videos && !videosLoading) {
const processedVideos = videos.map(video => ({...parseEvent(video), type: 'video'}));
setProcessedVideos(processedVideos);
}
}, [videos, videosLoading]);
useEffect(() => {
if (courses && !coursesLoading) {
const processedCourses = courses.map(course => ({...parseCourseEvent(course), type: 'course'}));
setProcessedCourses(processedCourses);
}
}, [courses, coursesLoading]);
useEffect(() => {
const allContent = [...processedDocuments, ...processedVideos, ...processedCourses];
setAllContent(allContent);
const uniqueTopics = new Set(allContent.map(item => item.topics).flat());
const otherTopics = Array.from(uniqueTopics).filter(topic =>
!['Top', 'Courses', 'Videos', 'Documents', 'Free', 'Paid', 'More'].includes(topic) &&
!['document', 'video', 'course'].includes(topic)
);
setAllTopics(otherTopics);
filterContent(selectedTopic, allContent);
}, [processedDocuments, processedVideos, processedCourses, selectedTopic]);
const filterContent = (topic, content) => {
let filtered = content;
if (topic !== 'Top' && topic !== 'More') {
const topicLower = topic.toLowerCase();
if (['courses', 'videos', 'documents'].includes(topicLower)) {
filtered = content.filter(item => item.type === topicLower.slice(0, -1));
} else if (topicLower === 'free') {
filtered = content.filter(item => !item.price || Number(item.price) === 0);
} else if (topicLower === 'paid') {
filtered = content.filter(item => item.price && Number(item.price) > 0);
} else {
filtered = content.filter(item => item.topics && item.topics.includes(topicLower));
}
}
};
const handleTopicChange = (newTopic) => {
setSelectedTopic(newTopic);
if (newTopic === 'More') {
router.push('/content?tag=all');
} else {
filterContent(newTopic, allContent);
router.push(newTopic === 'Top' ? '/' : `/content?tag=${newTopic.toLowerCase()}`);
}
};
return (
<>
<Head>
<title>PlebDevs</title>
<meta name="description" content="Build on Bitcoin" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<HeroBanner />
<div className="w-full px-12">
<MenuTab
selectedTopic={selectedTopic}
onTabChange={handleTopicChange}
allTopics={allTopics}
className="max-w-[90%] mx-auto"
/>
</div>
<div className="w-full px-4">
<CoursesCarousel />
<VideosCarousel />
<DocumentsCarousel />
</div>
</main>
</>
);
}

View File

@ -1,7 +1,6 @@
import React, { useState, useEffect } from "react";
import { TabView, TabPanel } from "primereact/tabview";
import UserProfile from "@/components/profile/UserProfile";
import UserSettings from "@/components/profile/UserSettings";
import UserContent from "@/components/profile/UserContent";
import UserSubscription from "@/components/profile/subscription/UserSubscription";
import { useRouter } from "next/router";
@ -9,13 +8,14 @@ import { useSession } from "next-auth/react";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import { ProgressSpinner } from "primereact/progressspinner";
//todo: Link below connect wallet, relays hidden in ... (open modal)
const Profile = () => {
const router = useRouter();
const { data: session, status } = useSession();
const [activeTab, setActiveTab] = useState(0);
const {isAdmin, isLoading} = useIsAdmin();
const tabs = ["profile", "settings", "content", "subscribe"];
const tabs = ["profile", "content", "subscribe"];
useEffect(() => {
const { tab } = router.query;
@ -53,7 +53,7 @@ const Profile = () => {
if (!session) return null;
return (
<div className="w-full min-h-full min-bottom-bar:w-[86vw] mx-auto">
<div className="w-full min-h-full mx-auto px-10">
<TabView
pt={{
root: {
@ -73,13 +73,6 @@ const Profile = () => {
}}>
<UserProfile />
</TabPanel>
<TabPanel header="Settings" pt={{
headerAction: {
className: "bg-transparent"
},
}}>
<UserSettings />
</TabPanel>
{isAdmin && (
<TabPanel header="Content" pt={{
headerAction: {

View File

@ -1,306 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/router';
import { useToast } from '@/hooks/useToast';
import axios from 'axios';
import { Card } from 'primereact/card';
import { Message } from 'primereact/message';
import useWindowWidth from '@/hooks/useWindowWidth';
import { Menu } from "primereact/menu";
import GenericButton from '@/components/buttons/GenericButton';
import { ProgressSpinner } from 'primereact/progressspinner';
import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
import Image from 'next/image';
import NostrIcon from '../../public/images/nostr.png';
import CalendlyEmbed from '@/components/profile/subscription/CalendlyEmbed';
import CancelSubscription from '@/components/profile/subscription/CancelSubscription';
import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
import Nip05Form from '@/components/profile/subscription/Nip05Form';
import LightningAddressForm from '@/components/profile/subscription/LightningAddressForm';
const Subscribe = () => {
const { data: session, update } = useSession();
const { showToast } = useToast();
const router = useRouter();
const menu = useRef(null);
const windowWidth = useWindowWidth();
const [user, setUser] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [subscribed, setSubscribed] = useState(false);
const [subscribedUntil, setSubscribedUntil] = useState(null);
const [subscriptionExpiredAt, setSubscriptionExpiredAt] = useState(null);
const [calendlyVisible, setCalendlyVisible] = useState(false);
const [lightningAddressVisible, setLightningAddressVisible] = useState(false);
const [nip05Visible, setNip05Visible] = useState(false);
const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
useEffect(() => {
if (session && session?.user) {
setUser(session.user);
}
}, [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.subscriptionExpiredAt) {
const expiredAt = new Date(user.role.subscriptionExpiredAt)
setSubscriptionExpiredAt(expiredAt);
}
}
}, [user]);
const handleSubscriptionSuccess = async (response) => {
setIsProcessing(true);
try {
const apiResponse = await axios.put('/api/users/subscription', {
userId: session.user.id,
isSubscribed: true,
});
if (apiResponse.data) {
await update();
showToast('success', 'Subscription Successful', 'Your subscription has been activated.');
} else {
throw new Error('Failed to update subscription status');
}
} catch (error) {
console.error('Subscription update error:', error);
showToast('error', 'Subscription Update Failed', `Error: ${error.message}`);
} finally {
setIsProcessing(false);
}
};
const handleSubscriptionError = (error) => {
console.error('Subscription error:', error);
showToast('error', 'Subscription Failed', `An error occurred: ${error.message}`);
setIsProcessing(false);
};
const handleRecurringSubscriptionSuccess = async () => {
setIsProcessing(true);
try {
await update();
showToast('success', 'Recurring Subscription Activated', 'Your recurring subscription has been set up successfully.');
} catch (error) {
console.error('Session update error:', error);
showToast('error', 'Session Update Failed', `Error: ${error.message}`);
} finally {
setIsProcessing(false);
}
};
const menuItems = [
{
label: "Schedule 1:1",
icon: "pi pi-calendar",
command: () => setCalendlyVisible(true),
},
{
label: session?.user?.platformLightningAddress ? "Update PlebDevs Lightning Address" : "Claim PlebDevs Lightning Address",
icon: "pi pi-bolt",
command: () => setLightningAddressVisible(true),
},
{
label: session?.user?.platformNip05?.name ? "Update PlebDevs Nostr NIP-05" : "Claim PlebDevs Nostr NIP-05",
icon: "pi pi-at",
command: () => setNip05Visible(true),
},
{
label: "Renew Subscription",
icon: "pi pi-sync",
command: () => setRenewSubscriptionVisible(true),
},
{
label: "Cancel Subscription",
icon: "pi pi-trash",
command: () => setCancelSubscriptionVisible(true),
},
];
return (
<div className="p-4">
{windowWidth < 768 && (
<h1 className="text-3xl font-bold mb-6">Subscription Management</h1>
)}
<div className="mb-2 p-4 bg-gray-800 rounded-lg w-fit">
{session && session?.user ? (
<>
{subscribed && !user?.role?.nwc && (
<div className="flex flex-col">
<Message className="w-fit" severity="success" text="Subscribed!" />
<p className="mt-4">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400">Pay-as-you-go subscription must be manually renewed on {subscribedUntil.toLocaleDateString()}</p>
</div>
)}
{subscribed && user?.role?.nwc && (
<div className="flex flex-col">
<Message className="w-fit" severity="success" text="Subscribed!" />
<p className="mt-4">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400">Recurring subscription will AUTO renew on {subscribedUntil.toLocaleDateString()}</p>
</div>
)}
{(!subscribed && !subscriptionExpiredAt) && (
<div className="flex flex-col">
<Message className="w-fit" severity="info" text="You currently have no active subscription" />
</div>
)}
{subscriptionExpiredAt && (
<div className="flex flex-col">
<Message className="w-fit" severity="warn" text={`Your subscription expired on ${subscriptionExpiredAt.toLocaleDateString()}`} />
</div>
)}
</>
) : (
<div className="flex flex-col">
<Message className="w-fit" severity="info" text="Login to manage your subscription" />
</div>
)}
</div>
{!session?.user && (
<>
<Card title="Start Your PlebDevs Journey" className="mb-2">
<p className='mb-4 text-xl'>
The PlebDevs subscription unlocks all paid content, grants access to our 1:1 calendar for tutoring, support, and mentorship, and grants you your own personal plebdevs.com Lightning Address and Nostr NIP-05 identity.
</p>
<p className='text-xl mb-4'>
Subscribe monthly with a pay-as-you-go option or set up an auto-recurring subscription using Nostr Wallet Connect.
</p>
</Card>
<Card title="Ready to level up?" className="mb-2">
<p className='text-xl pb-4'>Login to start your subscription!</p>
<GenericButton label="Login" onClick={() => router.push('/auth/signin')} className='text-[#f8f8ff] w-fit' rounded icon="pi pi-user" />
</Card>
</>
)}
<Card title="Subscribe to PlebDevs" className="mb-2">
{isProcessing ? (
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
<div className='w-full h-full flex items-center justify-center'><ProgressSpinner /></div>
<span className="ml-2">Processing subscription...</span>
</div>
) : (
<div className="flex flex-col">
<div className="mb-4">
<h2 className="text-2xl font-bold text-primary">Unlock Premium Benefits</h2>
<p className="text-gray-400">Subscribe now and elevate your development journey!</p>
</div>
<div className="flex flex-col gap-4 mb-4">
<div className="flex items-center">
<i className="pi pi-book text-2xl text-primary mr-2 text-blue-400"></i>
<span>Access ALL current and future PlebDevs content</span>
</div>
<div className="flex items-center">
<i className="pi pi-calendar text-2xl text-primary mr-2 text-red-400"></i>
<span>Personal mentorship & guidance and access to exclusive 1:1 booking calendar</span>
</div>
<div className="flex items-center">
<i className="pi pi-bolt text-2xl text-primary mr-2 text-yellow-500"></i>
<span>Claim your own personal plebdevs.com Lightning Address</span>
</div>
<div className="flex items-center">
<Image src={NostrIcon} alt="Nostr" width={25} height={25} className='mr-2' />
<span>Claim your own personal plebdevs.com Nostr NIP-05 identity</span>
</div>
</div>
<SubscriptionPaymentButtons
onSuccess={handleSubscriptionSuccess}
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
onError={handleSubscriptionError}
setIsProcessing={setIsProcessing}
layout={windowWidth < 768 ? "col" : "row"}
/>
</div>
)}
</Card>
{session?.user && subscribed && (
<>
<Card title="Subscription Benefits" className="mb-2">
<div className="flex flex-col gap-4">
<GenericButton severity="info" outlined className="w-fit text-start" label="Schedule 1:1" icon="pi pi-calendar" onClick={() => setCalendlyVisible(true)} />
<GenericButton severity="help" outlined className="w-fit text-start" label={session?.user?.platformNip05?.name ? "Update Nostr NIP-05" : "Claim PlebDevs Nostr NIP-05"} icon="pi pi-at" onClick={() => setNip05Visible(true)} />
<GenericButton severity="warning" outlined className="w-fit text-start" label={session?.user?.platformLightningAddress ? "Update Lightning Address" : "Claim PlebDevs Lightning Address"} icon={<i style={{ color: "orange" }} className="pi pi-bolt mr-2"></i>} onClick={() => setLightningAddressVisible(true)} />
</div>
</Card>
<Card title="Manage Subscription" className="mb-2">
<div className='flex flex-col gap-4'>
<GenericButton outlined className="w-fit" label="Renew Subscription" icon="pi pi-sync" onClick={() => setRenewSubscriptionVisible(true)} />
<GenericButton severity="danger" outlined className="w-fit" label="Cancel Subscription" icon="pi pi-trash" onClick={() => setCancelSubscriptionVisible(true)} />
</div>
</Card>
</>
)}
<Card title="Frequently Asked Questions" className="mb-2">
<div className="flex flex-col gap-4 max-w-[80%] max-mob:max-w-full">
<div>
<h3 className="text-lg font-semibold">How does the subscription work?</h3>
<p>Think of the subscriptions as a Patreon-type model. You pay a monthly fee and in return you get access to premium features and all of the paid content. You can cancel at any time.</p>
</div>
<div className='flex flex-col gap-2'>
<h3 className="text-lg font-semibold">What are the benefits of a subscription?</h3>
<p>The subscription gives you access to all of the premium features and all of the paid content. You can cancel at any time.</p>
</div>
<div className='flex flex-col gap-2'>
<h3 className="text-lg font-semibold">How much does the subscription cost?</h3>
<p>The subscription is 50,000 sats per month.</p>
</div>
<div>
<h3 className="text-lg font-semibold">How do I Subscribe? (Pay as you go)</h3>
<p>The pay as you go subscription is a one-time payment that gives you access to all of the premium features for one month. You will need to manually renew your subscription every month.</p>
</div>
<div>
<h3 className="text-lg font-semibold">How do I Subscribe? (Recurring)</h3>
<p>The recurring subscription option allows you to submit a Nostr Wallet Connect URI that will be used to automatically send the subscription fee every month. You can cancel at any time.</p>
</div>
<div>
<h3 className="text-lg font-semibold">Can I cancel my subscription?</h3>
<p>Yes, you can cancel your subscription at any time. Your access will remain active until the end of the current billing period.</p>
</div>
<div>
<h3 className="text-lg font-semibold">What happens if I don&apos;t renew my subscription?</h3>
<p>If you don&apos;t renew your subscription, your access to 1:1 calendar and paid content will be removed. However, you will still have access to your PlebDevs Lightning Address, NIP-05, and any content that you paid for.</p>
</div>
<div>
<h3 className="text-lg font-semibold">What is Nostr Wallet Connect?</h3>
<p>Nostr Wallet Connect is a Nostr-based authentication method that allows you to connect your Nostr wallet to the PlebDevs platform. This will allow you to subscribe to the platform in an auto recurring manner which still gives you full control over your wallet and the ability to cancel at any time from your wallet.</p>
</div>
</div>
</Card>
<CalendlyEmbed
visible={calendlyVisible}
onHide={() => setCalendlyVisible(false)}
userId={session?.user?.id}
userName={session?.user?.username || user?.kind0?.username}
userEmail={session?.user?.email}
/>
<CancelSubscription
visible={cancelSubscriptionVisible}
onHide={() => setCancelSubscriptionVisible(false)}
/>
<RenewSubscription
visible={renewSubscriptionVisible}
onHide={() => setRenewSubscriptionVisible(false)}
subscribedUntil={subscribedUntil}
/>
<Nip05Form
visible={nip05Visible}
onHide={() => setNip05Visible(false)}
/>
<LightningAddressForm
visible={lightningAddressVisible}
onHide={() => setLightningAddressVisible(false)}
/>
</div>
);
};
export default Subscribe;