first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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;
}
});
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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};
});
}