45
common/resources/client/billing/pricing-table/billing-cycle-radio.tsx
Executable file
45
common/resources/client/billing/pricing-table/billing-cycle-radio.tsx
Executable file
@@ -0,0 +1,45 @@
|
||||
import {Radio} from '../../ui/forms/radio-group/radio';
|
||||
import {UpsellBillingCycle} from './find-best-price';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupProps,
|
||||
} from '../../ui/forms/radio-group/radio-group';
|
||||
import {UpsellLabel} from './upsell-label';
|
||||
import {Product} from '../product';
|
||||
|
||||
interface BillingCycleRadioProps extends Omit<RadioGroupProps, 'children'> {
|
||||
selectedCycle: UpsellBillingCycle;
|
||||
onChange: (value: UpsellBillingCycle) => void;
|
||||
products?: Product[];
|
||||
}
|
||||
export function BillingCycleRadio({
|
||||
selectedCycle,
|
||||
onChange,
|
||||
products,
|
||||
...radioGroupProps
|
||||
}: BillingCycleRadioProps) {
|
||||
return (
|
||||
<RadioGroup {...radioGroupProps}>
|
||||
<Radio
|
||||
value="yearly"
|
||||
checked={selectedCycle === 'yearly'}
|
||||
onChange={e => {
|
||||
onChange(e.target.value as UpsellBillingCycle);
|
||||
}}
|
||||
>
|
||||
<Trans message="Annual" />
|
||||
<UpsellLabel products={products} />
|
||||
</Radio>
|
||||
<Radio
|
||||
value="monthly"
|
||||
checked={selectedCycle === 'monthly'}
|
||||
onChange={e => {
|
||||
onChange(e.target.value as UpsellBillingCycle);
|
||||
}}
|
||||
>
|
||||
<Trans message="Monthly" />
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
21
common/resources/client/billing/pricing-table/billing-period-control.tsx
Executable file
21
common/resources/client/billing/pricing-table/billing-period-control.tsx
Executable file
@@ -0,0 +1,21 @@
|
||||
import {SegmentedRadio} from '../../ui/forms/segmented-radio-group/segmented-radio';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {
|
||||
SegmentedRadioGroup,
|
||||
SegmentedRadioGroupProps,
|
||||
} from '../../ui/forms/segmented-radio-group/segmented-radio-group';
|
||||
|
||||
interface BillingPeriodControlProps
|
||||
extends Omit<SegmentedRadioGroupProps, 'children'> {}
|
||||
export function BillingPeriodControl(props: BillingPeriodControlProps) {
|
||||
return (
|
||||
<SegmentedRadioGroup {...props}>
|
||||
<SegmentedRadio value="monthly">
|
||||
<Trans message="Monthly billing" />
|
||||
</SegmentedRadio>
|
||||
<SegmentedRadio value="yearly">
|
||||
<Trans message="Yearly billing" />
|
||||
</SegmentedRadio>
|
||||
</SegmentedRadioGroup>
|
||||
);
|
||||
}
|
||||
42
common/resources/client/billing/pricing-table/find-best-price.ts
Executable file
42
common/resources/client/billing/pricing-table/find-best-price.ts
Executable file
@@ -0,0 +1,42 @@
|
||||
import {Price} from '../price';
|
||||
|
||||
export type UpsellBillingCycle = 'monthly' | 'yearly';
|
||||
|
||||
export function findBestPrice(
|
||||
token: UpsellBillingCycle,
|
||||
prices: Price[]
|
||||
): Price | undefined {
|
||||
if (token === 'monthly') {
|
||||
const match = findMonthlyPrice(prices);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
if (token === 'yearly') {
|
||||
const match = findYearlyPrice(prices);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return prices[0];
|
||||
}
|
||||
|
||||
function findYearlyPrice(prices: Price[]) {
|
||||
return prices.find(price => {
|
||||
if (price.interval === 'month' && price.interval_count >= 12) {
|
||||
return price;
|
||||
}
|
||||
if (price.interval === 'year' && price.interval_count >= 1) {
|
||||
return price;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function findMonthlyPrice(prices: Price[]) {
|
||||
return prices.find(price => {
|
||||
if (price.interval === 'day' && price.interval_count >= 30) {
|
||||
return price;
|
||||
}
|
||||
if (price.interval === 'month' && price.interval_count >= 1) {
|
||||
return price;
|
||||
}
|
||||
});
|
||||
}
|
||||
69
common/resources/client/billing/pricing-table/pricing-page.tsx
Executable file
69
common/resources/client/billing/pricing-table/pricing-page.tsx
Executable file
@@ -0,0 +1,69 @@
|
||||
import {useProducts} from './use-products';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {ForumIcon} from '../../icons/material/Forum';
|
||||
import {Navbar} from '../../ui/navigation/navbar/navbar';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {Footer} from '../../ui/footer/footer';
|
||||
import {Fragment, useState} from 'react';
|
||||
import {UpsellBillingCycle} from './find-best-price';
|
||||
import {BillingCycleRadio} from './billing-cycle-radio';
|
||||
import {StaticPageTitle} from '../../seo/static-page-title';
|
||||
import {PricingTable} from '@common/billing/pricing-table/pricing-table';
|
||||
|
||||
export function PricingPage() {
|
||||
const query = useProducts('pricingPage');
|
||||
const [selectedCycle, setSelectedCycle] =
|
||||
useState<UpsellBillingCycle>('yearly');
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<StaticPageTitle>
|
||||
<Trans message="Pricing" />
|
||||
</StaticPageTitle>
|
||||
<Navbar
|
||||
color="bg"
|
||||
darkModeColor="transparent"
|
||||
border="border-b"
|
||||
menuPosition="pricing-table-page"
|
||||
/>
|
||||
<div className="container mx-auto px-24">
|
||||
<h1 className="mb-30 mt-30 text-center text-3xl font-normal md:mt-60 md:text-4xl md:font-medium">
|
||||
<Trans message="Choose the right plan for you" />
|
||||
</h1>
|
||||
|
||||
<BillingCycleRadio
|
||||
products={query.data?.products}
|
||||
selectedCycle={selectedCycle}
|
||||
onChange={setSelectedCycle}
|
||||
className="mb-40 flex justify-center md:mb-70"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
<PricingTable
|
||||
selectedCycle={selectedCycle}
|
||||
productLoader="pricingPage"
|
||||
/>
|
||||
<ContactSection />
|
||||
</div>
|
||||
<Footer className="container mx-auto flex-shrink-0 px-24" />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactSection() {
|
||||
return (
|
||||
<div className="my-20 p-24 text-center md:my-80">
|
||||
<ForumIcon size="xl" className="text-muted" />
|
||||
<div className="my-8 md:text-lg">
|
||||
<Trans message="Do you have any questions about PRO accounts?" />
|
||||
</div>
|
||||
<div className="mb-24 mt-20 text-sm md:mt-0 md:text-base">
|
||||
<Trans message="Our support team will be happy to assist you." />
|
||||
</div>
|
||||
<Button variant="flat" color="primary" elementType={Link} to="/contact">
|
||||
<Trans message="Contact us" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
common/resources/client/billing/pricing-table/pricing-table.tsx
Executable file
178
common/resources/client/billing/pricing-table/pricing-table.tsx
Executable file
@@ -0,0 +1,178 @@
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {Fragment} from 'react';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {useProducts} from '@common/billing/pricing-table/use-products';
|
||||
import {Product} from '@common/billing/product';
|
||||
import {
|
||||
findBestPrice,
|
||||
UpsellBillingCycle,
|
||||
} from '@common/billing/pricing-table/find-best-price';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import clsx from 'clsx';
|
||||
import {Chip} from '@common/ui/forms/input-field/chip-field/chip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FormattedPrice} from '@common/i18n/formatted-price';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {setInLocalStorage} from '@common/utils/hooks/local-storage';
|
||||
import {ProductFeatureList} from '@common/billing/pricing-table/product-feature-list';
|
||||
|
||||
interface PricingTableProps {
|
||||
selectedCycle: UpsellBillingCycle;
|
||||
className?: string;
|
||||
productLoader?: string;
|
||||
}
|
||||
export function PricingTable({
|
||||
selectedCycle,
|
||||
className,
|
||||
productLoader,
|
||||
}: PricingTableProps) {
|
||||
const query = useProducts(productLoader);
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col items-stretch gap-24 overflow-x-auto overflow-y-visible pb-20 md:flex-row md:justify-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{query.data ? (
|
||||
<PlanList
|
||||
key="plan-list"
|
||||
plans={query.data.products}
|
||||
selectedPeriod={selectedCycle}
|
||||
/>
|
||||
) : (
|
||||
<SkeletonLoader key="skeleton-loader" />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlanListProps {
|
||||
plans: Product[];
|
||||
selectedPeriod: UpsellBillingCycle;
|
||||
}
|
||||
function PlanList({plans, selectedPeriod}: PlanListProps) {
|
||||
const {isLoggedIn, isSubscribed} = useAuth();
|
||||
const filteredPlans = plans.filter(plan => !plan.hidden);
|
||||
return (
|
||||
<Fragment>
|
||||
{filteredPlans.map((plan, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === filteredPlans.length - 1;
|
||||
const price = findBestPrice(selectedPeriod, plan.prices);
|
||||
|
||||
let upgradeRoute;
|
||||
if (!isLoggedIn) {
|
||||
upgradeRoute = `/register?redirectFrom=pricing`;
|
||||
}
|
||||
if (isSubscribed) {
|
||||
upgradeRoute = `/change-plan/${plan.id}/${price?.id}/confirm`;
|
||||
}
|
||||
if (isLoggedIn && !plan.free) {
|
||||
upgradeRoute = `/checkout/${plan.id}/${price?.id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div
|
||||
key={plan.id}
|
||||
{...opacityAnimation}
|
||||
className={clsx(
|
||||
'w-full rounded-panel border bg px-28 py-28 shadow-lg md:min-w-240 md:max-w-350',
|
||||
isFirst && 'ml-auto',
|
||||
isLast && 'mr-auto',
|
||||
)}
|
||||
>
|
||||
<div className="mb-32">
|
||||
<Chip
|
||||
radius="rounded"
|
||||
size="sm"
|
||||
className={clsx(
|
||||
'mb-20 w-min',
|
||||
!plan.recommended && 'invisible',
|
||||
)}
|
||||
>
|
||||
<Trans message="Most popular" />
|
||||
</Chip>
|
||||
<div className="mb-12 text-xl font-semibold">
|
||||
<Trans message={plan.name} />
|
||||
</div>
|
||||
<div className="text-sm text-muted">
|
||||
<Trans message={plan.description} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{price ? (
|
||||
<FormattedPrice
|
||||
priceClassName="font-bold text-4xl"
|
||||
periodClassName="text-muted text-xs"
|
||||
variant="separateLine"
|
||||
price={price}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-4xl font-bold">
|
||||
<Trans message="Free" />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-60">
|
||||
<Button
|
||||
variant={plan.recommended ? 'flat' : 'outline'}
|
||||
color="primary"
|
||||
className="w-full"
|
||||
size="md"
|
||||
elementType={upgradeRoute ? Link : undefined}
|
||||
disabled={!upgradeRoute}
|
||||
onClick={() => {
|
||||
if (isLoggedIn || !price || !plan) return;
|
||||
setInLocalStorage('be.onboarding.selected', {
|
||||
productId: plan.id,
|
||||
priceId: price.id,
|
||||
});
|
||||
}}
|
||||
to={upgradeRoute}
|
||||
>
|
||||
{plan.free ? (
|
||||
<Trans message="Get started" />
|
||||
) : (
|
||||
<Trans message="Upgrade" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<ProductFeatureList product={plan} />
|
||||
</div>
|
||||
</m.div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<Fragment>
|
||||
<PlanSkeleton key="skeleton-1" />
|
||||
<PlanSkeleton key="skeleton-2" />
|
||||
<PlanSkeleton key="skeleton-3" />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanSkeleton() {
|
||||
return (
|
||||
<m.div
|
||||
{...opacityAnimation}
|
||||
className="w-full rounded-lg border px-28 py-90 shadow-lg md:max-w-350"
|
||||
>
|
||||
<Skeleton className="my-10" />
|
||||
<Skeleton className="mb-40" />
|
||||
<Skeleton className="mb-40 h-30" />
|
||||
<Skeleton className="mb-40 h-40" />
|
||||
<Skeleton className="mb-20" />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
25
common/resources/client/billing/pricing-table/product-feature-list.tsx
Executable file
25
common/resources/client/billing/pricing-table/product-feature-list.tsx
Executable file
@@ -0,0 +1,25 @@
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {Product} from '../product';
|
||||
import {CheckIcon} from '@common/icons/material/Check';
|
||||
|
||||
interface FeatureListProps {
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export function ProductFeatureList({product}: FeatureListProps) {
|
||||
if (!product.feature_list.length) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-32 border-t pt-24">
|
||||
<div className="mb-10 text-sm font-semibold">
|
||||
<Trans message="What's included" />
|
||||
</div>
|
||||
{product.feature_list.map(feature => (
|
||||
<div key={feature} className="flex items-center gap-10 py-6 text-sm">
|
||||
<CheckIcon className="text-primary" size="sm" />
|
||||
<Trans message={feature} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
common/resources/client/billing/pricing-table/upsell-label.tsx
Executable file
59
common/resources/client/billing/pricing-table/upsell-label.tsx
Executable file
@@ -0,0 +1,59 @@
|
||||
// find the highest percentage decrease between monthly and yearly prices of specified products
|
||||
import {Product} from '../product';
|
||||
import {findBestPrice} from './find-best-price';
|
||||
import {Fragment, memo} from 'react';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
|
||||
interface UpsellLabelProps {
|
||||
products?: Product[];
|
||||
}
|
||||
export const UpsellLabel = memo(({products}: UpsellLabelProps) => {
|
||||
const upsellPercentage = calcHighestUpsellPercentage(products);
|
||||
|
||||
if (upsellPercentage <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<span className="font-medium text-positive-darker">
|
||||
{' '}
|
||||
(
|
||||
<Trans
|
||||
message="Save up to :percentage%"
|
||||
values={{percentage: upsellPercentage}}
|
||||
/>
|
||||
)
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
function calcHighestUpsellPercentage(products?: Product[]) {
|
||||
if (!products?.length) return 0;
|
||||
|
||||
const decreases = products.map(product => {
|
||||
if (product.hidden) return 0;
|
||||
|
||||
const monthly = findBestPrice('monthly', product.prices);
|
||||
const yearly = findBestPrice('yearly', product.prices);
|
||||
|
||||
if (!monthly || !yearly) return 0;
|
||||
|
||||
// monthly plan per year amount
|
||||
const monthlyAmount = monthly.amount * 12;
|
||||
const yearlyAmount = yearly.amount;
|
||||
|
||||
const savingsPercentage = Math.round(
|
||||
((monthlyAmount - yearlyAmount) / monthlyAmount) * 100,
|
||||
);
|
||||
|
||||
if (savingsPercentage > 0 && savingsPercentage <= 200) {
|
||||
return savingsPercentage;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return Math.max(Math.max(...decreases), 0);
|
||||
}
|
||||
33
common/resources/client/billing/pricing-table/use-products.ts
Executable file
33
common/resources/client/billing/pricing-table/use-products.ts
Executable file
@@ -0,0 +1,33 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '../../http/query-client';
|
||||
import {BackendResponse} from '../../http/backend-response/backend-response';
|
||||
import {PaginatedBackendResponse} from '../../http/backend-response/pagination-response';
|
||||
import {Product} from '../product';
|
||||
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
|
||||
|
||||
const endpoint = 'billing/products';
|
||||
|
||||
export interface FetchProductsResponse extends BackendResponse {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export function useProducts(loader?: string) {
|
||||
return useQuery<FetchProductsResponse>({
|
||||
queryKey: [endpoint],
|
||||
queryFn: () => fetchProducts(),
|
||||
initialData: () => {
|
||||
if (loader) {
|
||||
// @ts-ignore
|
||||
return getBootstrapData().loaders?.[loader];
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function fetchProducts(): Promise<FetchProductsResponse> {
|
||||
return apiClient
|
||||
.get<PaginatedBackendResponse<Product>>(endpoint)
|
||||
.then(response => {
|
||||
return {products: response.data.pagination.data};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user