Skip to content

Commit 3387f3b

Browse files
committed
payments screen
1 parent a1979b9 commit 3387f3b

8 files changed

Lines changed: 196 additions & 21 deletions

File tree

packages/dashboard/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"@hookform/resolvers": "^3.10.0",
1515
"@keyv/redis": "^4.3.2",
1616
"@monaco-editor/react": "^4.7.0",
17+
"@paddle/paddle-js": "^1.4.1",
18+
"@paddle/paddle-node-sdk": "^2.7.1",
1719
"@radix-ui/react-accordion": "^1.2.3",
1820
"@radix-ui/react-alert-dialog": "^1.1.6",
1921
"@radix-ui/react-avatar": "^1.1.3",

packages/dashboard/pnpm-lock.yaml

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/dashboard/src/app/(layout)/subscription/components/PlansComparison.tsx

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,42 @@ import {useState} from "react";
44
import {Button} from "@/components/ui/button";
55
import {Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle} from "@/components/ui/card";
66
import {CheckCircle, XCircle} from "lucide-react";
7+
import {usePaddle} from "@/hooks/usePaddle";
8+
import {createClient} from "@/utils/supabase/server";
9+
10+
const pro_monthly_id = "pri_01jv5ewwzjg4ab4dzzgm5xc1d5"
11+
const pro_yearly_id = "pri_01jv5ey9ahq6xb8es0v14z741p"
12+
13+
type Props = {
14+
initialSubscription: any
15+
customerData: {
16+
email: string
17+
}
18+
}
19+
20+
export default async function PlansComparison({initialSubscription, customerData}:Props) {
721

8-
export default function PlansComparison({initialSubscription}) {
922
const [subscription, setSubscription] = useState(initialSubscription);
1023
const [billingCycle, setBillingCycle] = useState(initialSubscription.billingCycle);
24+
const {paddle, error, openCheckout} = usePaddle();
1125

1226
// Handle upgrade
13-
const handleUpgrade = async () => {
27+
const handleUpgrade = async (priceId: string) => {
1428
try {
15-
// In a real app, you would call an API endpoint
16-
// await fetch('/api/subscription/upgrade', {
17-
// method: 'POST',
18-
// body: JSON.stringify({ plan: 'pro', billingCycle })
19-
// });
20-
21-
setSubscription({
22-
...subscription,
23-
type: "pro",
24-
requests: {
25-
used: subscription.requests.used,
26-
total: 10000
27-
},
28-
billingCycle: billingCycle,
29-
renewalDate: billingCycle === "monthly" ? "June 14, 2025" : "May 14, 2026"
30-
});
29+
openCheckout({
30+
customer: customerData,
31+
items: [{
32+
quantity:1,
33+
priceId
34+
}]
35+
})
3136
} catch (error) {
3237
console.error("Error upgrading subscription:", error);
3338
}
3439
};
3540

3641
// Toggle billing cycle
37-
const changeBillingCycle = (cycle) => {
42+
const changeBillingCycle = (cycle: any) => {
3843
setBillingCycle(cycle);
3944
};
4045

@@ -133,7 +138,7 @@ export default function PlansComparison({initialSubscription}) {
133138
{subscription.type === "pro" ? (
134139
<Button disabled variant="outline" className="w-full">Current Plan</Button>
135140
) : (
136-
<Button onClick={handleUpgrade} className="w-full">Upgrade Now</Button>
141+
<Button onClick={()=>handleUpgrade(billingCycle === "yearly" ? pro_yearly_id: pro_monthly_id)} className="w-full">Upgrade Now</Button>
137142
)}
138143
</CardFooter>
139144
</Card>

packages/dashboard/src/app/(layout)/subscription/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { Separator } from "@/components/ui/separator";
22
import SubscriptionCard from "./components/SubscriptionCard";
33
import PlansComparison from "./components/PlansComparison";
4+
import {createClient} from "@/utils/supabase/server";
45

56
export default async function SubscriptionPage() {
7+
const supabase = await createClient()
8+
const {data: {user}} = await supabase.auth.getUser()
69
// In a real app, you would fetch this data server-side
710
const userData = {
811
subscription: {
@@ -34,7 +37,9 @@ export default async function SubscriptionPage() {
3437
{/* Plans Comparison Section */}
3538
<div>
3639
<h2 className="text-xl font-semibold mb-4">Plans Comparison</h2>
37-
<PlansComparison initialSubscription={userData.subscription} />
40+
<PlansComparison initialSubscription={userData.subscription} customerData={{
41+
email: user?.email!,
42+
}} />
3843
</div>
3944
</div>
4045
</div>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { NextRequest } from 'next/server';
2+
import { ProcessWebhook } from '@/utils/paddle/process-webhook';
3+
import { getPaddleInstance } from '@/utils/paddle/get-paddle-instance';
4+
import { env } from 'next-runtime-env';
5+
6+
const webhookProcessor = new ProcessWebhook();
7+
8+
export async function POST(request: NextRequest) {
9+
const signature = request.headers.get('paddle-signature') || '';
10+
const rawRequestBody = await request.text();
11+
const privateKey = env('PADDLE_NOTIFICATION_WEBHOOK_SECRET') || '';
12+
13+
try {
14+
if (!signature || !rawRequestBody) {
15+
return Response.json({ error: 'Missing signature from header' }, { status: 400 });
16+
}
17+
18+
const paddle = getPaddleInstance();
19+
const eventData = await paddle.webhooks.unmarshal(rawRequestBody, privateKey, signature);
20+
const eventName = eventData?.eventType ?? 'Unknown event';
21+
22+
if (eventData) {
23+
await webhookProcessor.processEvent(eventData);
24+
}
25+
26+
return Response.json({ status: 200, eventName });
27+
} catch (e) {
28+
console.log(e);
29+
return Response.json({ error: 'Internal server error' }, { status: 500 });
30+
}
31+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { env } from "next-runtime-env";
2+
import {useCallback, useEffect, useState } from "react";
3+
import {initializePaddle, Paddle} from "@paddle/paddle-js";
4+
5+
6+
const environment = env('NEXT_PUBLIC_PADDLE_ENV') as "sandbox";
7+
const token = env('NEXT_PUBLIC_PADDLE_CLIENT_TOKEN')!;
8+
9+
export function usePaddle() {
10+
const [paddle, setPaddle] = useState<Paddle | null>(null);
11+
const [error, setError] = useState<Error | null>(null);
12+
13+
useEffect(() => {
14+
let isMounted = true;
15+
16+
initializePaddle({ environment, token })
17+
.then((instance) => {
18+
if (isMounted && instance) {
19+
setPaddle(instance);
20+
}
21+
})
22+
.catch((err) => {
23+
if (isMounted) {
24+
setError(err as Error);
25+
console.error('Failed to initialize Paddle:', err);
26+
}
27+
});
28+
29+
return () => {
30+
isMounted = false;
31+
};
32+
}, []);
33+
34+
const openCheckout = useCallback(
35+
(options: Parameters<Paddle['Checkout']['open']>[0]) => {
36+
if (!paddle) {
37+
console.warn('Paddle not initialized yet');
38+
return;
39+
}
40+
paddle.Checkout.open(options);
41+
},
42+
[paddle]
43+
);
44+
45+
return { paddle, openCheckout, error } as const;
46+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Environment, LogLevel, Paddle, PaddleOptions } from '@paddle/paddle-node-sdk';
2+
import {env} from "next-runtime-env";
3+
4+
export function getPaddleInstance() {
5+
const paddleOptions: PaddleOptions = {
6+
environment: (env('NEXT_PUBLIC_PADDLE_ENV') as Environment) ?? Environment.sandbox,
7+
logLevel: LogLevel.error,
8+
};
9+
10+
if (!process.env.PADDLE_API_KEY) {
11+
console.error('Paddle API key is missing');
12+
}
13+
14+
return new Paddle(process.env.PADDLE_API_KEY!, paddleOptions);
15+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {
2+
CustomerCreatedEvent,
3+
CustomerUpdatedEvent,
4+
EventEntity,
5+
EventName,
6+
SubscriptionCreatedEvent,
7+
SubscriptionUpdatedEvent,
8+
} from '@paddle/paddle-node-sdk';
9+
import { createClient } from '../supabase/server';
10+
11+
export class ProcessWebhook {
12+
async processEvent(eventData: EventEntity) {
13+
switch (eventData.eventType) {
14+
case EventName.SubscriptionCreated:
15+
case EventName.SubscriptionUpdated:
16+
await this.updateSubscriptionData(eventData);
17+
break;
18+
case EventName.CustomerCreated:
19+
case EventName.CustomerUpdated:
20+
await this.updateCustomerData(eventData);
21+
break;
22+
}
23+
}
24+
25+
private async updateSubscriptionData(eventData: SubscriptionCreatedEvent | SubscriptionUpdatedEvent) {
26+
const supabase = await createClient();
27+
const { error } = await supabase
28+
.from('subscriptions')
29+
.upsert({
30+
subscription_id: eventData.data.id,
31+
subscription_status: eventData.data.status,
32+
price_id: eventData.data.items[0].price?.id ?? '',
33+
product_id: eventData.data.items[0].price?.productId ?? '',
34+
scheduled_change: eventData.data.scheduledChange?.effectiveAt,
35+
customer_id: eventData.data.customerId,
36+
})
37+
.select();
38+
39+
if (error) throw error;
40+
}
41+
42+
private async updateCustomerData(eventData: CustomerCreatedEvent | CustomerUpdatedEvent) {
43+
const supabase = await createClient();
44+
const { error } = await supabase
45+
.from('customers')
46+
.upsert({
47+
customer_id: eventData.data.id,
48+
email: eventData.data.email,
49+
})
50+
.select();
51+
52+
if (error) throw error;
53+
}
54+
}

0 commit comments

Comments
 (0)