committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 942 additions and 5 deletions
-
1api/package.json
-
36api/pnpm-lock.yaml
-
2api/src/app.module.ts
-
18api/src/billing/billing.controller.spec.ts
-
65api/src/billing/billing.controller.ts
-
36api/src/billing/billing.dto.ts
-
27api/src/billing/billing.module.ts
-
18api/src/billing/billing.service.spec.ts
-
400api/src/billing/billing.service.ts
-
33api/src/billing/schemas/plan.schema.ts
-
42api/src/billing/schemas/subscription.schema.ts
-
4api/src/gateway/gateway.module.ts
-
27api/src/gateway/gateway.service.ts
-
3api/src/users/schemas/user.schema.ts
-
37web/app/(app)/checkout/[planName]/page.tsx
-
195web/app/(landing-page)/(components)/pricing-section.tsx
-
3web/app/(landing-page)/page.tsx
@ -0,0 +1,18 @@ |
|||
import { Test, TestingModule } from '@nestjs/testing'; |
|||
import { BillingController } from './billing.controller'; |
|||
|
|||
describe('BillingController', () => { |
|||
let controller: BillingController; |
|||
|
|||
beforeEach(async () => { |
|||
const module: TestingModule = await Test.createTestingModule({ |
|||
controllers: [BillingController], |
|||
}).compile(); |
|||
|
|||
controller = module.get<BillingController>(BillingController); |
|||
}); |
|||
|
|||
it('should be defined', () => { |
|||
expect(controller).toBeDefined(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,65 @@ |
|||
import { Controller, Post, Body, Get, UseGuards, Request } from '@nestjs/common' |
|||
import { BillingService } from './billing.service' |
|||
import { AuthGuard } from 'src/auth/guards/auth.guard' |
|||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger' |
|||
import { |
|||
CheckoutInputDTO, |
|||
CheckoutResponseDTO, |
|||
PlansResponseDTO, |
|||
} from './billing.dto' |
|||
|
|||
@ApiTags('billing') |
|||
@ApiBearerAuth() |
|||
@Controller('billing') |
|||
export class BillingController { |
|||
constructor(private billingService: BillingService) {} |
|||
|
|||
@Get('plans') |
|||
async getPlans(): Promise<PlansResponseDTO> { |
|||
return this.billingService.getPlans() |
|||
} |
|||
|
|||
@Post('checkout') |
|||
@UseGuards(AuthGuard) |
|||
async getCheckoutUrl( |
|||
@Body() payload: CheckoutInputDTO, |
|||
@Request() req: any, |
|||
): Promise<CheckoutResponseDTO> { |
|||
return this.billingService.getCheckoutUrl({ |
|||
user: req.user, |
|||
payload, |
|||
req, |
|||
}) |
|||
} |
|||
|
|||
@Post('webhook/polar') |
|||
async handlePolarWebhook(@Body() data: any, @Request() req: any) { |
|||
const payload = await this.billingService.validatePolarWebhookPayload( |
|||
data, |
|||
req.headers, |
|||
) |
|||
|
|||
// Handle Polar.sh webhook events
|
|||
switch (payload.type) { |
|||
case 'subscription.created': |
|||
await this.billingService.switchPlan({ |
|||
userId: payload.data.userId, |
|||
newPlanName: payload.data?.product?.name || 'pro', |
|||
newPlanPolarProductId: payload.data?.product?.id, |
|||
}) |
|||
break |
|||
|
|||
// @ts-ignore
|
|||
case 'subscription.cancelled': |
|||
await this.billingService.switchPlan({ |
|||
// @ts-ignore
|
|||
userId: payload?.data?.userId, |
|||
newPlanName: 'free', |
|||
}) |
|||
break |
|||
default: |
|||
console.log('Unhandled event type:', payload.type) |
|||
break |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
import { ApiProperty } from '@nestjs/swagger' |
|||
|
|||
export class PlanDTO { |
|||
@ApiProperty({ type: String }) |
|||
name: string |
|||
|
|||
@ApiProperty({ type: Number }) |
|||
monthlyPrice: number |
|||
|
|||
@ApiProperty({ type: Number }) |
|||
yearlyPrice?: number |
|||
|
|||
@ApiProperty({ type: String }) |
|||
polarProductId: string |
|||
|
|||
@ApiProperty({ type: Boolean }) |
|||
isActive: boolean |
|||
} |
|||
|
|||
export class PlansResponseDTO extends Array<PlanDTO> {} |
|||
|
|||
export class CheckoutInputDTO { |
|||
@ApiProperty({ type: String, required: true }) |
|||
planName: string |
|||
|
|||
@ApiProperty({ type: String }) |
|||
discountId?: string |
|||
|
|||
@ApiProperty({ type: Boolean }) |
|||
isYearly?: boolean |
|||
} |
|||
|
|||
export class CheckoutResponseDTO { |
|||
@ApiProperty({ type: String }) |
|||
redirectUrl: string |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
import { Module, forwardRef } from '@nestjs/common' |
|||
import { BillingController } from './billing.controller' |
|||
import { BillingService } from './billing.service' |
|||
import { PlanSchema } from './schemas/plan.schema' |
|||
import { SubscriptionSchema } from './schemas/subscription.schema' |
|||
import { Plan } from './schemas/plan.schema' |
|||
import { Subscription } from './schemas/subscription.schema' |
|||
import { MongooseModule } from '@nestjs/mongoose' |
|||
import { AuthModule } from 'src/auth/auth.module' |
|||
import { UsersModule } from 'src/users/users.module' |
|||
import { GatewayModule } from 'src/gateway/gateway.module' |
|||
|
|||
@Module({ |
|||
imports: [ |
|||
MongooseModule.forFeature([ |
|||
{ name: Plan.name, schema: PlanSchema }, |
|||
{ name: Subscription.name, schema: SubscriptionSchema }, |
|||
]), |
|||
AuthModule, |
|||
UsersModule, |
|||
forwardRef(() => GatewayModule), |
|||
], |
|||
controllers: [BillingController], |
|||
providers: [BillingService], |
|||
exports: [BillingService], |
|||
}) |
|||
export class BillingModule {} |
|||
@ -0,0 +1,18 @@ |
|||
import { Test, TestingModule } from '@nestjs/testing'; |
|||
import { BillingService } from './billing.service'; |
|||
|
|||
describe('BillingService', () => { |
|||
let service: BillingService; |
|||
|
|||
beforeEach(async () => { |
|||
const module: TestingModule = await Test.createTestingModule({ |
|||
providers: [BillingService], |
|||
}).compile(); |
|||
|
|||
service = module.get<BillingService>(BillingService); |
|||
}); |
|||
|
|||
it('should be defined', () => { |
|||
expect(service).toBeDefined(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,400 @@ |
|||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common' |
|||
import { InjectModel } from '@nestjs/mongoose' |
|||
import { Model } from 'mongoose' |
|||
import { Plan, PlanDocument } from './schemas/plan.schema' |
|||
import { |
|||
Subscription, |
|||
SubscriptionDocument, |
|||
} from './schemas/subscription.schema' |
|||
import { Polar } from '@polar-sh/sdk' |
|||
import { User, UserDocument } from 'src/users/schemas/user.schema' |
|||
import { CheckoutResponseDTO, PlanDTO } from './billing.dto' |
|||
import { SMSDocument } from 'src/gateway/schemas/sms.schema' |
|||
import { SMS } from 'src/gateway/schemas/sms.schema' |
|||
import { validateEvent } from '@polar-sh/sdk/webhooks' |
|||
|
|||
@Injectable() |
|||
export class BillingService { |
|||
private polarApi |
|||
|
|||
constructor( |
|||
@InjectModel(Plan.name) private planModel: Model<PlanDocument>, |
|||
@InjectModel(Subscription.name) |
|||
private subscriptionModel: Model<SubscriptionDocument>, |
|||
@InjectModel(User.name) private userModel: Model<UserDocument>, |
|||
@InjectModel(SMS.name) private smsModel: Model<SMSDocument>, |
|||
) { |
|||
this.initializePlans() |
|||
this.polarApi = new Polar({ |
|||
accessToken: process.env.POLAR_ACCESS_TOKEN ?? '', |
|||
server: |
|||
process.env.POLAR_SERVER === 'production' ? 'production' : 'sandbox', |
|||
}) |
|||
} |
|||
|
|||
private async initializePlans() { |
|||
const plans = await this.planModel.find() |
|||
if (plans.length === 0) { |
|||
await this.planModel.create([ |
|||
{ |
|||
name: 'free', |
|||
dailyLimit: 50, |
|||
monthlyLimit: 1000, |
|||
bulkSendLimit: 50, |
|||
monthlyPrice: 0, |
|||
yearlyPrice: 0, |
|||
}, |
|||
{ |
|||
name: 'pro', |
|||
dailyLimit: -1, // -1 means unlimited
|
|||
monthlyLimit: 5000, |
|||
bulkSendLimit: -1, |
|||
monthlyPrice: 690, // $6.90
|
|||
yearlyPrice: 6900, // $69.00
|
|||
}, |
|||
{ |
|||
name: 'custom', |
|||
dailyLimit: -1, |
|||
monthlyLimit: -1, |
|||
bulkSendLimit: -1, |
|||
monthlyPrice: 0, // Custom pricing
|
|||
yearlyPrice: 0, // Custom pricing
|
|||
}, |
|||
]) |
|||
} |
|||
} |
|||
|
|||
async getPlans(): Promise<PlanDTO[]> { |
|||
return this.planModel.find({ |
|||
isActive: true, |
|||
}) |
|||
} |
|||
|
|||
async getCheckoutUrl({ |
|||
user, |
|||
payload, |
|||
req, |
|||
}: { |
|||
user: any |
|||
payload: any |
|||
req: any |
|||
}): Promise<CheckoutResponseDTO> { |
|||
const isYearly = payload.isYearly |
|||
|
|||
const selectedPlan = await this.planModel.findOne({ |
|||
name: payload.planName, |
|||
}) |
|||
|
|||
if (!selectedPlan?.polarProductId) { |
|||
throw new Error('Plan cannot be purchased') |
|||
} |
|||
|
|||
// const product = await this.polarApi.products.get(selectedPlan.polarProductId)
|
|||
|
|||
const discountId = |
|||
payload.discountId ?? '48f62ff7-3cd8-46ec-8ca7-2e570dc9c522' |
|||
|
|||
try { |
|||
const checkoutOptions: any = { |
|||
productId: selectedPlan.polarProductId, |
|||
// productPriceId: isYearly
|
|||
// ? selectedPlan.yearlyPolarProductId
|
|||
// : selectedPlan.monthlyPolarProductId,
|
|||
successUrl: `${process.env.FRONTEND_URL}/checkout-success?checkout_id={CHECKOUT_ID}`, |
|||
cancelUrl: `${process.env.FRONTEND_URL}/checkout-cancel?checkout_id={CHECKOUT_ID}`, |
|||
customerEmail: user.email, |
|||
customerName: user.name, |
|||
customerIpAddress: req.ip, |
|||
metadata: { |
|||
userId: user._id?.toString(), |
|||
}, |
|||
} |
|||
const discount = await this.polarApi.discounts.get({ |
|||
id: discountId, |
|||
}) |
|||
if (discount) { |
|||
checkoutOptions.discountId = discount.id |
|||
} |
|||
|
|||
const checkout = |
|||
await this.polarApi.checkouts.custom.create(checkoutOptions) |
|||
console.log(checkout) |
|||
return { redirectUrl: checkout.url } |
|||
} catch (error) { |
|||
console.error(error) |
|||
throw new Error('Failed to create checkout') |
|||
} |
|||
} |
|||
|
|||
async getActiveSubscription(userId: string) { |
|||
const user = await this.userModel.findById(userId) |
|||
const plans = await this.planModel.find() |
|||
|
|||
const customPlan = plans.find((plan) => plan.name === 'custom') |
|||
const proPlan = plans.find((plan) => plan.name === 'pro') |
|||
const freePlan = plans.find((plan) => plan.name === 'free') |
|||
|
|||
const customPlanSubscription = await this.subscriptionModel.findOne({ |
|||
user: userId, |
|||
plan: customPlan._id, |
|||
isActive: true, |
|||
}) |
|||
|
|||
if (customPlanSubscription) { |
|||
return customPlanSubscription |
|||
} |
|||
|
|||
const proPlanSubscription = await this.subscriptionModel.findOne({ |
|||
user: userId, |
|||
plan: proPlan._id, |
|||
isActive: true, |
|||
}) |
|||
|
|||
if (proPlanSubscription) { |
|||
return proPlanSubscription |
|||
} |
|||
|
|||
const freePlanSubscription = await this.subscriptionModel.findOne({ |
|||
user: userId, |
|||
plan: freePlan._id, |
|||
isActive: true, |
|||
}) |
|||
|
|||
if (freePlanSubscription) { |
|||
return freePlanSubscription |
|||
} |
|||
|
|||
// create a new free plan subscription
|
|||
const newFreePlanSubscription = await this.subscriptionModel.create({ |
|||
user: userId, |
|||
plan: freePlan._id, |
|||
isActive: true, |
|||
startDate: new Date(), |
|||
}) |
|||
|
|||
return newFreePlanSubscription |
|||
} |
|||
|
|||
async getUserLimits(userId: string) { |
|||
const subscription = await this.subscriptionModel |
|||
.findOne({ user: userId, isActive: true }) |
|||
.populate('plan') |
|||
|
|||
if (!subscription) { |
|||
// Default to free plan limits
|
|||
const freePlan = await this.planModel.findOne({ name: 'free' }) |
|||
return { |
|||
dailyLimit: freePlan.dailyLimit, |
|||
monthlyLimit: freePlan.monthlyLimit, |
|||
bulkSendLimit: freePlan.bulkSendLimit, |
|||
} |
|||
} |
|||
|
|||
// For custom plans, use custom limits if set
|
|||
return { |
|||
dailyLimit: subscription.customDailyLimit || subscription.plan.dailyLimit, |
|||
monthlyLimit: |
|||
subscription.customMonthlyLimit || subscription.plan.monthlyLimit, |
|||
bulkSendLimit: |
|||
subscription.customBulkSendLimit || subscription.plan.bulkSendLimit, |
|||
} |
|||
} |
|||
|
|||
async switchPlan({ |
|||
userId, |
|||
newPlanName, |
|||
newPlanPolarProductId, |
|||
}: { |
|||
userId: string |
|||
newPlanName?: string |
|||
newPlanPolarProductId?: string |
|||
}) { |
|||
// switch the subscription to the new one
|
|||
// deactivate the current active subscription
|
|||
// activate the new subscription if it exists or create a new one
|
|||
|
|||
// get the plan from the polarProductId
|
|||
let plan: PlanDocument |
|||
if (newPlanPolarProductId) { |
|||
plan = await this.planModel.findOne({ |
|||
polarProductId: newPlanPolarProductId, |
|||
}) |
|||
} else if (newPlanName) { |
|||
plan = await this.planModel.findOne({ name: newPlanName }) |
|||
} |
|||
|
|||
if (!plan) { |
|||
throw new Error('Plan not found') |
|||
} |
|||
|
|||
// if any of the subscriptions that are not the new plan are active, deactivate them
|
|||
await this.subscriptionModel.updateMany( |
|||
{ user: userId, plan: { $ne: plan._id }, isActive: true }, |
|||
{ isActive: false, endDate: new Date() }, |
|||
) |
|||
|
|||
// create or update the new subscription
|
|||
await this.subscriptionModel.updateOne( |
|||
{ user: userId, plan: plan._id }, |
|||
{ isActive: true }, |
|||
{ upsert: true }, |
|||
) |
|||
} |
|||
|
|||
async canPerformAction( |
|||
userId: string, |
|||
action: 'send_sms' | 'receive_sms' | 'bulk_send_sms', |
|||
value: number, |
|||
) { |
|||
|
|||
|
|||
|
|||
// TODO: temporary allow all requests until march 15 2025
|
|||
if (new Date() < new Date('2025-03-15')) { |
|||
return true |
|||
} |
|||
|
|||
const user = await this.userModel.findById(userId) |
|||
if (user.isBanned) { |
|||
throw new HttpException( |
|||
{ |
|||
message: 'Sorry, we cannot process your request at the moment', |
|||
}, |
|||
HttpStatus.BAD_REQUEST, |
|||
) |
|||
} |
|||
|
|||
if (user.emailVerifiedAt === null) { |
|||
// throw new HttpException(
|
|||
// {
|
|||
// message: 'Please verify your email to continue',
|
|||
// },
|
|||
// HttpStatus.BAD_REQUEST,
|
|||
// )
|
|||
} |
|||
|
|||
let plan: PlanDocument |
|||
const subscription = await this.subscriptionModel.findOne({ |
|||
user: userId, |
|||
isActive: true, |
|||
}) |
|||
|
|||
if (!subscription) { |
|||
plan = await this.planModel.findOne({ name: 'free' }) |
|||
} else { |
|||
plan = await this.planModel.findById(subscription.plan) |
|||
} |
|||
|
|||
if (plan.name === 'custom') { |
|||
// TODO: for now custom plans are unlimited
|
|||
return true |
|||
} |
|||
|
|||
let hasReachedLimit = false |
|||
let message = '' |
|||
|
|||
const processedSmsToday = await this.smsModel.countDocuments({ |
|||
'device.user': userId, |
|||
createdAt: { $gte: new Date(new Date().setHours(0, 0, 0, 0)) }, |
|||
}) |
|||
const processedSmsLastMonth = await this.smsModel.countDocuments({ |
|||
'device.user': userId, |
|||
createdAt: { |
|||
$gte: new Date(new Date().setMonth(new Date().getMonth() - 1)), |
|||
}, |
|||
}) |
|||
|
|||
if (['send_sms', 'receive_sms', 'bulk_send_sms'].includes(action)) { |
|||
// check daily limit
|
|||
if ( |
|||
plan.dailyLimit !== -1 && |
|||
processedSmsToday + value > plan.dailyLimit |
|||
) { |
|||
hasReachedLimit = true |
|||
message = `You have reached your daily limit, you only have ${plan.dailyLimit - processedSmsToday} remaining` |
|||
} |
|||
|
|||
// check monthly limit
|
|||
if ( |
|||
plan.monthlyLimit !== -1 && |
|||
processedSmsLastMonth + value > plan.monthlyLimit |
|||
) { |
|||
hasReachedLimit = true |
|||
message = `You have reached your monthly limit, you only have ${plan.monthlyLimit - processedSmsLastMonth} remaining` |
|||
} |
|||
|
|||
// check bulk send limit
|
|||
if (plan.bulkSendLimit !== -1 && value > plan.bulkSendLimit) { |
|||
hasReachedLimit = true |
|||
message = `You can only send ${plan.bulkSendLimit} sms at a time` |
|||
} |
|||
} |
|||
|
|||
if (hasReachedLimit) { |
|||
throw new HttpException( |
|||
{ |
|||
message: message, |
|||
hasReachedLimit: true, |
|||
dailyLimit: plan.dailyLimit, |
|||
dailyRemaining: plan.dailyLimit - processedSmsToday, |
|||
monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth, |
|||
bulkSendLimit: plan.bulkSendLimit, |
|||
monthlyLimit: plan.monthlyLimit, |
|||
}, |
|||
HttpStatus.BAD_REQUEST, |
|||
) |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
async getUsage(userId: string) { |
|||
const subscription = await this.subscriptionModel.findOne({ |
|||
user: userId, |
|||
isActive: true, |
|||
}) |
|||
|
|||
const plan = await this.planModel.findById(subscription.plan) |
|||
|
|||
const processedSmsToday = await this.smsModel.countDocuments({ |
|||
'device.user': userId, |
|||
createdAt: { $gte: new Date(new Date().setHours(0, 0, 0, 0)) }, |
|||
}) |
|||
|
|||
const processedSmsLastMonth = await this.smsModel.countDocuments({ |
|||
'device.user': userId, |
|||
createdAt: { |
|||
$gte: new Date(new Date().setMonth(new Date().getMonth() - 1)), |
|||
}, |
|||
}) |
|||
|
|||
return { |
|||
processedSmsToday, |
|||
processedSmsLastMonth, |
|||
dailyLimit: plan.dailyLimit, |
|||
monthlyLimit: plan.monthlyLimit, |
|||
bulkSendLimit: plan.bulkSendLimit, |
|||
dailyRemaining: plan.dailyLimit - processedSmsToday, |
|||
monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth, |
|||
} |
|||
} |
|||
|
|||
async validatePolarWebhookPayload(payload: any, headers: any) { |
|||
const webhookHeaders = { |
|||
'webhook-id': headers['webhook-id'] ?? '', |
|||
'webhook-timestamp': headers['webhook-timestamp'] ?? '', |
|||
'webhook-signature': headers['webhook-signature'] ?? '', |
|||
} |
|||
|
|||
try { |
|||
const webhookPayload = validateEvent( |
|||
payload, |
|||
webhookHeaders, |
|||
process.env.POLAR_WEBHOOK_SECRET, |
|||
) |
|||
return webhookPayload |
|||
} catch (error) { |
|||
throw new Error('Invalid webhook payload') |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' |
|||
import { Document } from 'mongoose' |
|||
|
|||
export type PlanDocument = Plan & Document |
|||
|
|||
@Schema({ timestamps: true }) |
|||
export class Plan { |
|||
@Prop({ required: true, unique: true }) |
|||
name: string // free, pro, custom
|
|||
|
|||
@Prop({ required: true }) |
|||
dailyLimit: number |
|||
|
|||
@Prop({ required: true }) |
|||
monthlyLimit: number |
|||
|
|||
@Prop({ required: true }) |
|||
bulkSendLimit: number |
|||
|
|||
@Prop({ required: true }) |
|||
monthlyPrice: number // in cents
|
|||
|
|||
@Prop({}) |
|||
yearlyPrice: number // in cents
|
|||
|
|||
@Prop({ type: String, unique: true }) |
|||
polarProductId?: string |
|||
|
|||
@Prop({ type: Boolean, default: true }) |
|||
isActive: boolean |
|||
} |
|||
|
|||
export const PlanSchema = SchemaFactory.createForClass(Plan) |
|||
@ -0,0 +1,42 @@ |
|||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' |
|||
import { Document, Types } from 'mongoose' |
|||
import { User } from '../../users/schemas/user.schema' |
|||
import { Plan } from './plan.schema' |
|||
|
|||
export type SubscriptionDocument = Subscription & Document |
|||
|
|||
@Schema({ timestamps: true }) |
|||
export class Subscription { |
|||
@Prop({ type: Types.ObjectId, ref: User.name, required: true }) |
|||
user: User |
|||
|
|||
@Prop({ type: Types.ObjectId, ref: Plan.name, required: true }) |
|||
plan: Plan |
|||
|
|||
// @Prop()
|
|||
// polarSubscriptionId?: string
|
|||
|
|||
@Prop({ type: Date }) |
|||
startDate: Date |
|||
|
|||
@Prop({ type: Date }) |
|||
endDate: Date |
|||
|
|||
@Prop({ type: Boolean, default: true }) |
|||
isActive: boolean |
|||
|
|||
// Custom limits for custom plans
|
|||
@Prop({ type: Number }) |
|||
customDailyLimit?: number |
|||
|
|||
@Prop({ type: Number }) |
|||
customMonthlyLimit?: number |
|||
|
|||
@Prop({ type: Number }) |
|||
customBulkSendLimit?: number |
|||
} |
|||
|
|||
export const SubscriptionSchema = SchemaFactory.createForClass(Subscription) |
|||
|
|||
// a user can only have one active subscription at a time
|
|||
SubscriptionSchema.index({ user: 1, isActive: 1 }, { unique: true }) |
|||
@ -0,0 +1,37 @@ |
|||
'use client' |
|||
|
|||
import { useState, useEffect } from 'react' |
|||
import httpBrowserClient from '@/lib/httpBrowserClient' |
|||
|
|||
export default function CheckoutPage({ params }) { |
|||
const [error, setError] = useState<string | null>(null) |
|||
|
|||
const planName = params.planName as string |
|||
|
|||
useEffect(() => { |
|||
const initiateCheckout = async () => { |
|||
try { |
|||
const response = await httpBrowserClient.post('/billing/checkout', { |
|||
planName, |
|||
}) |
|||
|
|||
window.location.href = response.data?.redirectUrl |
|||
} catch (error) { |
|||
setError('Failed to create checkout session. Please try again.') |
|||
console.error(error) |
|||
} |
|||
} |
|||
|
|||
initiateCheckout() |
|||
}, [planName]) |
|||
|
|||
if (error) { |
|||
return <div className='text-red-500'>{error}</div> |
|||
} |
|||
|
|||
return ( |
|||
<div className='flex justify-center items-center min-h-[50vh]'> |
|||
processing... |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,195 @@ |
|||
'use client' |
|||
|
|||
import { Button } from '@/components/ui/button' |
|||
import { Check } from 'lucide-react' |
|||
import Link from 'next/link' |
|||
|
|||
const PricingSection = () => { |
|||
return ( |
|||
<section |
|||
id='pricing' |
|||
className='py-16 bg-gradient-to-b from-white to-gray-50 dark:from-gray-900 dark:to-gray-950' |
|||
> |
|||
<div className='container px-4 mx-auto'> |
|||
<div className='max-w-2xl mx-auto mb-12 text-center'> |
|||
<h2 className='text-3xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-4xl'> |
|||
Pricing |
|||
</h2> |
|||
<p className='mt-3 text-base text-gray-600 dark:text-gray-400'> |
|||
Choose the perfect plan for your messaging needs |
|||
</p> |
|||
</div> |
|||
|
|||
<div className='grid gap-6 lg:grid-cols-3'> |
|||
{/* Free Plan */} |
|||
<div className='flex flex-col p-5 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow'> |
|||
<h3 className='text-xl font-bold text-gray-900 dark:text-white'> |
|||
Free |
|||
</h3> |
|||
<p className='mt-3 text-sm text-gray-600 dark:text-gray-400'> |
|||
Perfect for getting started |
|||
</p> |
|||
<div className='my-6'> |
|||
<span className='text-3xl font-bold text-gray-900 dark:text-white'> |
|||
$0 |
|||
</span> |
|||
<span className='text-gray-600 dark:text-gray-400'>/month</span> |
|||
</div> |
|||
|
|||
<ul className='mb-6 space-y-3 flex-1'> |
|||
<Feature text='Send and receive SMS Messages' /> |
|||
<Feature text='Register 1 active device' /> |
|||
<Feature text='Max 50 messages per day' /> |
|||
<Feature text='Up to 500 messages per month' /> |
|||
<Feature text='Up to 50 recipients in bulk' /> |
|||
<Feature text='Webhook notifications' /> |
|||
<Feature text='Basic support' /> |
|||
</ul> |
|||
|
|||
<Button asChild className='w-full' variant='outline'> |
|||
<Link href='/dashboard?selectedPlan=free'>Get Started</Link> |
|||
</Button> |
|||
</div> |
|||
|
|||
{/* Pro Plan */} |
|||
<div className='flex flex-col p-5 bg-slate-800 dark:bg-gray-800/60 text-white rounded-lg border border-gray-800 dark:border-gray-600 shadow-lg scale-105 hover:scale-105 transition-transform'> |
|||
<div className='inline-block px-3 py-1 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-xs font-semibold mb-3'> |
|||
MOST POPULAR |
|||
</div> |
|||
<h3 className='text-xl font-bold'>Pro</h3> |
|||
<p className='mt-3 text-sm text-gray-300'> |
|||
Ideal for most use-cases |
|||
</p> |
|||
|
|||
<div className='my-6'> |
|||
<div className='grid grid-cols-2 gap-2'> |
|||
{/* Monthly pricing */} |
|||
<div className='space-y-2'> |
|||
<div className='flex items-baseline'> |
|||
<span className='text-xs text-gray-400 uppercase'> |
|||
Monthly |
|||
</span> |
|||
</div> |
|||
<div> |
|||
<div className='space-y-1'> |
|||
<div className='text-lg text-gray-400 line-through'> |
|||
$9.90 |
|||
</div> |
|||
<div className='flex items-baseline gap-1'> |
|||
<span className='text-3xl font-bold'>$6.90</span> |
|||
<span className='text-gray-300'>/month</span> |
|||
</div> |
|||
</div> |
|||
<span className='mt-1 inline-block bg-green-500/10 text-green-400 text-xs px-2 py-0.5 rounded-full border border-green-500/20'> |
|||
Save 30% |
|||
</span> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Yearly pricing */} |
|||
<div className='space-y-2 border-l border-gray-800 pl-2'> |
|||
<div className='flex items-baseline gap-2'> |
|||
<span className='text-xs text-gray-400 uppercase'> |
|||
Yearly |
|||
</span> |
|||
<span className='text-xs text-green-400'> |
|||
(2 months free) |
|||
</span> |
|||
</div> |
|||
<div> |
|||
<div className='space-y-1'> |
|||
<div className='text-lg text-gray-400 line-through'> |
|||
$99 |
|||
</div> |
|||
<div className='flex items-baseline gap-1'> |
|||
<span className='text-3xl font-bold'>$69</span> |
|||
<span className='text-gray-300'>/year</span> |
|||
</div> |
|||
</div> |
|||
<span className='mt-1 inline-block bg-green-500/10 text-green-400 text-xs px-2 py-0.5 rounded-full border border-green-500/20'> |
|||
Save 42% |
|||
</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<ul className='mb-6 space-y-3 flex-1'> |
|||
<Feature text='Everything in Free' light /> |
|||
<Feature text='Register upto 5 active devices' light /> |
|||
<Feature |
|||
text='Unlimited daily messages (within monthly quota)' |
|||
light |
|||
/> |
|||
<Feature text='Up to 5,000 messages per month' light /> |
|||
<Feature text='No bulk SMS recipient limits' light /> |
|||
<Feature text='Priority support' light /> |
|||
</ul> |
|||
|
|||
<Button |
|||
asChild |
|||
className='w-full bg-white text-black hover:bg-gray-100' |
|||
> |
|||
<Link href='/dashboard?selectedPlan=pro'>Upgrade to Pro</Link> |
|||
</Button> |
|||
</div> |
|||
|
|||
{/* Custom Plan */} |
|||
<div className='flex flex-col p-5 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow'> |
|||
<h3 className='text-xl font-bold text-gray-900 dark:text-white'> |
|||
Custom |
|||
</h3> |
|||
<p className='mt-3 text-sm text-gray-600 dark:text-gray-400'> |
|||
For more specific needs or custom integrations |
|||
</p> |
|||
<div className='my-6'> |
|||
<span className='text-3xl font-bold text-gray-900 dark:text-white'> |
|||
Custom |
|||
</span> |
|||
<span className='text-gray-600 dark:text-gray-400'> pricing</span> |
|||
</div> |
|||
|
|||
<ul className='mb-6 space-y-3 flex-1'> |
|||
<Feature text='Custom message limits' /> |
|||
<Feature text='Custom bulk limits' /> |
|||
<Feature text='Custom integrations' /> |
|||
<Feature text='SLA agreement' /> |
|||
<Feature text='Dedicated support' /> |
|||
</ul> |
|||
|
|||
<Button asChild className='w-full' variant='outline'> |
|||
<Link href='mailto:sales@textbee.dev?subject=Interested%20in%20TextBee%20Custom%20Plan'> |
|||
Contact Sales |
|||
</Link> |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
) |
|||
} |
|||
|
|||
const Feature = ({ |
|||
text, |
|||
light = false, |
|||
}: { |
|||
text: string |
|||
light?: boolean |
|||
}) => ( |
|||
<li className='flex items-center'> |
|||
<Check |
|||
className={`h-4 w-4 ${ |
|||
light ? 'text-green-400' : 'text-green-500 dark:text-green-400' |
|||
} mr-2`}
|
|||
/> |
|||
<span |
|||
className={`text-sm ${ |
|||
light ? 'text-gray-300' : 'text-gray-600 dark:text-gray-300' |
|||
}`}
|
|||
> |
|||
{text} |
|||
</span> |
|||
</li> |
|||
) |
|||
|
|||
export default PricingSection |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue