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
45 changes: 41 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@
"dev": "next dev -p 3002",
"start": "next start",
"lint": "next lint",
"build": "next build",
"typecheck": "tsc --noEmit"
"build": "prisma migrate deploy && prisma generate && next build",
"typecheck": "tsc --noEmit",
"postinstall": "prisma generate"
},
"dependencies": {
"@ai-sdk/react": "^2.0.9",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@freemius/checkout": "^1.3.1",
"@freemius/sdk": "^0.0.6",
"@icons-pack/react-simple-icons": "^13.7.0",
"@prisma/client": "^6.13.0",
"@prisma/extension-accelerate": "^2.0.2",
Expand Down Expand Up @@ -63,7 +66,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"use-stick-to-bottom": "^1.1.1",
"vaul": "^1.1.2"
"vaul": "^1.1.2",
"zod": "^4.1.12"
},
"peerDependencies": {
"react": ">=16.8.0",
Expand Down
5 changes: 3 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ generator client {
}

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}

model User {
Expand Down
25 changes: 24 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,29 @@ 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.',
},
// 402 Payment Required
{ status: 402 }
);
}

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: 402 }
);
}

/**
* 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 };
35 changes: 35 additions & 0 deletions src/app/billing/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import AppMain, { AppContent } from '@/components/app-main';
import { auth } from '@/lib/auth';
import { freemius } from '@/lib/freemius';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { ErrorBoundary } from '@/components/error';
import { CustomerPortal } from '@/react-starter/components/customer-portal';
import AppCheckoutProvider from '@/components/app-checkout-provider';

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>
<ErrorBoundary>
<AppCheckoutProvider checkout={checkout.serialize()}>
<CustomerPortal endpoint={process.env.NEXT_PUBLIC_APP_URL! + '/api/portal'} />
</AppCheckoutProvider>
</ErrorBoundary>
</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, showInsufficientCredits, showNoActivePurchase, state } = 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>
);
}
6 changes: 5 additions & 1 deletion src/app/credits/credits.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
'use client';

import { Topup } from '@/react-starter/components/topup';

export default function Credits(props: { credits?: number; hasSubscription?: boolean }) {
const { credits } = props;

// Use Intl.NumberFormat to format the number with commas
const formattedCredit = new Intl.NumberFormat().format(credits ?? 0);

return (
const info = (
<div className="text-center">
<h2 className="text-lg font-medium">You have {formattedCredit} 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
46 changes: 46 additions & 0 deletions src/app/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { WebhookEventType } from '@freemius/sdk';
import { freemius } from '@/lib/freemius';
import {
deleteEntitlement,
renewCreditsFromWebhook,
sendRenewalFailureEmail,
syncEntitlementFromWebhook,
} from '@/lib/user-entitlement';

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.extended', async ({ data }) => {
if (data.is_renewal) {
renewCreditsFromWebhook(data.license_id);
}
});

listener.on('license.deleted', async ({ data }) => {
await deleteEntitlement(data.license_id);
console.log('License deleted:', data.license_id);
});

listener.on('subscription.renewal.failed', async ({ objects: { subscription } }) => {
await sendRenewalFailureEmail(subscription);
console.log('Subscription renewal failed:', subscription);
});

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 { type 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(`Your purchase was successful! 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>
);
}
Loading