Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 36 additions & 22 deletions src/components/bitcoinConnect/CoursePaymentButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Promo Pricing Overwrites Discount Codes

The new promo pricing logic (lines 83-87) bypasses existing discount code functionality, leading to a confusing UX where discounts appear applied but are ignored. This also removes the check for 0-satoshi discounted amounts, which could cause issues when fetching invoices if a discount would make the course free.

Fix in Cursor Fix in Web

return;
}

Expand All @@ -89,18 +98,17 @@ 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 => {
try {
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);
Expand All @@ -121,6 +129,7 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId }
if (onError) onError(error);
}
setDialogVisible(false);
setInvoice(null);
};

const handleDiscountCode = value => {
Expand Down Expand Up @@ -202,22 +211,27 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId }
)}
</div>
)}
<GenericButton
label={`${discountApplied ? calculateDiscount(amount).discountedAmount : amount} sats`}
icon="pi pi-wallet"
onClick={() => {
if (status === 'unauthenticated') {
console.log('unauthenticated');
router.push('/auth/signin');
} else {
fetchInvoice();
}
}}
disabled={isLoading}
severity="primary"
rounded
className={`text-[#f8f8ff] text-sm ${isLoading ? 'hidden' : ''}`}
/>
<div className="flex items-center gap-2">
<GenericButton
label="Free"
icon="pi pi-wallet"
onClick={() => {
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 && (
<PromoFreeBadge showLabel={false} wrapperClassName="flex items-center" />
)}
</div>
{isLoading && (
<div className="w-full h-full flex items-center justify-center">
<ProgressSpinner
Expand Down
57 changes: 39 additions & 18 deletions src/components/bitcoinConnect/ResourcePaymentButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import axios from 'axios';
import GenericButton from '@/components/buttons/GenericButton';
import useWindowWidth from '@/hooks/useWindowWidth';
import { useRouter } from 'next/router';
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,
Expand Down Expand Up @@ -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({
Expand All @@ -63,16 +77,17 @@ 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 => {
try {
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);
Expand All @@ -93,26 +108,32 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource
if (onError) onError(error);
}
setDialogVisible(false);
setInvoice(null);
};

return (
<>
<GenericButton
label={`${amount} sats`}
icon="pi pi-wallet"
onClick={() => {
if (status === 'unauthenticated') {
console.log('unauthenticated');
router.push('/auth/signin');
} else {
fetchInvoice();
}
}}
disabled={isLoading}
severity="primary"
rounded
className={`text-[#f8f8ff] text-sm ${isLoading ? 'hidden' : ''}`}
/>
<div className="flex items-center gap-2">
<GenericButton
label="Free"
icon="pi pi-wallet"
onClick={() => {
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 && (
<PromoFreeBadge showLabel={false} wrapperClassName="flex items-center" />
)}
</div>
{isLoading && (
<div className="w-full h-full flex items-center justify-center">
<ProgressSpinner
Expand Down
22 changes: 7 additions & 15 deletions src/components/content/carousels/templates/CombinedTemplate.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { formatTimestampToHowLongAgo } from '@/utils/time';
import { nip19 } from 'nostr-tools';
import { Tag } from 'primereact/tag';
import { Message } from 'primereact/message';
import PromoFreeBadge from '@/components/pricing/PromoFreeBadge';
import useWindowWidth from '@/hooks/useWindowWidth';
import GenericButton from '@/components/buttons/GenericButton';
import { PlayCircle, FileText } from 'lucide-react';
Expand Down Expand Up @@ -106,21 +107,12 @@ export function CombinedTemplate({ resource, isLesson, showMetaTags }) {
<Tag size="small" className="px-2 py-1 text-sm text-[#f8f8ff]" value="lesson" />
)}
</div>
{resource?.price && resource?.price > 0 ? (
<Message
className={`${isMobile ? 'py-1 text-xs' : 'py-2'} whitespace-nowrap`}
icon="pi pi-lock"
severity="info"
text={`${resource.price} sats`}
/>
) : (
<Message
className={`${isMobile ? 'py-1 text-xs' : 'py-2'} whitespace-nowrap`}
icon="pi pi-lock-open"
severity="success"
text="Free"
/>
)}
<Message
className={`${isMobile ? 'py-1 text-xs' : 'py-2'} whitespace-nowrap flex items-center gap-2`}
severity="success"
icon="pi pi-lock-open"
content={<PromoFreeBadge />}
/>
</CardContent>
<CardDescription
className={`${isMobile ? 'w-full p-3' : 'p-6'} py-2 pt-0 text-base text-neutral-50/90 dark:text-neutral-900/90 overflow-hidden min-h-[4em] flex items-center max-w-[100%]`}
Expand Down
27 changes: 12 additions & 15 deletions src/components/content/carousels/templates/CourseTemplate.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useRouter } from 'next/router';
import { formatTimestampToHowLongAgo } from '@/utils/time';
import { ProgressSpinner } from 'primereact/progressspinner';
import { Message } from 'primereact/message';
import PromoFreeBadge from '@/components/pricing/PromoFreeBadge';
import useWindowWidth from '@/hooks/useWindowWidth';
import GenericButton from '@/components/buttons/GenericButton';
import appConfig from '@/config/appConfig';
Expand Down Expand Up @@ -126,21 +127,17 @@ export function CourseTemplate({ course, showMetaTags = true }) {
)
)}
</div>
{course?.price && course?.price > 0 ? (
<Message
className={`${isMobile ? 'py-1 text-xs' : 'py-2'} whitespace-nowrap`}
icon="pi pi-lock"
severity="info"
text={`${course.price} sats`}
/>
) : (
<Message
className={`${isMobile ? 'py-1 text-xs' : 'py-2'} whitespace-nowrap`}
icon="pi pi-lock-open"
severity="success"
text="Free"
/>
)}
<Message
className={`${isMobile ? 'py-1 text-xs' : 'py-2'} whitespace-nowrap flex items-center gap-2`}
severity="success"
icon="pi pi-lock-open"
content={
<PromoFreeBadge
iconClassName="pi pi-question-circle text-xs text-green-300"
labelClassName="font-semibold text-green-400"
/>
}
/>
</CardContent>
<CardDescription
className={`${
Expand Down
27 changes: 12 additions & 15 deletions src/components/content/carousels/templates/DocumentTemplate.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { formatTimestampToHowLongAgo } from '@/utils/time';
import { nip19 } from 'nostr-tools';
import { Tag } from 'primereact/tag';
import { Message } from 'primereact/message';
import PromoFreeBadge from '@/components/pricing/PromoFreeBadge';
import useWindowWidth from '@/hooks/useWindowWidth';
import GenericButton from '@/components/buttons/GenericButton';
import { FileText } from 'lucide-react';
Expand Down Expand Up @@ -105,21 +106,17 @@ export function DocumentTemplate({ document, isLesson, showMetaTags }) {
<Tag size="small" className="px-2 py-1 text-sm text-[#f8f8ff]" value="lesson" />
)}
</div>
{document?.price && document?.price > 0 ? (
<Message
className={`${isMobile ? 'py-1 text-xs' : 'py-2'} whitespace-nowrap`}
icon="pi pi-lock"
severity="info"
text={`${document.price} sats`}
/>
) : (
<Message
className={`${isMobile ? 'py-1 text-xs' : 'py-2'} whitespace-nowrap`}
icon="pi pi-lock-open"
severity="success"
text="Free"
/>
)}
<Message
className={`${isMobile ? 'py-1 text-xs' : 'py-2'} whitespace-nowrap flex items-center gap-2`}
severity="success"
icon="pi pi-lock-open"
content={
<PromoFreeBadge
iconClassName="pi pi-question-circle text-xs text-green-300"
labelClassName="font-semibold text-green-400"
/>
}
/>
</CardContent>
<CardDescription
className={`${isMobile ? 'w-full p-3' : 'p-6'} py-2 pt-0 text-base text-neutral-50/90 dark:text-neutral-900/90 overflow-hidden min-h-[4em] flex items-center max-w-[100%]`}
Expand Down
27 changes: 12 additions & 15 deletions src/components/content/carousels/templates/VideoTemplate.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { formatTimestampToHowLongAgo } from '@/utils/time';
import { nip19 } from 'nostr-tools';
import { Tag } from 'primereact/tag';
import { Message } from 'primereact/message';
import PromoFreeBadge from '@/components/pricing/PromoFreeBadge';
import useWindowWidth from '@/hooks/useWindowWidth';
import GenericButton from '@/components/buttons/GenericButton';

Expand Down Expand Up @@ -105,21 +106,17 @@ export function VideoTemplate({ video, isLesson, showMetaTags }) {
<Tag size="small" className="px-2 py-1 text-sm text-[#f8f8ff]" value="lesson" />
)}
</div>
{video?.price && video?.price > 0 ? (
<Message
className={`${isMobile ? 'py-1 text-xs' : 'py-2'} whitespace-nowrap`}
icon="pi pi-lock"
severity="info"
text={`${video.price} sats`}
/>
) : (
<Message
className={`${isMobile ? 'py-1 text-xs' : 'py-2'} whitespace-nowrap`}
icon="pi pi-lock-open"
severity="success"
text="Free"
/>
)}
<Message
className={`${isMobile ? 'py-1 text-xs' : 'py-2'} whitespace-nowrap flex items-center gap-2`}
severity="success"
icon="pi pi-lock-open"
content={
<PromoFreeBadge
iconClassName="pi pi-question-circle text-xs text-green-300"
labelClassName="font-semibold text-green-400"
/>
}
/>
</CardContent>

<CardDescription
Expand Down
Loading