276 lines
11 KiB
TypeScript
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>
|
|
}
|