From f8af4242b4b7ee1a8f89f56892875fc80e1a2ecc Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Wed, 24 Sep 2025 09:15:14 -0500 Subject: [PATCH] messages for platform winding down, updates to payments buttons to support free content for all content but still track payments --- AGENTS.md | 19 ++++ .../bitcoinConnect/CoursePaymentButton.js | 58 ++++++---- .../bitcoinConnect/ResourcePaymentButton.js | 57 +++++++--- .../carousels/templates/CombinedTemplate.js | 22 ++-- .../carousels/templates/CourseTemplate.js | 27 ++--- .../carousels/templates/DocumentTemplate.js | 27 ++--- .../carousels/templates/VideoTemplate.js | 27 ++--- .../content/combined/CombinedDetails.js | 66 +++++------ .../content/courses/details/CourseDetails.js | 38 +++---- .../courses/details/DesktopCourseDetails.js | 9 +- .../courses/details/MobileCourseDetails.js | 9 +- .../content/documents/DocumentDetails.js | 64 +++++------ .../content/dropdowns/ContentDropdownItem.js | 20 ++-- src/components/content/videos/VideoDetails.js | 64 +++++------ src/components/pricing/PromoFreeBadge.js | 44 ++++++++ src/config/appConfig.js | 3 +- src/constants/promoPricing.js | 4 + src/pages/api/purchase/resource.js | 10 +- src/pages/index.js | 103 ++++++++++++++++++ 19 files changed, 421 insertions(+), 250 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/components/pricing/PromoFreeBadge.js create mode 100644 src/constants/promoPricing.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9ad66124 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Repository Guidelines + +## Project Structure & Module Organization +The Next.js app lives in `src/`, with page routes in `src/pages`, reusable UI in `src/components`, hooks in `src/hooks`, shared helpers in `src/utils`, and server-facing logic under `src/lib` and `src/db`. Context providers, constants, and config live in `src/context`, `src/constants`, and `src/config`. Styling combines Tailwind layers and globals in `src/styles`, while static files sit in `public/`. Database schemas and migrations are managed via `prisma/schema.prisma` and `prisma/migrations/`. Local infrastructure relies on `docker-compose.yml` and the project `Dockerfile` for Postgres-backed workflows. + +## Build, Test, and Development Commands +Install dependencies once with `npm install`. Use `npm run dev` for the hot-reloading Next.js server, or `docker compose up --build` when the Postgres service is required. Apply schema updates with `npx prisma migrate dev` and regenerate clients via `npx prisma generate` (also run automatically on `postinstall`). Before shipping, execute `npm run build` followed by `npm run start` to verify the production bundle. Guard code quality with `npm run lint`, and auto-fix common issues using `npm run lint:fix`. + +## Coding Style & Naming Conventions +Prettier enforces two-space indentation, single quotes, trailing commas (ES5), and 100-character lines; run it before committing. Favor functional React components with PascalCase filenames such as `src/components/ProfileCard.tsx`. Keep hooks prefixed with `use`, colocate utility modules near their feature, and import shared modules with the `@/` path alias defined in `jsconfig.json`. + +## Testing Guidelines +A formal automated test suite is not yet established. Treat linting and targeted manual verification as the baseline, and capture edge cases in your PR description. When adding tests, colocate them beside the feature as `feature.test.ts(x)` files or place them in a nearby `__tests__/` folder. Always rerun `npm run lint` and any affected flows locally before requesting review. + +## Commit & Pull Request Guidelines +Write concise, imperative commit subjects (e.g., `Add wallet connect modal`) and group related changes together. Pull requests should restate the problem, highlight key updates, link relevant issues, and include screenshots or short clips for UI adjustments. Confirm that `npm run lint`, schema migrations, and regeneration steps have been executed, and call out required environment variables such as `.env.local` entries. + +## Environment & Security Notes +Request secrets from maintainers rather than reusing staging values. Never commit credentials or Prisma client artifacts unless schema changes demand it. Mask sensitive values—especially `POSTGRES_PRISMA_URL` and `POSTGRES_URL_NON_POOLING`—in logs and PR discussions, and review `.github/workflows/` when introducing automation to keep credentials scoped. diff --git a/src/components/bitcoinConnect/CoursePaymentButton.js b/src/components/bitcoinConnect/CoursePaymentButton.js index 98cb7320..faed5acd 100644 --- a/src/components/bitcoinConnect/CoursePaymentButton.js +++ b/src/components/bitcoinConnect/CoursePaymentButton.js @@ -11,6 +11,8 @@ import GenericButton from '@/components/buttons/GenericButton'; import { useRouter } from 'next/router'; import useWindowWidth from '@/hooks/useWindowWidth'; import { InputText } from 'primereact/inputtext'; +import PromoFreeBadge from '@/components/pricing/PromoFreeBadge'; +import { PROMO_FREE_PRICE_SATS, PROMO_PRICING_MESSAGE } from '@/constants/promoPricing'; const Payment = dynamic(() => import('@getalby/bitcoin-connect-react').then(mod => mod.Payment), { ssr: false, @@ -72,8 +74,15 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId } const fetchInvoice = async () => { setIsLoading(true); try { - if (discountApplied && calculateDiscount(amount).discountedAmount === 0) { - handlePaymentSuccess({ paid: true, preimage: 'course_pass' }); + if (!session?.user?.id) { + showToast('warn', 'Sign In Required', 'Please sign in to unlock this course.'); + router.push('/auth/signin'); + return; + } + + if (PROMO_FREE_PRICE_SATS === 0) { + await handlePaymentSuccess({ paid: true, preimage: 'promo-free-course' }); + showToast('success', 'Free Course Access', PROMO_PRICING_MESSAGE); return; } @@ -89,8 +98,9 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId } console.error('Error fetching invoice:', error); showToast('error', 'Invoice Error', 'Failed to fetch the invoice.'); if (onError) onError(error); + } finally { + setIsLoading(false); } - setIsLoading(false); }; const handlePaymentSuccess = async response => { @@ -98,9 +108,7 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId } const purchaseData = { userId: session.user.id, courseId: courseId, - amountPaid: discountApplied - ? calculateDiscount(amount).discountedAmount - : parseInt(amount, 10), + amountPaid: PROMO_FREE_PRICE_SATS, }; const result = await axios.post('/api/purchase/course', purchaseData); @@ -121,6 +129,7 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId } if (onError) onError(error); } setDialogVisible(false); + setInvoice(null); }; const handleDiscountCode = value => { @@ -202,22 +211,27 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId } )} )} - { - if (status === 'unauthenticated') { - console.log('unauthenticated'); - router.push('/auth/signin'); - } else { - fetchInvoice(); - } - }} - disabled={isLoading} - severity="primary" - rounded - className={`text-[#f8f8ff] text-sm ${isLoading ? 'hidden' : ''}`} - /> +
+ { + if (status === 'unauthenticated') { + console.log('unauthenticated'); + router.push('/auth/signin'); + } else { + fetchInvoice(); + } + }} + disabled={isLoading} + severity="primary" + rounded + className={`text-[#f8f8ff] text-sm ${isLoading ? 'hidden' : ''}`} + /> + {!isLoading && ( + + )} +
{isLoading && (
import('@getalby/bitcoin-connect-react').then(mod => mod.Payment), { ssr: false, @@ -51,6 +53,18 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource const fetchInvoice = async () => { setIsLoading(true); try { + if (!session?.user?.id) { + showToast('warn', 'Sign In Required', 'Please sign in to unlock this content.'); + router.push('/auth/signin'); + return; + } + + if (PROMO_FREE_PRICE_SATS === 0) { + await handlePaymentSuccess({ paid: true, preimage: 'promo-free-resource' }); + showToast('success', 'Free Access Granted', PROMO_PRICING_MESSAGE); + return; + } + const ln = new LightningAddress(lnAddress); await ln.fetch(); const invoice = await ln.requestInvoice({ @@ -63,8 +77,9 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource console.error('Error fetching invoice:', error); showToast('error', 'Invoice Error', 'Failed to fetch the invoice.'); if (onError) onError(error); + } finally { + setIsLoading(false); } - setIsLoading(false); }; const handlePaymentSuccess = async response => { @@ -72,7 +87,7 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource const purchaseData = { userId: session.user.id, resourceId: resourceId, - amountPaid: parseInt(amount, 10), + amountPaid: PROMO_FREE_PRICE_SATS, }; const result = await axios.post('/api/purchase/resource', purchaseData); @@ -93,26 +108,32 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource if (onError) onError(error); } setDialogVisible(false); + setInvoice(null); }; return ( <> - { - if (status === 'unauthenticated') { - console.log('unauthenticated'); - router.push('/auth/signin'); - } else { - fetchInvoice(); - } - }} - disabled={isLoading} - severity="primary" - rounded - className={`text-[#f8f8ff] text-sm ${isLoading ? 'hidden' : ''}`} - /> +
+ { + if (status === 'unauthenticated') { + console.log('unauthenticated'); + router.push('/auth/signin'); + } else { + fetchInvoice(); + } + }} + disabled={isLoading} + severity="primary" + rounded + className={`text-[#f8f8ff] text-sm ${isLoading ? 'hidden' : ''}`} + /> + {!isLoading && ( + + )} +
{isLoading && (
)}
- {resource?.price && resource?.price > 0 ? ( - - ) : ( - - )} + } + /> - {course?.price && course?.price > 0 ? ( - - ) : ( - - )} + + } + /> )}
- {document?.price && document?.price > 0 ? ( - - ) : ( - - )} + + } + /> )} - {video?.price && video?.price > 0 ? ( - - ) : ( - - )} + + } + /> purchase.courseId === course) ) { - const coursePurchase = session?.user?.purchased?.find( - purchase => purchase.courseId === course - ); return ( - +
+ + +
); } @@ -193,29 +189,25 @@ const CombinedDetails = ({ !session?.user?.role?.subscribed ) { return ( - +
+ + +
); } if (paidResource && author && processedEvent?.pubkey === session?.user?.pubkey) { return ( - +
+ + +
); } @@ -232,15 +224,13 @@ const CombinedDetails = ({
- +
-

- This content is paid and needs to be purchased before viewing. -

+

{PROMO_PRICING_MESSAGE}

- +
+ + +
); } if (paidCourse && author && processedEvent?.pubkey === session?.user?.pubkey) { return ( - +
+ + +
); } diff --git a/src/components/content/courses/details/DesktopCourseDetails.js b/src/components/content/courses/details/DesktopCourseDetails.js index 82fd8372..3b2dd8ce 100644 --- a/src/components/content/courses/details/DesktopCourseDetails.js +++ b/src/components/content/courses/details/DesktopCourseDetails.js @@ -5,6 +5,7 @@ import ZapDisplay from '@/components/zaps/ZapDisplay'; import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu'; import { Divider } from 'primereact/divider'; import GenericButton from '@/components/buttons/GenericButton'; +import PromoFreeBadge from '@/components/pricing/PromoFreeBadge'; export default function DesktopCourseDetails({ processedEvent, @@ -161,7 +162,11 @@ export default function DesktopCourseDetails({ {paidCourse && (

Price

-

{processedEvent.price} sats

+
)}
@@ -195,4 +200,4 @@ export default function DesktopCourseDetails({
); -} \ No newline at end of file +} diff --git a/src/components/content/courses/details/MobileCourseDetails.js b/src/components/content/courses/details/MobileCourseDetails.js index 37469695..6c43c3d9 100644 --- a/src/components/content/courses/details/MobileCourseDetails.js +++ b/src/components/content/courses/details/MobileCourseDetails.js @@ -5,6 +5,7 @@ import ZapDisplay from '@/components/zaps/ZapDisplay'; import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu'; import { Divider } from 'primereact/divider'; import GenericButton from '@/components/buttons/GenericButton'; +import PromoFreeBadge from '@/components/pricing/PromoFreeBadge'; export default function MobileCourseDetails({ processedEvent, @@ -148,7 +149,11 @@ export default function MobileCourseDetails({ {paidCourse && (

Price

-

{processedEvent.price} sats

+
)}
@@ -183,4 +188,4 @@ export default function MobileCourseDetails({ ); -} \ No newline at end of file +} diff --git a/src/components/content/documents/DocumentDetails.js b/src/components/content/documents/DocumentDetails.js index dbe71f24..22f2413c 100644 --- a/src/components/content/documents/DocumentDetails.js +++ b/src/components/content/documents/DocumentDetails.js @@ -18,6 +18,8 @@ import ZapThreadsWrapper from '@/components/ZapThreadsWrapper'; import appConfig from '@/config/appConfig'; import { nip19 } from 'nostr-tools'; import MarkdownDisplay from '@/components/markdown/MarkdownDisplay'; +import PromoFreeBadge from '@/components/pricing/PromoFreeBadge'; +import { PROMO_PRICING_MESSAGE } from '@/constants/promoPricing'; const DocumentDetails = ({ processedEvent, @@ -174,18 +176,14 @@ const DocumentDetails = ({ session?.user?.purchased?.some(purchase => purchase.courseId === course) ) { return ( - purchase.courseId === course)?.course?.price - } sats for the course.`} - icon="pi pi-check" - label={`Paid`} - severity="success" - outlined - size="small" - className="cursor-default hover:opacity-100 hover:bg-transparent focus:ring-0" - /> +
+ + +
); } @@ -197,31 +195,27 @@ const DocumentDetails = ({ !session?.user?.role?.subscribed ) { return ( - +
+ + +
); } if (paidResource && author && processedEvent?.pubkey === session?.user?.pubkey) { return ( - +
+ + +
); } @@ -237,11 +231,9 @@ const DocumentDetails = ({
- +
-

- This content is paid and needs to be purchased before viewing. -

+

{PROMO_PRICING_MESSAGE}

{ : (content?.title || content?.name)} - {content?.price > 0 ? ( - - ) : ( - - )} + } + />
{content?.summary && ( diff --git a/src/components/content/videos/VideoDetails.js b/src/components/content/videos/VideoDetails.js index 957bb167..b8e36438 100644 --- a/src/components/content/videos/VideoDetails.js +++ b/src/components/content/videos/VideoDetails.js @@ -19,6 +19,8 @@ import appConfig from '@/config/appConfig'; import { nip19 } from 'nostr-tools'; import { Buffer } from 'buffer'; import MarkdownDisplay from '@/components/markdown/MarkdownDisplay'; +import PromoFreeBadge from '@/components/pricing/PromoFreeBadge'; +import { PROMO_PRICING_MESSAGE } from '@/constants/promoPricing'; const VideoDetails = ({ processedEvent, @@ -174,20 +176,14 @@ const VideoDetails = ({ session?.user?.purchased?.some(purchase => purchase.courseId === course) ) { return ( - purchase.courseId === course)?.course?.price - } sats for the course.`} - icon="pi pi-check" - label={`Paid ${ - session?.user?.purchased?.find(purchase => purchase.courseId === course)?.course?.price - } sats`} - severity="success" - outlined - size="small" - className="cursor-default hover:opacity-100 hover:bg-transparent focus:ring-0" - /> +
+ + +
); } @@ -199,29 +195,27 @@ const VideoDetails = ({ !session?.user?.role?.subscribed ) { return ( - +
+ + +
); } if (paidResource && author && processedEvent?.pubkey === session?.user?.pubkey) { return ( - +
+ + +
); } @@ -245,11 +239,9 @@ const VideoDetails = ({ >
- +
-

- This content is paid and needs to be purchased before viewing. -

+

{PROMO_PRICING_MESSAGE}

id.replace(/[^a-zA-Z0-9_-]/g, ''); + +const PromoFreeBadge = ({ + label = 'Free', + showLabel = true, + labelClassName = 'font-semibold text-green-400', + iconClassName = 'pi pi-question-circle text-xs text-sky-300', + wrapperClassName = 'flex items-center gap-1', + tooltipPosition = PROMO_TOOLTIP_POSITION, + showPriceValue = false, + price = PROMO_FREE_PRICE_SATS, +}) => { + const rawId = useId(); + const tooltipId = `promo-free-${sanitizeId(rawId)}`; + const windowWidth = useWindowWidth(); + const isMobile = windowWidth < 768; + + return ( + + {showLabel && {label}} + {showPriceValue && ( + {price} sats + )} + + {!isMobile && } + + ); +}; + +export default PromoFreeBadge; diff --git a/src/config/appConfig.js b/src/config/appConfig.js index a8325baf..4363377a 100644 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -11,7 +11,8 @@ const appConfig = { ], authorPubkeys: [ 'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741', - 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345' + 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345', + '6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4' ], customLightningAddresses: [ { diff --git a/src/constants/promoPricing.js b/src/constants/promoPricing.js new file mode 100644 index 00000000..fd8ed232 --- /dev/null +++ b/src/constants/promoPricing.js @@ -0,0 +1,4 @@ +export const PROMO_FREE_PRICE_SATS = 0; +export const PROMO_PRICING_MESSAGE = + 'All content is free for a limited time while we finish building out PlebDevs Platform V2. Keep an eye out, coming soon (tm)!'; +export const PROMO_TOOLTIP_POSITION = 'top'; diff --git a/src/pages/api/purchase/resource.js b/src/pages/api/purchase/resource.js index cc9ac973..6e514139 100644 --- a/src/pages/api/purchase/resource.js +++ b/src/pages/api/purchase/resource.js @@ -13,13 +13,19 @@ export default async function handler(req, res) { try { const { userId, resourceId, amountPaid } = req.body; - if (!userId || !resourceId || !amountPaid) { + if (!userId || !resourceId || amountPaid === undefined || amountPaid === null) { return res.status(400).json({ error: 'Missing required fields' }); } + const parsedAmount = parseInt(amountPaid, 10); + + if (Number.isNaN(parsedAmount)) { + return res.status(400).json({ error: 'Invalid amount' }); + } + const updatedUser = await addResourcePurchaseToUser(userId, { resourceId, - amountPaid: parseInt(amountPaid, 10), + amountPaid: parsedAmount, }); res.status(200).json(updatedUser); diff --git a/src/pages/index.js b/src/pages/index.js index bf5c62f7..dffae0c2 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -8,6 +8,8 @@ import { useDocuments } from '@/hooks/nostr/useDocuments'; import { useVideos } from '@/hooks/nostr/useVideos'; import { useCourses } from '@/hooks/nostr/useCourses'; import { TabMenu } from 'primereact/tabmenu'; +import { Message } from 'primereact/message'; +import { Badge } from 'primereact/badge'; import 'primeicons/primeicons.css'; import GenericButton from '@/components/buttons/GenericButton'; import { useRouter } from 'next/router'; @@ -104,6 +106,8 @@ export default function Home() { const [allContent, setAllContent] = useState([]); const [allTopics, setAllTopics] = useState([]); const [selectedTopic, setSelectedTopic] = useState('Top'); + const bannerMessage = + 'All content is free for a limited time while we finish building out PlebDevs Platform V2. Keep an eye out, coming soon (tm)!'; useEffect(() => { if (documents && !documentsLoading) { @@ -183,6 +187,23 @@ export default function Home() {
+
+ + + {bannerMessage} + + } + /> +
+ ); }