diff --git a/src/app/api/ai/route.ts b/src/app/api/ai/route.ts index 23c572e..2c8ee56 100644 --- a/src/app/api/ai/route.ts +++ b/src/app/api/ai/route.ts @@ -1,6 +1,6 @@ import { headers } from 'next/headers'; import { auth } from '@/lib/auth'; -import { deductCredits } from '@/lib/user-entitlement'; +import { deductCredits, getUserEntitlement, hasCredits } from '@/lib/user-entitlement'; import { getAiResponse } from '@/lib/ai'; export async function POST(request: Request) { @@ -18,6 +18,28 @@ export async function POST(request: Request) { ); } + const entitlement = await getUserEntitlement(session.user.id); + + if (!entitlement) { + return Response.json( + { + code: 'no_active_purchase', + message: 'You do not have an active license to use this feature.', + }, + { status: 403 } + ); + } + + if (!(await hasCredits(session.user.id, 100))) { + return Response.json( + { + code: 'insufficient_credits', + message: 'You do not have enough credits to use this feature.', + }, + { status: 403 } + ); + } + /** * Here you would implement the AI asset generation and credit consumption logic. * For demonstration, we will just return a dummy response and deduct 100 credits. diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts new file mode 100644 index 0000000..4d01385 --- /dev/null +++ b/src/app/api/checkout/route.ts @@ -0,0 +1,11 @@ +/** + * This route handles the Purchase actions and sync actions coming from the Freemius React Starter Kit. + */ +import { freemius } from '@/lib/freemius'; +import { processPurchaseInfo } from '@/lib/user-entitlement'; + +const processor = freemius.checkout.request.createProcessor({ + onPurchase: processPurchaseInfo, +}); + +export { processor as GET, processor as POST }; diff --git a/src/app/api/portal/route.ts b/src/app/api/portal/route.ts new file mode 100644 index 0000000..7bc4b8a --- /dev/null +++ b/src/app/api/portal/route.ts @@ -0,0 +1,11 @@ +import { freemius } from '@/lib/freemius'; +import { getFsUser, processPurchaseInfo } from '@/lib/user-entitlement'; + +const processor = freemius.customerPortal.request.createProcessor({ + getUser: getFsUser, + portalEndpoint: process.env.NEXT_PUBLIC_APP_URL! + '/api/portal', + isSandbox: process.env.NODE_ENV !== 'production', + onRestore: freemius.customerPortal.createRestorer(processPurchaseInfo), +}); + +export { processor as GET, processor as POST }; diff --git a/src/app/billing/page.tsx b/src/app/billing/page.tsx new file mode 100644 index 0000000..059fbfd --- /dev/null +++ b/src/app/billing/page.tsx @@ -0,0 +1,32 @@ +import { auth } from '@/lib/auth'; +import { freemius } from '@/lib/freemius'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { CustomerPortal } from '@/react-starter/components/customer-portal'; +import AppCheckoutProvider from '@/components/app-checkout-provider'; +import AppMain, { AppContent } from '@/components/app-main'; + +export default async function Billing() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + redirect('/login'); + } + + const checkout = await freemius.checkout.create({ + user: session?.user, + isSandbox: process.env.NODE_ENV !== 'production', + }); + + return ( + + + + + + + + ); +} diff --git a/src/app/chat/ai-app.tsx b/src/app/chat/ai-app.tsx index 10cd1a7..b085676 100644 --- a/src/app/chat/ai-app.tsx +++ b/src/app/chat/ai-app.tsx @@ -3,20 +3,28 @@ import { useState } from 'react'; import LoginModal from '@/components/login-modal'; import { AIChat } from '@/components/ai-chat'; +import { Paywall, usePaywall } from '@/react-starter/components/paywall'; export default function AiApp(props: { examples: string[] }) { const { examples } = props; const [isShowingLogin, setIsShowingLogin] = useState(false); + const { hidePaywall, state, showNoActivePurchase, showInsufficientCredits } = usePaywall(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleApiError = (data: any) => { if (data.code === 'unauthenticated') { setIsShowingLogin(true); + } else if (data.code === 'no_active_purchase') { + showNoActivePurchase(); + } else if (data.code === 'insufficient_credits') { + showInsufficientCredits(); } }; return ( <> + + setIsShowingLogin(false)} /> diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index a33d6c0..5d8e953 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -3,15 +3,24 @@ import { auth } from '@/lib/auth'; import { headers } from 'next/headers'; import { examples } from '@/lib/ai'; import AiApp from './ai-app'; +import { freemius } from '@/lib/freemius'; +import AppCheckoutProvider from '@/components/app-checkout-provider'; export default async function Dashboard() { const session = await auth.api.getSession({ headers: await headers(), }); + const checkout = await freemius.checkout.create({ + user: session?.user, + isSandbox: process.env.NODE_ENV !== 'production', + }); + return ( - + + + ); } diff --git a/src/app/credits/credits.tsx b/src/app/credits/credits.tsx index 3a4aae2..c58e464 100644 --- a/src/app/credits/credits.tsx +++ b/src/app/credits/credits.tsx @@ -1,14 +1,17 @@ 'use client'; +import { Topup } from '@/react-starter/components/topup'; import { formatNumber } from '@/react-starter/utils/formatter'; export default function Credits(props: { credits?: number }) { const { credits } = props; - return ( + const info = (

You have {formatNumber(credits ?? 0)} credits

You can purchase more credits below.

); + + return {info}; } diff --git a/src/app/credits/page.tsx b/src/app/credits/page.tsx index 15b346e..baa4c9f 100644 --- a/src/app/credits/page.tsx +++ b/src/app/credits/page.tsx @@ -5,6 +5,8 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { ErrorBoundary } from '@/components/error'; import Credits from './credits'; +import { freemius } from '@/lib/freemius'; +import AppCheckoutProvider from '@/components/app-checkout-provider'; export default async function CreditsPage() { const session = await auth.api.getSession({ @@ -17,11 +19,18 @@ export default async function CreditsPage() { const credits = await getCredits(session.user.id); + const checkout = await freemius.checkout.create({ + user: session?.user, + isSandbox: process.env.NODE_ENV !== 'production', + }); + return ( - + + + diff --git a/src/app/webhook/route.ts b/src/app/webhook/route.ts new file mode 100644 index 0000000..868e1a7 --- /dev/null +++ b/src/app/webhook/route.ts @@ -0,0 +1,35 @@ +import { freemius } from '@/lib/freemius'; +import { deleteEntitlement, renewCreditsFromWebhook, syncEntitlementFromWebhook } from '@/lib/user-entitlement'; +import { WebhookEventType } from '@freemius/sdk'; + +const listener = freemius.webhook.createListener(); + +const licenseEvents: WebhookEventType[] = [ + 'license.created', + 'license.extended', + 'license.shortened', + 'license.updated', + 'license.cancelled', + 'license.expired', + 'license.plan.changed', +]; + +listener.on(licenseEvents, async ({ objects: { license } }) => { + if (license && license.id) { + await syncEntitlementFromWebhook(license.id); + } +}); + +listener.on('license.deleted', async ({ data }) => { + await deleteEntitlement(data.license_id); +}); + +listener.on('license.extended', async ({ data }) => { + if (data.is_renewal) { + renewCreditsFromWebhook(data.license_id); + } +}); + +const processor = freemius.webhook.createRequestProcessor(listener); + +export { processor as POST }; diff --git a/src/components/app-checkout-provider.tsx b/src/components/app-checkout-provider.tsx new file mode 100644 index 0000000..4307925 --- /dev/null +++ b/src/components/app-checkout-provider.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { CheckoutProvider } from '@/react-starter/components/checkout-provider'; +import { CheckoutSerialized } from '@freemius/sdk'; +import * as React from 'react'; +import { toast } from 'sonner'; +import { useRouter } from 'next/navigation'; + +export default function AppCheckoutProvider(props: { children: React.ReactNode; checkout: CheckoutSerialized }) { + const router = useRouter(); + + const onAfterSync = React.useCallback(() => { + toast.success(`Successfully updated your subscription! Now you can continue using the app.`); + router.refresh(); + }, [router]); + + return ( + + {props.children} + + ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 9f36cc7..79a75fe 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -36,11 +36,11 @@ const data = { }, ], navFooterLoggedIn: [ - // { - // title: 'Billing & Payments', - // url: '/billing', - // icon: IconReceipt, - // }, + { + title: 'Billing & Payments', + url: '/billing', + icon: IconReceipt, + }, { title: 'Credits & Topups', url: '/credits', diff --git a/src/lib/user-entitlement.ts b/src/lib/user-entitlement.ts index 646cade..acbae24 100644 --- a/src/lib/user-entitlement.ts +++ b/src/lib/user-entitlement.ts @@ -17,15 +17,43 @@ import { freemius } from './freemius'; // #region Freemius SDK Supporting Functions for User Entitlements export async function processPurchaseInfo(fsPurchase: PurchaseInfo): Promise { - // Todo: fill me + const user = await getUserByEmail(fsPurchase.email); + + if (!user) { + return; + } + + const credit = await getCreditsForUserPurchase(user, fsPurchase); + + // Save purchase info in our DB + await prisma.userFsEntitlement.upsert({ + where: { fsLicenseId: fsPurchase.licenseId }, + create: fsPurchase.toEntitlementRecord({ userId: user.id }), + update: fsPurchase.toEntitlementRecord(), + }); + + if (credit > 0) { + await addCredits(user.id, credit); + } } export async function getUserEntitlement(userId: string): Promise { - // Todo: fill me + const entitlements = await prisma.userFsEntitlement.findMany({ + where: { userId, type: 'subscription' }, + }); + + return freemius.entitlement.getActive(entitlements); } export const getFsUser: UserRetriever = async () => { - // Todo: fill me + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const entitlement = session ? await getUserEntitlement(session.user.id) : null; + const email = session?.user.email ?? undefined; + + return freemius.entitlement.getFsUser(entitlement, email); }; function getEntitledCredits(fsPurchase: PurchaseInfo): number {