40346-vm/frontend/src/pages/subscription.tsx
2026-06-29 07:16:39 +00:00

276 lines
11 KiB
TypeScript

import {
mdiArrowUpBoldCircleOutline,
mdiCheckCircleOutline,
mdiCreditCardOutline,
mdiRefresh,
} from '@mdi/js'
import axios from 'axios'
import Head from 'next/head'
import React, { ReactElement, useEffect, useState } from 'react'
import BaseButton from '../components/BaseButton'
import CardBox from '../components/CardBox'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import LayoutAuthenticated from '../layouts/Authenticated'
import { getPageTitle } from '../config'
import { SubscriptionPlan } from '../subscriptionPlans'
type SubscriptionStatusResponse = {
subscription: {
planId: string
planName: string
status: string
effectiveStatus: string
isActive: boolean
trialEndsAt?: string | null
trialDaysLeft?: number | null
priceMonthly: number
currency: string
}
usage: {
monthlyReviewRequests: number
businesses: number
teamMembers: number
paymentConnectors: number
periodStart?: string
periodEnd?: string
}
limits: SubscriptionPlan['limits']
plans: SubscriptionPlan[]
}
const usageLabels: Array<{
key: keyof SubscriptionStatusResponse['usage']
limitKey: keyof SubscriptionPlan['limits']
label: string
}> = [
{ key: 'monthlyReviewRequests', limitKey: 'monthlyReviewRequests', label: 'Review requests this month' },
{ key: 'businesses', limitKey: 'businesses', label: 'Businesses / locations' },
{ key: 'teamMembers', limitKey: 'teamMembers', label: 'Team members' },
{ key: 'paymentConnectors', limitKey: 'paymentConnectors', label: 'Connected payment providers' },
]
function formatDate(value?: string | null) {
if (!value) return 'Not set'
return new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(value))
}
function formatLimit(value: number) {
return value.toLocaleString()
}
export default function SubscriptionPage() {
const [status, setStatus] = useState<SubscriptionStatusResponse | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [selectingPlanId, setSelectingPlanId] = useState('')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const loadStatus = async () => {
setIsLoading(true)
try {
const response = await axios.get('/subscription/me')
setStatus(response.data)
setError('')
} catch (requestError) {
console.error('Failed to load subscription status:', requestError)
setError('Could not load your subscription status. Please refresh and try again.')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadStatus()
}, [])
const selectPlan = async (planId: string) => {
setSelectingPlanId(planId)
setError('')
setMessage('')
try {
const response = await axios.post('/subscription/select-plan', { planId })
setStatus(response.data)
setMessage(`Your trial plan is now ${response.data.subscription.planName}.`)
} catch (requestError) {
console.error('Failed to select subscription plan:', requestError)
if (axios.isAxiosError(requestError) && requestError.response?.data) {
setError(String(requestError.response.data))
} else {
setError('Could not update your plan. Please try again.')
}
} finally {
setSelectingPlanId('')
}
}
const currentPlanId = status?.subscription.planId
return (
<>
<Head>
<title>{getPageTitle('Subscription')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiCreditCardOutline}
title='Subscription and limits'
main
>
<BaseButton
icon={mdiRefresh}
label='Refresh'
color='whiteDark'
onClick={loadStatus}
/>
</SectionTitleLineWithButton>
{message && (
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
{message}
</div>
)}
{error && (
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
{error}
</div>
)}
{isLoading && !status ? (
<CardBox>Loading subscription details...</CardBox>
) : status ? (
<>
<CardBox className='mb-6 overflow-hidden border-0 bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 text-white shadow-2xl'>
<div className='grid gap-6 lg:grid-cols-[1fr_0.8fr] lg:items-center'>
<div>
<p className='text-sm font-black uppercase tracking-[0.3em] text-emerald-300'>
Current plan
</p>
<h2 className='mt-3 text-4xl font-black tracking-tight md:text-5xl'>
{status.subscription.planName}
</h2>
<p className='mt-3 max-w-2xl text-slate-200'>
Status: <strong>{status.subscription.effectiveStatus}</strong>. Trial ends {formatDate(status.subscription.trialEndsAt)}
{status.subscription.trialDaysLeft !== null && status.subscription.trialDaysLeft !== undefined
? ` (${status.subscription.trialDaysLeft} days left)`
: ''}
.
</p>
</div>
<div className='rounded-3xl bg-white/10 p-6 ring-1 ring-white/15'>
<p className='text-sm font-bold text-slate-300'>Monthly price</p>
<p className='mt-2 text-5xl font-black'>${status.subscription.priceMonthly}</p>
<p className='mt-1 text-sm text-slate-300'>per month after trial</p>
</div>
</div>
</CardBox>
<div className='mb-6 grid gap-6 lg:grid-cols-2'>
{usageLabels.map((item) => {
const used = Number(status.usage[item.key]) || 0
const limit = Number(status.limits[item.limitKey]) || 1
const percent = Math.min(100, Math.round((used / limit) * 100))
const isNearLimit = percent >= 80
return (
<CardBox key={item.key} className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
<div className='mb-3 flex items-center justify-between gap-3'>
<p className='font-black text-slate-900 dark:text-white'>{item.label}</p>
<p className={isNearLimit ? 'font-black text-amber-600' : 'font-black text-emerald-600'}>
{formatLimit(used)} / {formatLimit(limit)}
</p>
</div>
<div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'>
<div
className={isNearLimit ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'}
style={{ width: `${percent}%` }}
/>
</div>
</CardBox>
)
})}
</div>
<div className='grid gap-6 lg:grid-cols-2'>
{status.plans.map((plan) => {
const isCurrent = currentPlanId === plan.id
const isPro = plan.id === 'pro'
return (
<CardBox
key={plan.id}
className={`relative overflow-hidden border-0 shadow-2xl ${isPro ? 'ring-2 ring-indigo-600' : 'ring-1 ring-slate-200 dark:ring-dark-700'}`}
cardBoxClassName='p-0'
>
{isPro && (
<div className='absolute right-6 top-6 rounded-full bg-indigo-600 px-4 py-1 text-sm font-black text-white'>
Pro growth tools
</div>
)}
<div className='p-8'>
<p className='text-sm font-black uppercase tracking-[0.3em] text-slate-400'>Review Flow</p>
<h3 className='mt-3 text-3xl font-black text-slate-900 dark:text-white'>{plan.name}</h3>
<p className='mt-3 min-h-[56px] leading-7 text-slate-500 dark:text-slate-400'>{plan.tagline}</p>
<div className='mt-8 flex items-end gap-2'>
<span className='text-5xl font-black tracking-tight text-slate-900 dark:text-white'>${plan.priceMonthly}</span>
<span className='pb-2 font-bold text-slate-500'>/month</span>
</div>
<div className='mt-8 grid gap-3 rounded-3xl bg-slate-50 p-5 dark:bg-dark-800 sm:grid-cols-2'>
<div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.monthlyReviewRequests)}</p>
<p className='text-sm text-slate-500'>requests/month</p>
</div>
<div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.businesses)}</p>
<p className='text-sm text-slate-500'>businesses</p>
</div>
<div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.teamMembers)}</p>
<p className='text-sm text-slate-500'>team members</p>
</div>
<div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.paymentConnectors)}</p>
<p className='text-sm text-slate-500'>connectors</p>
</div>
</div>
<BaseButton
icon={isCurrent ? mdiCheckCircleOutline : mdiArrowUpBoldCircleOutline}
label={isCurrent ? 'Current plan' : `Switch trial to ${plan.name}`}
color={isCurrent ? 'success' : isPro ? 'info' : 'whiteDark'}
className='mt-8 w-full'
disabled={isCurrent || Boolean(selectingPlanId)}
onClick={() => selectPlan(plan.id)}
/>
</div>
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
<p className='mb-5 text-sm font-black uppercase tracking-[0.25em] text-emerald-300'>Included</p>
<div className='grid gap-3'>
{plan.features.map((feature) => (
<div key={feature} className='flex items-start gap-3'>
<span className='mt-1 text-emerald-300'></span>
<span className='font-semibold text-slate-100'>{feature}</span>
</div>
))}
</div>
</div>
</CardBox>
)
})}
</div>
</>
) : null}
</SectionMain>
</>
)
}
SubscriptionPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}