diff --git a/.env.example b/.env.example index f3f42634..e9e868f0 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,6 @@ YANDEX_CLIENT_ID= YANDEX_CLIENT_SECRET= # AI secrets -OPEN_AI_ORG= OPEN_AI_TOKEN= # Databases diff --git a/actions/auth/get-current-user.ts b/actions/auth/get-current-user.ts index 4d3bd1a8..be8aa39b 100644 --- a/actions/auth/get-current-user.ts +++ b/actions/auth/get-current-user.ts @@ -2,7 +2,7 @@ import { getServerSession } from 'next-auth/next'; -import { authOptions } from '@/lib/auth'; +import { authOptions } from '@/lib/auth/auth'; export const getCurrentUser = async () => { const session = await getServerSession(authOptions); diff --git a/actions/auth/get-updated-user.ts b/actions/auth/get-updated-user.ts new file mode 100644 index 00000000..f4e52da8 --- /dev/null +++ b/actions/auth/get-updated-user.ts @@ -0,0 +1,22 @@ +'use server'; + +import { TEN_MINUTE_SEC } from '@/constants/common'; +import { fetchCachedData } from '@/lib/cache'; +import { db } from '@/lib/db'; + +export const getUpdatedUser = async (userId = '') => { + const updatedUser = await fetchCachedData( + `updated-user-[${userId}]`, + async () => { + const updatedUser = await db.user.findUnique({ + where: { id: userId }, + select: { role: true }, + }); + + return { role: updatedUser?.role }; + }, + TEN_MINUTE_SEC, + ); + + return updatedUser; +}; diff --git a/actions/auth/login-user.ts b/actions/auth/login-user.ts index e6dc08d4..60d8a335 100644 --- a/actions/auth/login-user.ts +++ b/actions/auth/login-user.ts @@ -10,10 +10,14 @@ export const loginUser = async ( name?: string | null, pictureUrl?: string | null, ) => { - const existingUser = await db.user.findUnique({ where: { email } }); + const existingUser = await db.user.findUnique({ + where: { email }, + include: { stripeSubscription: true }, + }); if (existingUser) { return { + hasSubscription: Boolean(existingUser.stripeSubscription), id: existingUser.id, image: existingUser.pictureUrl, isPublic: existingUser.isPublic, @@ -80,6 +84,7 @@ export const loginUser = async ( }); return { + hasSubscription: false, id: user.id, image: user.pictureUrl, isPublic: user.isPublic, diff --git a/actions/stripe/get-user-subscription.ts b/actions/stripe/get-user-subscription.ts new file mode 100644 index 00000000..1a8d9383 --- /dev/null +++ b/actions/stripe/get-user-subscription.ts @@ -0,0 +1,70 @@ +'use server'; + +import { StripeSubscriptionPeriod } from '@prisma/client'; +import { compareAsc, fromUnixTime } from 'date-fns'; + +import { ONE_DAY_SEC } from '@/constants/common'; +import { fetchCachedData } from '@/lib/cache'; +import { db } from '@/lib/db'; +import { stripe } from '@/server/stripe'; + +export const getUserSubscription = async (userId = '', noCache = false) => { + try { + const callback = async () => { + const userSubscription = await db.stripeSubscription.findUnique({ + where: { userId }, + }); + + if (!userSubscription) { + return null; + } + + const stripeSubscription = await stripe.subscriptions.retrieve( + userSubscription.stripeSubscriptionId, + ); + + if (!stripeSubscription) { + return null; + } + + if ( + stripeSubscription.cancel_at && + compareAsc(fromUnixTime(stripeSubscription.cancel_at), Date.now()) < 0 + ) { + await db.stripeSubscription.delete({ + where: { stripeSubscriptionId: stripeSubscription.id }, + }); + + return null; + } + + const planDescription = await db.stripeSubscriptionDescription.findFirst({ + where: { + period: `${stripeSubscription.items.data[0].plan.interval}ly` as StripeSubscriptionPeriod, + }, + }); + + return { + cancelAt: stripeSubscription.cancel_at ? fromUnixTime(stripeSubscription.cancel_at) : null, + endPeriod: fromUnixTime(stripeSubscription.current_period_end), + price: { + currency: stripeSubscription.items.data[0].price.currency, + unitAmount: stripeSubscription.items.data[0].price.unit_amount, + }, + plan: stripeSubscription.items.data[0].plan, + planName: planDescription?.name ?? 'Nova Plus', + startPeriod: fromUnixTime(stripeSubscription.current_period_start), + }; + }; + + const subscription = noCache + ? await callback() + : await fetchCachedData(`user-subscription-[${userId}]`, callback, ONE_DAY_SEC); + + return subscription; + } catch (error) { + console.error('[GET_USER_SUBSCRIPTION]', error); + + return null; + } +}; diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx index bdb0334f..0a0c5dcc 100644 --- a/app/(chat)/layout.tsx +++ b/app/(chat)/layout.tsx @@ -5,7 +5,6 @@ import { getCurrentUser } from '@/actions/auth/get-current-user'; import { getGlobalProgress } from '@/actions/courses/get-global-progress'; import { getUserNotifications } from '@/actions/users/get-user-notifications'; import { NavBar } from '@/components/navbar/navbar'; -import { UserRole } from '@/constants/auth'; export const metadata: Metadata = { title: 'Chat AI', @@ -24,7 +23,7 @@ const ChatLayout = async ({ children }: ChatLayoutProps) => { take: 5, }); - if (![UserRole.ADMIN, UserRole.TEACHER].includes(user?.role as UserRole)) { + if (!user?.hasSubscription) { return redirect('/'); } diff --git a/app/(dashboard)/(routes)/landing-course/[courseId]/page.tsx b/app/(dashboard)/(routes)/landing-course/[courseId]/page.tsx index e6a51b16..1de5e2ea 100644 --- a/app/(dashboard)/(routes)/landing-course/[courseId]/page.tsx +++ b/app/(dashboard)/(routes)/landing-course/[courseId]/page.tsx @@ -118,8 +118,6 @@ const LandingCourseIdPage = async ({ params }: LandingCourseIdPageProps) => { )} - {/* TODO: External recourses. [https://trello.com/c/R4RkoqmC/13-add-external-resources-for-landing-course-page] */} - {/*
*/} diff --git a/app/(dashboard)/(routes)/leaderboard/_components/leaders-table.tsx b/app/(dashboard)/(routes)/leaderboard/_components/leaders-table.tsx index 5bd89df1..846fba30 100644 --- a/app/(dashboard)/(routes)/leaderboard/_components/leaders-table.tsx +++ b/app/(dashboard)/(routes)/leaderboard/_components/leaders-table.tsx @@ -22,10 +22,11 @@ type LeadersTableProps = { userId: string; xp: number; }[]; + hasSubscription?: boolean; userId?: string; }; -export const LeadersTable = ({ leaders, userId }: LeadersTableProps) => { +export const LeadersTable = ({ leaders, hasSubscription = false, userId }: LeadersTableProps) => { const filteredLeaders = leaders.filter((leader) => leader.userId !== userId); const currentLeader = leaders.find((leader) => leader.userId === userId); @@ -54,11 +55,12 @@ export const LeadersTable = ({ leaders, userId }: LeadersTableProps) => {

{leader.name}

{leader.userId === userId && } + {hasSubscription && }
- + ))} diff --git a/app/(dashboard)/(routes)/leaderboard/page.tsx b/app/(dashboard)/(routes)/leaderboard/page.tsx index d86d9c57..434602ca 100644 --- a/app/(dashboard)/(routes)/leaderboard/page.tsx +++ b/app/(dashboard)/(routes)/leaderboard/page.tsx @@ -19,7 +19,11 @@ const LeaderBoard = async () => { return ( }>
- +
); diff --git a/app/(dashboard)/(routes)/settings/billing/_components/active-plan.tsx b/app/(dashboard)/(routes)/settings/billing/_components/active-plan.tsx new file mode 100644 index 00000000..1b52bfc2 --- /dev/null +++ b/app/(dashboard)/(routes)/settings/billing/_components/active-plan.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { format } from 'date-fns'; +import { usePathname } from 'next/navigation'; +import { useState } from 'react'; +import toast from 'react-hot-toast'; + +import { getUserSubscription } from '@/actions/stripe/get-user-subscription'; +import { Banner } from '@/components/common/banner'; +import { Button, Card, CardContent } from '@/components/ui'; +import { TIMESTAMP_SUBSCRIPTION_TEMPLATE } from '@/constants/common'; +import { fetcher } from '@/lib/fetcher'; + +type ActivePlanProps = { + userSubscription: Awaited>; +}; + +export const ActivePlan = ({ userSubscription }: ActivePlanProps) => { + const pathname = usePathname(); + + const [isFetching, setIsFetching] = useState(false); + + const handleManageSubscription = async () => { + setIsFetching(true); + + await toast.promise( + fetcher.post('/api/payments/subscription', { + body: { + returnUrl: pathname, + }, + responseType: 'json', + }), + { + loading: 'Subscription processing...', + success: (data) => { + setIsFetching(false); + + window.location.assign(data.url); + + return 'Checkout'; + }, + error: () => { + setIsFetching(false); + + return 'Something went wrong'; + }, + }, + ); + }; + return ( +
+

Active Plan

+ {userSubscription?.cancelAt && ( + + )} + {userSubscription && ( + + +
+

{userSubscription.planName}

+

+ Your subscription will be {userSubscription?.cancelAt ? 'cancelled' : 'renewed'} on{' '} + {format(userSubscription.endPeriod, TIMESTAMP_SUBSCRIPTION_TEMPLATE)} +

+
+ +
+
+ )} + {!userSubscription && ( +
+

You do not have any active subscriptions.

+
+ )} +
+ ); +}; diff --git a/app/(dashboard)/(routes)/settings/billing/_components/billing-history.tsx b/app/(dashboard)/(routes)/settings/billing/_components/billing-history.tsx new file mode 100644 index 00000000..cba64f61 --- /dev/null +++ b/app/(dashboard)/(routes)/settings/billing/_components/billing-history.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { getUserBilling } from '@/actions/stripe/get-user-billing'; +import { DataTable } from '@/components/data-table/data-table'; + +import { columns } from './data-table/columns'; + +type BillingHistoryProps = { + userBilling: Awaited>; +}; + +export const BillingHistory = ({ userBilling }: BillingHistoryProps) => { + return ( +
+

Billing History

+ +
+ ); +}; diff --git a/app/(dashboard)/(routes)/settings/billing/page.tsx b/app/(dashboard)/(routes)/settings/billing/page.tsx index abf83249..e013ddea 100644 --- a/app/(dashboard)/(routes)/settings/billing/page.tsx +++ b/app/(dashboard)/(routes)/settings/billing/page.tsx @@ -1,23 +1,21 @@ import { getCurrentUser } from '@/actions/auth/get-current-user'; import { getUserBilling } from '@/actions/stripe/get-user-billing'; -import { DataTable } from '@/components/data-table/data-table'; +import { getUserSubscription } from '@/actions/stripe/get-user-subscription'; -import { columns } from './_components/data-table/columns'; +import { ActivePlan } from './_components/active-plan'; +import { BillingHistory } from './_components/billing-history'; const BillingPage = async () => { const user = await getCurrentUser(); const userBilling = await getUserBilling(user?.userId); + const userSubscription = await getUserSubscription(user?.userId, true); return (
-

Billing History

+

Billing & Subscription

- + +
); diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 5d00646b..3f27aabe 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,6 @@ import NextAuth from 'next-auth'; -import { authOptions } from '@/lib/auth'; +import { authOptions } from '@/lib/auth/auth'; const handler = NextAuth(authOptions); diff --git a/app/api/courses/[courseId]/checkout/route.ts b/app/api/courses/[courseId]/checkout/route.ts index 7ba716a3..c82d0160 100644 --- a/app/api/courses/[courseId]/checkout/route.ts +++ b/app/api/courses/[courseId]/checkout/route.ts @@ -5,6 +5,7 @@ import Stripe from 'stripe'; import { getCurrentUser } from '@/actions/auth/get-current-user'; import { db } from '@/lib/db'; +import { absoluteUrl } from '@/lib/utils'; import { stripe } from '@/server/stripe'; export const POST = async (req: NextRequest, { params }: { params: { courseId: string } }) => { @@ -64,7 +65,7 @@ export const POST = async (req: NextRequest, { params }: { params: { courseId: s const session = await stripe.checkout.sessions.create({ allow_promotion_codes: true, - cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/landing-course/${course.id}?canceled=true`, + cancel_url: absoluteUrl(`/landing-course/${course.id}?canceled=true`), customer: stripeCustomer.stripeCustomerId, expires_at: getUnixTime(addSeconds(Date.now(), 3600)), payment_method_types: ['card'], @@ -73,7 +74,7 @@ export const POST = async (req: NextRequest, { params }: { params: { courseId: s }, line_items: lineItems, mode: 'payment', - success_url: `${process.env.NEXT_PUBLIC_APP_URL}/courses/${course.id}?success=true`, + success_url: absoluteUrl(`/courses/${course.id}?success=true`), metadata: { ...details, courseId: course.id, diff --git a/app/api/payments/stripe-connect/[userId]/create/route.ts b/app/api/payments/stripe-connect/[userId]/create/route.ts index dd07ed96..ed00b53b 100644 --- a/app/api/payments/stripe-connect/[userId]/create/route.ts +++ b/app/api/payments/stripe-connect/[userId]/create/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCurrentUser } from '@/actions/auth/get-current-user'; import { DEFAULT_COUNTRY_CODE } from '@/constants/locale'; import { db } from '@/lib/db'; +import { absoluteUrl } from '@/lib/utils'; import { stripe } from '@/server/stripe'; export const POST = async (_: NextRequest, { params }: { params: { userId: string } }) => { @@ -53,8 +54,8 @@ export const POST = async (_: NextRequest, { params }: { params: { userId: strin const connectAccountLink = await stripe.accountLinks.create({ account: connectAccountId, - refresh_url: `${process.env.NEXT_PUBLIC_APP_URL}/teacher/analytics`, - return_url: `${process.env.NEXT_PUBLIC_APP_URL}/teacher/analytics`, + refresh_url: absoluteUrl('/teacher/analytics'), + return_url: absoluteUrl('/teacher/analytics'), type: 'account_onboarding', }); diff --git a/app/api/payments/subscription/route.ts b/app/api/payments/subscription/route.ts new file mode 100644 index 00000000..0c04753b --- /dev/null +++ b/app/api/payments/subscription/route.ts @@ -0,0 +1,106 @@ +import { ReasonPhrases, StatusCodes } from 'http-status-codes'; +import { NextRequest, NextResponse } from 'next/server'; + +import { getCurrentUser } from '@/actions/auth/get-current-user'; +import { TEN_MINUTE_SEC } from '@/constants/common'; +import { fetchCachedData } from '@/lib/cache'; +import { db } from '@/lib/db'; +import { absoluteUrl } from '@/lib/utils'; +import { stripe } from '@/server/stripe'; + +export const GET = async () => { + try { + const subscriptionDescription = await fetchCachedData( + 'subscription-description', + async () => { + const subscription = await db.stripeSubscriptionDescription.findMany(); + + return subscription ?? []; + }, + TEN_MINUTE_SEC, + ); + + return NextResponse.json(subscriptionDescription); + } catch (error) { + console.error('[PAYMENTS_SUBSCRIPTION]', error); + + return new NextResponse(ReasonPhrases.INTERNAL_SERVER_ERROR, { + status: StatusCodes.INTERNAL_SERVER_ERROR, + }); + } +}; + +export const POST = async (req: NextRequest) => { + try { + const user = await getCurrentUser(); + + if (!user) { + return new NextResponse(ReasonPhrases.UNAUTHORIZED, { status: StatusCodes.UNAUTHORIZED }); + } + + const { details, locale, price, rate, recurringInterval, returnUrl, subscriptionName } = + await req.json(); + + const userSubscription = await db.stripeSubscription.findUnique({ + where: { userId: user?.userId }, + }); + + if (userSubscription?.stripeCustomerId) { + const stripeSession = await stripe.billingPortal.sessions.create({ + customer: userSubscription.stripeCustomerId, + return_url: absoluteUrl(returnUrl), + }); + + return NextResponse.json({ url: stripeSession.url }); + } + + let stripeCustomer = await db.stripeCustomer.findUnique({ + where: { userId: user?.userId }, + select: { stripeCustomerId: true }, + }); + + if (!stripeCustomer) { + const customer = await stripe.customers.create({ + email: user?.email || '', + name: user?.name || undefined, + }); + + stripeCustomer = await db.stripeCustomer.create({ + data: { userId: user.userId, stripeCustomerId: customer.id }, + }); + } + + const stripeSession = await stripe.checkout.sessions.create({ + mode: 'subscription', + payment_method_types: ['card'], + customer: stripeCustomer.stripeCustomerId, + line_items: [ + { + quantity: 1, + price_data: { + currency: locale.currency, + product_data: { name: subscriptionName }, + unit_amount: Math.round((price ?? 0) * rate), + recurring: { interval: recurringInterval }, + }, + }, + ], + metadata: { + ...details, + isSubscription: true, + subscriptionName, + userId: user?.userId, + }, + success_url: absoluteUrl(`${returnUrl}?success=true`), + cancel_url: absoluteUrl(returnUrl), + }); + + return NextResponse.json({ url: stripeSession.url }); + } catch (error) { + console.error('[PAYMENTS_SUBSCRIPTION_[USER_ID]]', error); + + return new NextResponse(ReasonPhrases.INTERNAL_SERVER_ERROR, { + status: StatusCodes.INTERNAL_SERVER_ERROR, + }); + } +}; diff --git a/app/api/webhook/stripe/route.ts b/app/api/webhook/stripe/route.ts index 4c7979d6..337f20e1 100644 --- a/app/api/webhook/stripe/route.ts +++ b/app/api/webhook/stripe/route.ts @@ -1,10 +1,13 @@ +import { fromUnixTime } from 'date-fns'; import { StatusCodes } from 'http-status-codes'; import { headers } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; import Stripe from 'stripe'; -import stripe from 'stripe'; +import { removeValueFromMemoryCache } from '@/lib/cache'; import { db } from '@/lib/db'; +import { isObject, isString } from '@/lib/guard'; +import { stripe } from '@/server/stripe'; export const POST = async (req: NextRequest) => { const body = await req.text(); @@ -27,49 +30,91 @@ export const POST = async (req: NextRequest) => { const session = event.data.object as Stripe.Checkout.Session; const userId = session?.metadata?.userId; const courseId = session?.metadata?.courseId; + const isSubscription = session.metadata?.isSubscription; if (event.type === 'checkout.session.completed') { - if (!userId || !courseId) { + if (!userId || (!isSubscription && !courseId)) { return new NextResponse('Webhook Error: Missing metadata', { status: StatusCodes.BAD_REQUEST, }); } - const purchase = await db.purchase.create({ - data: { - courseId, - userId, - }, - }); + const subscription = await stripe.subscriptions.retrieve(session.subscription as string); + + if (isSubscription) { + await db.stripeSubscription.create({ + data: { + endDate: new Date(subscription.current_period_end * 1000), + name: session?.metadata?.subscriptionName ?? '', + startDate: new Date(subscription.current_period_start * 1000), + stripeCustomerId: subscription.customer as string, + stripePriceId: subscription.items.data[0].price.id, + stripeSubscriptionId: subscription.id, + userId, + }, + }); - const invoiceId = (() => { - if (typeof session.invoice === 'string') { - return session.invoice; - } + await removeValueFromMemoryCache(`user-subscription-[${userId}]`); - if (typeof session.invoice === 'object') { - return session.invoice?.id; - } - return null; - })(); + return new NextResponse(null); + } else { + const purchase = await db.purchase.create({ + data: { + courseId: courseId!, + userId, + }, + }); + + const invoiceId = (() => { + if (isString(session.invoice)) { + return session.invoice; + } + + if (isObject(session.invoice)) { + return session.invoice?.id; + } + return null; + })(); + + await db.purchaseDetails.create({ + data: { + city: session?.metadata?.city, + country: session?.metadata?.country, + countryCode: session?.metadata?.countryCode, + currency: session.currency?.toUpperCase(), + invoiceId, + latitude: Number(session?.metadata?.latitude), + longitude: Number(session?.metadata?.longitude), + paymentIntent: session.payment_intent?.toString(), + price: session.amount_total ?? 0, + purchaseId: purchase.id, + }, + }); - await db.purchaseDetails.create({ + return new NextResponse(null); + } + } + + if (event.type === 'customer.subscription.updated') { + const subscription: Stripe.Subscription = event.data.object; + + await db.stripeSubscription.update({ + where: { stripeSubscriptionId: subscription.id }, data: { - city: session?.metadata?.city, - country: session?.metadata?.country, - countryCode: session?.metadata?.countryCode, - currency: session.currency?.toUpperCase(), - invoiceId, - latitude: Number(session?.metadata?.latitude), - longitude: Number(session?.metadata?.longitude), - paymentIntent: session.payment_intent?.toString(), - price: session.amount_total ?? 0, - purchaseId: purchase.id, + cancelAt: subscription.cancel_at ? fromUnixTime(subscription.cancel_at) : null, }, }); - } else { - return new NextResponse(`Webhook Error: Unhandled event type ${event.type}`); + + return new NextResponse(null); + } + + if (event.type === 'customer.subscription.deleted') { + const subscription = event.data.object; + + await db.stripeSubscription.delete({ where: { stripeSubscriptionId: subscription.id } }); + + return new NextResponse(null); } - return new NextResponse(null); + return new NextResponse(`Webhook Error: Unhandled event type ${event.type}`); }; diff --git a/components/auth/user-profile-button.tsx b/components/auth/user-profile-button.tsx index 6ade43c0..295d6ea1 100644 --- a/components/auth/user-profile-button.tsx +++ b/components/auth/user-profile-button.tsx @@ -71,6 +71,7 @@ export const UserProfileButton = ({ globalProgress }: UserProfileButtonProps) => const isAdmin = user?.role === UserRole.ADMIN; const isStudent = user?.role === UserRole.STUDENT; const isTeacher = user?.role === UserRole.TEACHER; + const hasSubscription = user?.hasSubscription; return user ? ( @@ -122,7 +123,7 @@ export const UserProfileButton = ({ globalProgress }: UserProfileButtonProps) => Owner )} - {(isAdmin || isTeacher) && ( + {(isAdmin || isTeacher || hasSubscription) && ( <> { - return ( -
-

Upgrade to Nova Plus

-

- Unlock premium courses, get access to Nova AI, and more. -

- -
- ); -}; diff --git a/components/common/price.tsx b/components/common/price.tsx index 6735e00d..1a6e04c8 100644 --- a/components/common/price.tsx +++ b/components/common/price.tsx @@ -53,7 +53,7 @@ export const Price = ({ {!isLoading && (amount ?? 0) > 0 && (
{formattedPrice} - {formattedNet && formattedTotalFees && ( + {Boolean(fees.length) && formattedNet && formattedTotalFees && ( <> {showFeesAccordion && ( diff --git a/components/common/subscription-banner.tsx b/components/common/subscription-banner.tsx new file mode 100644 index 00000000..6014bad3 --- /dev/null +++ b/components/common/subscription-banner.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { StripeSubscriptionDescription } from '@prisma/client'; +import { useState } from 'react'; +import toast from 'react-hot-toast'; + +import { fetcher } from '@/lib/fetcher'; + +import { SubscriptionModal } from '../modals/subscription-modal'; +import { Button } from '../ui'; + +export const SubscriptionBanner = () => { + const [subscriptionDescription, setSubscriptionDescription] = useState< + StripeSubscriptionDescription[] + >([]); + const [isFetching, setIsFetching] = useState(false); + const [open, setOpen] = useState(false); + + const handleFetchSubscriptionDescription = async () => { + setIsFetching(true); + + try { + const response = await fetcher.get('/api/payments/subscription', { + responseType: 'json', + }); + + if (!response) { + setSubscriptionDescription([]); + } + + setSubscriptionDescription(response); + setOpen(true); + } catch (error) { + toast.error('Something went wrong!'); + } finally { + setIsFetching(false); + } + }; + + return ( + <> + +
+

Upgrade to Nova Plus

+

+ Unlock premium courses, get access to Nova AI, and more. +

+ +
+ + ); +}; diff --git a/components/data-table/data-table.tsx b/components/data-table/data-table.tsx index c4ec0c0a..3ad5bc2e 100644 --- a/components/data-table/data-table.tsx +++ b/components/data-table/data-table.tsx @@ -110,7 +110,7 @@ export function DataTable({ return (
{(isTeacherCoursesPage || isPromoPage || isNotificationPage) && ( -
+
{}; diff --git a/components/modals/subscription-modal.tsx b/components/modals/subscription-modal.tsx new file mode 100644 index 00000000..03296ee6 --- /dev/null +++ b/components/modals/subscription-modal.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { StripeSubscriptionDescription, StripeSubscriptionPeriod } from '@prisma/client'; +import { ArrowRight, CheckCircle2 as CheckCircle } from 'lucide-react'; +import { usePathname } from 'next/navigation'; +import { SyntheticEvent, useState } from 'react'; +import toast from 'react-hot-toast'; + +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useCurrentUser } from '@/hooks/use-current-user'; +import { useLocaleStore } from '@/hooks/use-locale-store'; +import { fetcher } from '@/lib/fetcher'; +import { capitalize } from '@/lib/utils'; + +import { Price } from '../common/price'; +import { TextBadge } from '../common/text-badge'; +import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from '../ui'; +import { AuthModal } from './auth-modal'; + +type SubscriptionModalProps = { + description: StripeSubscriptionDescription[]; + open: boolean; + setOpen: (value: boolean) => void; +}; + +export const SubscriptionModal = ({ description = [], open, setOpen }: SubscriptionModalProps) => { + const pathname = usePathname(); + const { user } = useCurrentUser(); + + const localeInfo = useLocaleStore((state) => state.localeInfo); + + const [isFetching, setIsFetching] = useState(false); + const [currentTab, setCurrentTab] = useState( + StripeSubscriptionPeriod.yearly, + ); + + const yearly = description.find(({ period }) => period === StripeSubscriptionPeriod.yearly); + const monthly = description.find(({ period }) => period === StripeSubscriptionPeriod.monthly); + + const { price, unitPrice, recurringInterval, subscriptionName } = (() => { + const currentPeriod = currentTab === StripeSubscriptionPeriod.yearly ? yearly : monthly; + + return { + price: currentPeriod?.price, + unitPrice: + currentPeriod?.period === StripeSubscriptionPeriod?.yearly + ? Math.round((currentPeriod?.price ?? 0) / 12) + : currentPeriod?.price, + recurringInterval: + currentPeriod?.period === StripeSubscriptionPeriod.yearly ? 'year' : 'month', + subscriptionName: currentPeriod?.name, + }; + })(); + + const handleUpgrade = async (event: SyntheticEvent) => { + event.preventDefault(); + + setIsFetching(true); + + await toast.promise( + fetcher.post('/api/payments/subscription', { + body: { + details: localeInfo?.details, + locale: localeInfo?.locale, + price, + rate: localeInfo?.rate, + recurringInterval, + returnUrl: pathname, + subscriptionName, + }, + responseType: 'json', + }), + { + loading: 'Subscription processing...', + success: (data) => { + setIsFetching(false); + + window.location.assign(data.url); + + return 'Checkout'; + }, + error: () => { + setIsFetching(false); + + return 'Something went wrong'; + }, + }, + ); + }; + + return ( + + +
+ + + {capitalize(currentTab)} + +
+

+ +

+ /mo +
+
+ setCurrentTab(value as StripeSubscriptionPeriod)} + value={currentTab} + > + + + {capitalize(yearly?.period ?? '')} + + + {capitalize(monthly?.period ?? '')} + + + {currentTab === StripeSubscriptionPeriod.yearly && ( +
+ +
+ )} + +
    + {yearly?.points.map((point) => ( +
  • + + {point} +
  • + ))} +
+
+ +
    + {monthly?.points.map((point) => ( +
  • + + {point} +
  • + ))} +
+
+
+ + {user?.userId && ( + + )} + {!user?.userId && ( + + + + )} + +
+
+
+ ); +}; diff --git a/components/sidebar/sidebar-routes.tsx b/components/sidebar/sidebar-routes.tsx index 9bf14df6..e8fdca27 100644 --- a/components/sidebar/sidebar-routes.tsx +++ b/components/sidebar/sidebar-routes.tsx @@ -17,7 +17,10 @@ import { import { usePathname } from 'next/navigation'; import { useMemo } from 'react'; -import { PremiumBanner } from '../common/premium-banner'; +import { AuthStatus } from '@/constants/auth'; +import { useCurrentUser } from '@/hooks/use-current-user'; + +import { SubscriptionBanner } from '../common/subscription-banner'; import { SideBarItem } from './sidebar-item'; const studentRoutes = [ @@ -117,12 +120,15 @@ const paymentsRoutes = [ ]; export const SideBarRoutes = () => { + const { user, status } = useCurrentUser(); const pathname = usePathname(); const isSettingsPage = pathname?.includes('/settings'); const isTeacherPage = pathname?.includes('/teacher'); const isPaymentsPage = pathname?.includes('/owner'); + const isLoading = status === AuthStatus.LOADING; + const routes = useMemo(() => { if (isSettingsPage) { return settingsRoutes; @@ -149,7 +155,7 @@ export const SideBarRoutes = () => { /> ))}
- + {!isLoading && !user?.hasSubscription && }
); }; diff --git a/constants/common.ts b/constants/common.ts index 38ac9f65..b6380d28 100644 --- a/constants/common.ts +++ b/constants/common.ts @@ -23,4 +23,5 @@ export const TEN_MINUTE_SEC = 60 * 10; export const ONE_DAY_MS = 24 * 60 * 60 * 1000; export const TIMESTAMP_TEMPLATE = 'HH:mm, dd MMM yyyy'; +export const TIMESTAMP_SUBSCRIPTION_TEMPLATE = 'dd MMM yyyy'; export const DATE_RANGE_TEMPLATE = 'LLL dd, y'; diff --git a/lib/auth.ts b/lib/auth.ts deleted file mode 100644 index 84b03c5d..00000000 --- a/lib/auth.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { cookies } from 'next/headers'; -import type { NextAuthOptions } from 'next-auth'; -import GitHubProvider from 'next-auth/providers/github'; -import GoogleProvider from 'next-auth/providers/google'; -import LinkedInProvider from 'next-auth/providers/linkedin'; -import MailRuProvider from 'next-auth/providers/mailru'; -import SlackProvider from 'next-auth/providers/slack'; -import VkProvider from 'next-auth/providers/vk'; -import YandexProvider from 'next-auth/providers/yandex'; - -import { loginUser } from '@/actions/auth/login-user'; -import { Provider, UserRole } from '@/constants/auth'; -import { TEN_MINUTE_SEC } from '@/constants/common'; -import { OTP_SECRET_SECURE } from '@/constants/otp'; - -import { fetchCachedData } from './cache'; -import { db } from './db'; -import { isString } from './guard'; -import { encrypt } from './utils'; - -export const authOptions = { - pages: { - signIn: '/sign-in', - }, - - providers: [ - GitHubProvider({ - clientId: process.env.GITHUB_ID as string, - clientSecret: process.env.GITHUB_SECRET as string, - }), - GoogleProvider({ - clientId: process.env.GOOGLE_CLIENT_ID as string, - clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, - }), - YandexProvider({ - clientId: process.env.YANDEX_CLIENT_ID as string, - clientSecret: process.env.YANDEX_CLIENT_SECRET as string, - }), - SlackProvider({ - clientId: process.env.SLACK_CLIENT_ID as string, - clientSecret: process.env.SLACK_CLIENT_SECRET as string, - }), - LinkedInProvider({ - clientId: process.env.LINKEDIN_CLIENT_ID as string, - clientSecret: process.env.LINKEDIN_CLIENT_SECRET as string, - authorization: { - params: { scope: 'openid profile email' }, - }, - issuer: 'https://www.linkedin.com/oauth', - jwks_endpoint: 'https://www.linkedin.com/oauth/openid/jwks', - profile(profile) { - return { - email: profile.email, - id: profile.id, - image: profile?.picture, - name: profile.name, - role: UserRole.STUDENT, - }; - }, - }), - MailRuProvider({ - clientId: process.env.MAILRU_CLIENT_ID as string, - clientSecret: process.env.MAILRU_CLIENT_SECRET as string, - authorization: 'https://oauth.mail.ru/login', - token: 'https://oauth.mail.ru/token', - userinfo: { - async request(context) { - const res = await fetch( - `https://oauth.mail.ru/userinfo?access_token=${context.tokens.access_token}`, - ); - return await res.json(); - }, - }, - profile(profile) { - return { - email: profile.email, - id: profile.id, - image: profile?.image, - name: profile.name, - role: UserRole.STUDENT, - }; - }, - }), - VkProvider({ - clientId: process.env.VK_CLIENT_ID as string, - clientSecret: process.env.VK_CLIENT_SECRET as string, - }), - ], - callbacks: { - async signIn({ user, account }) { - const email = user?.email ?? account?.email; - const hasOtpSecret = cookies().has(OTP_SECRET_SECURE); - - if (Object.values(Provider).includes(account?.provider as Provider) && isString(email)) { - const dbUser = await loginUser(email, user.name, user.image); - - if (!hasOtpSecret && dbUser.otpSecret) { - return `/otp-verification?code=${encodeURIComponent(encrypt({ secret: dbUser.otpSecret, userId: dbUser.id, provider: account?.provider }, process.env.OTP_SECRET as string))}`; - } - - user.id = dbUser.id; - user.email = email; - user.image = dbUser.image; - user.isPublic = Boolean(dbUser.isPublic); - user.name = dbUser.name; - user.role = dbUser.role; - - return true; - } - - return false; - }, - async jwt({ token, user, trigger, session }) { - if (user) { - token.email = user.email; - token.isPublic = user.isPublic; - token.role = user.role; - } - - if (trigger === 'update') { - if (session?.name) { - token.name = session.name; - } - - if (session?.pictureUrl) { - token.picture = session.pictureUrl; - } - } - - return token; - }, - async session({ session, token }) { - if (session.user) { - if (token.sub) { - session.user.email = token.email; - session.user.isPublic = Boolean(token.isPublic); - session.user.userId = token.sub; - } - - if (token.role) { - session.user.role = (token.role as string) || UserRole.STUDENT; - } - - const updatedToken = await fetchCachedData( - `token-change-${token.email}`, - async () => { - const updatedUser = await db.user.findUnique({ - where: { id: session?.user?.userId }, - select: { role: true }, - }); - - return { role: updatedUser?.role }; - }, - TEN_MINUTE_SEC, - ); - - if (updatedToken?.role && updatedToken.role !== session?.user?.role) { - session.user.role = updatedToken.role; - } - } - - return session; - }, - }, -} satisfies NextAuthOptions; diff --git a/lib/auth/auth.ts b/lib/auth/auth.ts new file mode 100644 index 00000000..5174d698 --- /dev/null +++ b/lib/auth/auth.ts @@ -0,0 +1,12 @@ +import type { NextAuthOptions } from 'next-auth'; + +import { callbacks } from './callbacks'; +import { providers } from './providers'; + +export const authOptions = { + pages: { + signIn: '/sign-in', + }, + providers, + callbacks, +} satisfies NextAuthOptions; diff --git a/lib/auth/callbacks.ts b/lib/auth/callbacks.ts new file mode 100644 index 00000000..3c4a48cc --- /dev/null +++ b/lib/auth/callbacks.ts @@ -0,0 +1,85 @@ +import { cookies } from 'next/headers'; +import { NextAuthOptions } from 'next-auth'; + +import { getUpdatedUser } from '@/actions/auth/get-updated-user'; +import { loginUser } from '@/actions/auth/login-user'; +import { getUserSubscription } from '@/actions/stripe/get-user-subscription'; +import { Provider, UserRole } from '@/constants/auth'; +import { OTP_SECRET_SECURE } from '@/constants/otp'; + +import { isString } from '../guard'; +import { encrypt } from '../utils'; + +export const callbacks: NextAuthOptions['callbacks'] = { + async signIn({ user, account }) { + const email = user?.email ?? account?.email; + const hasOtpSecret = cookies().has(OTP_SECRET_SECURE); + + if (Object.values(Provider).includes(account?.provider as Provider) && isString(email)) { + const dbUser = await loginUser(email, user.name, user.image); + + if (!hasOtpSecret && dbUser.otpSecret) { + return `/otp-verification?code=${encodeURIComponent(encrypt({ secret: dbUser.otpSecret, userId: dbUser.id, provider: account?.provider }, process.env.OTP_SECRET as string))}`; + } + + user.email = email; + user.hasSubscription = dbUser.hasSubscription; + user.id = dbUser.id; + user.image = dbUser.image; + user.isPublic = Boolean(dbUser.isPublic); + user.name = dbUser.name; + user.role = dbUser.role; + + return true; + } + + return false; + }, + async jwt({ token, user, trigger, session }) { + if (user) { + token.email = user.email; + token.hasSubscription = user.hasSubscription; + token.isPublic = user.isPublic; + token.role = user.role; + } + + if (trigger === 'update') { + if (session?.name) { + token.name = session.name; + } + + if (session?.pictureUrl) { + token.picture = session.pictureUrl; + } + } + + return token; + }, + async session({ session, token }) { + if (session.user) { + if (token.sub) { + session.user.email = token.email; + session.user.hasSubscription = Boolean(token.subscription); + session.user.isPublic = Boolean(token.isPublic); + session.user.userId = token.sub; + } + + if (token.role) { + session.user.role = (token.role as string) || UserRole.STUDENT; + } + + const userId = session?.user?.userId; + + const updatedToken = await getUpdatedUser(userId); + const userSubscription = await getUserSubscription(userId); + + if (updatedToken?.role && updatedToken.role !== session?.user?.role) { + session.user.role = updatedToken.role; + } + + session.user.hasSubscription = Boolean(userSubscription); + } + + return session; + }, +}; diff --git a/lib/auth/providers.ts b/lib/auth/providers.ts new file mode 100644 index 00000000..fe84a8fa --- /dev/null +++ b/lib/auth/providers.ts @@ -0,0 +1,73 @@ +import GitHubProvider from 'next-auth/providers/github'; +import GoogleProvider from 'next-auth/providers/google'; +import LinkedInProvider from 'next-auth/providers/linkedin'; +import MailRuProvider from 'next-auth/providers/mailru'; +import SlackProvider from 'next-auth/providers/slack'; +import VkProvider from 'next-auth/providers/vk'; +import YandexProvider from 'next-auth/providers/yandex'; + +import { UserRole } from '@/constants/auth'; + +export const providers = [ + GitHubProvider({ + clientId: process.env.GITHUB_ID as string, + clientSecret: process.env.GITHUB_SECRET as string, + }), + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID as string, + clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, + }), + YandexProvider({ + clientId: process.env.YANDEX_CLIENT_ID as string, + clientSecret: process.env.YANDEX_CLIENT_SECRET as string, + }), + SlackProvider({ + clientId: process.env.SLACK_CLIENT_ID as string, + clientSecret: process.env.SLACK_CLIENT_SECRET as string, + }), + LinkedInProvider({ + clientId: process.env.LINKEDIN_CLIENT_ID as string, + clientSecret: process.env.LINKEDIN_CLIENT_SECRET as string, + authorization: { + params: { scope: 'openid profile email' }, + }, + issuer: 'https://www.linkedin.com/oauth', + jwks_endpoint: 'https://www.linkedin.com/oauth/openid/jwks', + profile(profile) { + return { + email: profile.email, + id: profile.id, + image: profile?.picture, + name: profile.name, + role: UserRole.STUDENT, + }; + }, + }), + MailRuProvider({ + clientId: process.env.MAILRU_CLIENT_ID as string, + clientSecret: process.env.MAILRU_CLIENT_SECRET as string, + authorization: 'https://oauth.mail.ru/login', + token: 'https://oauth.mail.ru/token', + userinfo: { + async request(context) { + const res = await fetch( + `https://oauth.mail.ru/userinfo?access_token=${context.tokens.access_token}`, + ); + return await res.json(); + }, + }, + profile(profile) { + return { + email: profile.email, + id: profile.id, + image: profile?.image, + name: profile.name, + role: UserRole.STUDENT, + }; + }, + }), + VkProvider({ + clientId: process.env.VK_CLIENT_ID as string, + clientSecret: process.env.VK_CLIENT_SECRET as string, + }), +]; diff --git a/lib/guard.ts b/lib/guard.ts index bf3f5698..3d7bf7f2 100644 --- a/lib/guard.ts +++ b/lib/guard.ts @@ -2,3 +2,5 @@ export const isString = (value: unknown): value is string => typeof value === 's export const isNumber = (value: unknown): value is number => typeof value === 'number' && isFinite(value); + +export const isObject = (value: unknown): value is object => typeof value === 'object'; diff --git a/lib/utils.ts b/lib/utils.ts index 65187898..e97ed676 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -51,3 +51,5 @@ export const decrypt = (cipher: string, secret: string) => { return bytes.toString(enc.Utf8); }; + +export const absoluteUrl = (path: string) => `${process.env.NEXT_PUBLIC_APP_URL}${path}`; diff --git a/package.json b/package.json index fec9136a..45602757 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "recharts": "^2.12.0", "remark-gfm": "^4.0.0", "sharp": "^0.32.6", - "stripe": "^14.16.0", + "stripe": "^16.7.0", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "uploadthing": "^6.13.2", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5f2298a4..c1aac5e4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4,22 +4,28 @@ generator client { datasource db { provider = "postgresql" - url = env("POSTGRES_PRISMA_URL") // uses connection pooling + url = env("POSTGRES_PRISMA_URL") +} + +enum StripeSubscriptionPeriod { + monthly + yearly } model User { - id String @id @default(uuid()) - course Course[] - createdAt DateTime @default(now()) - email String @unique - isPublic Boolean? @default(false) - name String? - notifications Notification[] - otpCreatedAt DateTime? - otpSecret String? - pictureUrl String? - role String @default("student") - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + stripeSubscription StripeSubscription? + course Course[] + createdAt DateTime @default(now()) + email String @unique + isPublic Boolean? @default(false) + name String? + notifications Notification[] + otpCreatedAt DateTime? + otpSecret String? + pictureUrl String? + role String @default("student") + updatedAt DateTime @updatedAt } model Course { @@ -159,6 +165,33 @@ model StripePromo { updatedAt DateTime @updatedAt } +model StripeSubscription { + id String @id @default(uuid()) + cancelAt DateTime? + createdAt DateTime @default(now()) + endDate DateTime + name String + startDate DateTime @default(now()) + stripeCustomerId String @unique + stripePriceId String @unique + stripeSubscriptionId String @unique + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) + userId String @unique + + @@index([userId]) +} + +model StripeSubscriptionDescription { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + name String + period StripeSubscriptionPeriod + points String[] + price Int? @default(0) + updatedAt DateTime @updatedAt +} + model Fee { id String @id @default(uuid()) amount Int @default(0) diff --git a/scripts/seed.ts b/scripts/seed.ts index 6f9db0f3..0bd9a903 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -42,6 +42,21 @@ async function main() { ], }); + await database.stripeSubscriptionDescription.createMany({ + data: [ + { + period: 'monthly', + points: ['Unlock premium courses', 'Get access to Nova AI', 'Cancel anytime'], + price: 4900, + }, + { + period: 'yearly', + points: ['Unlock premium courses', 'Get access to Nova AI', 'Cancel anytime'], + price: 2400, + }, + ], + }); + console.info('Success'); } catch (error) { console.error('Error seeding the database categories', error); diff --git a/server/openai.ts b/server/openai.ts index 44d486a8..ba6587ce 100644 --- a/server/openai.ts +++ b/server/openai.ts @@ -2,5 +2,4 @@ import OpenAI from 'openai'; export const openai = new OpenAI({ apiKey: process.env.OPEN_AI_TOKEN, - organization: process.env.OPEN_AI_ORG, }); diff --git a/server/stripe.ts b/server/stripe.ts index eb466470..f91c204b 100644 --- a/server/stripe.ts +++ b/server/stripe.ts @@ -1,6 +1,6 @@ import Stripe from 'stripe'; export const stripe = new Stripe(process.env.STRIPE_API_KEY as string, { - apiVersion: '2023-10-16', + apiVersion: '2024-06-20', typescript: true, }); diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index d7a6a7f1..d9f44a33 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -3,6 +3,7 @@ import { DefaultSession, User as AuthUser } from 'next-auth'; declare module 'next-auth' { interface Session { user: { + hasSubscription?: boolean; isPublic?: boolean; role: string; userId: string; @@ -10,6 +11,7 @@ declare module 'next-auth' { } interface User extends AuthUser { + hasSubscription?: boolean; isPublic?: boolean; role: string; } diff --git a/yarn.lock b/yarn.lock index d7e3427c..1f4d50a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6004,7 +6004,7 @@ __metadata: recharts: "npm:^2.12.0" remark-gfm: "npm:^4.0.0" sharp: "npm:^0.32.6" - stripe: "npm:^14.16.0" + stripe: "npm:^16.7.0" tailwind-merge: "npm:^2.2.1" tailwindcss: "npm:^3.3.0" tailwindcss-animate: "npm:^1.0.7" @@ -9208,13 +9208,13 @@ __metadata: languageName: node linkType: hard -"stripe@npm:^14.16.0": - version: 14.16.0 - resolution: "stripe@npm:14.16.0" +"stripe@npm:^16.7.0": + version: 16.7.0 + resolution: "stripe@npm:16.7.0" dependencies: "@types/node": "npm:>=8.1.0" qs: "npm:^6.11.0" - checksum: 10c0/bada06609592bae71094ba86fdf745d86945d6bb5b44482da0355235c01ca6f2c76f261fad31d9d367cee5cf6b8b5532fb66733d664d4bb497125809b61699bf + checksum: 10c0/2604c58cffd939315385e34ed4b69f19f6e1dc14960c95e8294b235d2dcb2b60653e09efa16385246921abf3c55bcacb0067a10ab2148ad884bfb2dcd5a8e20a languageName: node linkType: hard