Skip to content
Open
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
24 changes: 23 additions & 1 deletion src/app/api/ai/route.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions src/app/api/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -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 };
11 changes: 11 additions & 0 deletions src/app/api/portal/route.ts
Original file line number Diff line number Diff line change
@@ -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 };
32 changes: 32 additions & 0 deletions src/app/billing/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AppMain title="Billing" isLoggedIn={true}>
<AppContent>
<AppCheckoutProvider checkout={checkout.serialize()}>
<CustomerPortal endpoint={process.env.NEXT_PUBLIC_APP_URL! + '/api/portal'} />
</AppCheckoutProvider>
</AppContent>
</AppMain>
);
}
8 changes: 8 additions & 0 deletions src/app/chat/ai-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(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 (
<>
<Paywall state={state} hidePaywall={hidePaywall} />

<LoginModal isShowing={isShowingLogin} onClose={() => setIsShowingLogin(false)} />

<AIChat examples={examples} onApiError={handleApiError} />
Expand Down
11 changes: 10 additions & 1 deletion src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<AppMain title="New Chat" isLoggedIn={!!session}>
<AiApp examples={examples} />
<AppCheckoutProvider checkout={checkout.serialize()}>
<AiApp examples={examples} />
</AppCheckoutProvider>
</AppMain>
);
}
5 changes: 4 additions & 1 deletion src/app/credits/credits.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<div className="text-center">
<h2 className="text-lg font-medium">You have {formatNumber(credits ?? 0)} credits</h2>
<p className="mb-10 text-muted-foreground">You can purchase more credits below.</p>
</div>
);

return <Topup>{info}</Topup>;
}
11 changes: 10 additions & 1 deletion src/app/credits/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 (
<AppMain title="Credits & Topups" isLoggedIn={true}>
<AppContent>
<ErrorBoundary>
<Credits credits={credits} />
<AppCheckoutProvider checkout={checkout.serialize()}>
<Credits credits={credits} />
</AppCheckoutProvider>
</ErrorBoundary>
</AppContent>
</AppMain>
Expand Down
35 changes: 35 additions & 0 deletions src/app/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -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 };
26 changes: 26 additions & 0 deletions src/components/app-checkout-provider.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<CheckoutProvider
onAfterSync={onAfterSync}
checkout={props.checkout}
endpoint={process.env.NEXT_PUBLIC_APP_URL! + '/api/checkout'}
>
{props.children}
</CheckoutProvider>
);
}
10 changes: 5 additions & 5 deletions src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
34 changes: 31 additions & 3 deletions src/lib/user-entitlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,43 @@ import { freemius } from './freemius';
// #region Freemius SDK Supporting Functions for User Entitlements

export async function processPurchaseInfo(fsPurchase: PurchaseInfo): Promise<void> {
// 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<UserFsEntitlement | null> {
// 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 {
Expand Down