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,39 @@
import {useUser} from '../../auth/ui/use-user';
import {Navbar} from '../../ui/navigation/navbar/navbar';
import {ProgressCircle} from '../../ui/progress/progress-circle';
import {useAuth} from '../../auth/use-auth';
import {Outlet} from 'react-router-dom';
import {Footer} from '../../ui/footer/footer';
import {StaticPageTitle} from '../../seo/static-page-title';
import {Trans} from '../../i18n/trans';
import {Fragment} from 'react';
export function BillingPageLayout() {
const {user} = useAuth();
const query = useUser(user!.id, {
with: ['subscriptions.product', 'subscriptions.price'],
});
return (
<Fragment>
<StaticPageTitle>
<Trans message="Billing" />
</StaticPageTitle>
<Navbar menuPosition="billing-page" />
<div className="flex flex-col">
<div className="container mx-auto my-24 px-24 flex-auto">
{query.isLoading ? (
<ProgressCircle
className="my-80"
aria-label="Loading user.."
isIndeterminate
/>
) : (
<Outlet />
)}
</div>
<Footer className="container mx-auto px-24" />
</div>
</Fragment>
);
}

View File

@@ -0,0 +1,43 @@
import {Route, Routes} from 'react-router-dom';
import React from 'react';
import {SubscribedRoute} from '../../auth/guards/subscribed-route';
import {BillingPageLayout} from './billing-page-layout';
import {ChangePaymentMethodLayout} from './change-payment-method/change-payment-method-layout';
import {ChangePaymentMethodPage} from './change-payment-method/change-payment-method-page';
import {ChangePaymentMethodDone} from './change-payment-method/change-payment-method-done';
import {ChangePlanPage} from './change-plan-page';
import {ConfirmPlanChangePage} from './confirm-plan-change-page';
import {ConfirmPlanCancellationPage} from './confirm-plan-cancellation-page';
import {ConfirmPlanRenewalPage} from './confirm-plan-renewal-page';
import {BillingPage} from './billing-page';
export default function BillingPageRoutes() {
return (
<Routes>
<Route
path="/"
element={
<SubscribedRoute>
<BillingPageLayout />
</SubscribedRoute>
}
>
<Route index element={<BillingPage />} />
<Route
path="change-payment-method"
element={<ChangePaymentMethodLayout />}
>
<Route index element={<ChangePaymentMethodPage />} />
<Route path="done" element={<ChangePaymentMethodDone />} />
</Route>
<Route path="change-plan" element={<ChangePlanPage />} />
<Route
path="change-plan/:productId/:priceId/confirm"
element={<ConfirmPlanChangePage />}
/>
<Route path="cancel" element={<ConfirmPlanCancellationPage />} />
<Route path="renew" element={<ConfirmPlanRenewalPage />} />
</Route>
</Routes>
);
}

View File

@@ -0,0 +1,24 @@
import {useBillingUser} from './use-billing-user';
import {CancelledPlanPanel} from './panels/cancelled-plan-panel';
import {ActivePlanPanel} from './panels/active-plan-panel';
import {PaymentMethodPanel} from './panels/payment-method-panel';
import {InvoiceHistoryPanel} from './panels/invoice-history-panel';
export function BillingPage() {
const {subscription} = useBillingUser();
if (!subscription?.price || !subscription?.product) return null;
const planPanel = subscription.ends_at ? (
<CancelledPlanPanel />
) : (
<ActivePlanPanel />
);
return (
<div>
{planPanel}
<PaymentMethodPanel />
<InvoiceHistoryPanel />
</div>
);
}

View File

@@ -0,0 +1,16 @@
import {ReactNode} from 'react';
interface BillingPlanPanelProps {
title: ReactNode;
children: ReactNode;
}
export function BillingPlanPanel({title, children}: BillingPlanPanelProps) {
return (
<div className="mb-64">
<div className="text-sm font-medium uppercase pb-16 mb-16 border-b">
{title}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,106 @@
import {useEffect, useRef, useState} from 'react';
import {useSearchParams} from 'react-router-dom';
import {loadStripe, SetupIntent} from '@stripe/stripe-js';
import {message} from '../../../i18n/message';
import {apiClient} from '../../../http/query-client';
import {useNavigate} from '../../../utils/hooks/use-navigate';
import {
BillingRedirectMessage,
BillingRedirectMessageConfig,
} from '../../billing-redirect-message';
import {invalidateBillingUserQuery} from '../use-billing-user';
import {useSettings} from '../../../core/settings/use-settings';
const previousUrl = '/billing';
export function ChangePaymentMethodDone() {
const {
billing: {stripe_public_key},
} = useSettings();
const navigate = useNavigate();
const [params] = useSearchParams();
const clientSecret = params.get('setup_intent_client_secret');
const [messageConfig, setMessageConfig] =
useState<BillingRedirectMessageConfig>();
const stripeInitiated = useRef<boolean>();
useEffect(() => {
if (stripeInitiated.current || !clientSecret) return;
loadStripe(stripe_public_key!).then(stripe => {
if (!stripe) {
setMessageConfig(getRedirectMessageConfig());
return;
}
stripe.retrieveSetupIntent(clientSecret).then(({setupIntent}) => {
if (setupIntent?.status === 'succeeded') {
changeDefaultPaymentMethod(setupIntent.payment_method as string).then(
() => {
invalidateBillingUserQuery();
}
);
}
setMessageConfig(getRedirectMessageConfig(setupIntent?.status));
});
});
stripeInitiated.current = true;
}, [stripe_public_key, clientSecret]);
if (!clientSecret) {
navigate(previousUrl);
return null;
}
return <BillingRedirectMessage config={messageConfig} />;
}
function getRedirectMessageConfig(
status?: SetupIntent.Status
): BillingRedirectMessageConfig {
switch (status) {
case 'succeeded':
return {
...redirectMessageDefaults,
message: message('Payment method changed successfully!'),
status: 'success',
};
case 'processing':
return {
...redirectMessageDefaults,
message: message(
"Your request is processing. We'll update you when your payment method is confirmed."
),
status: 'success',
};
case 'requires_payment_method':
return {
...redirectMessageDefaults,
message: message(
'Payment method confirmation failed. Please try another payment method.'
),
status: 'error',
};
default:
return {
...redirectMessageDefaults,
message: message('Something went wrong'),
status: 'error',
};
}
}
const redirectMessageDefaults: Omit<
BillingRedirectMessageConfig,
'message' | 'status'
> = {
link: previousUrl,
buttonLabel: message('Go back'),
};
function changeDefaultPaymentMethod(paymentMethodId: string) {
return apiClient.post('billing/stripe/change-default-payment-method', {
payment_method_id: paymentMethodId,
});
}

View File

@@ -0,0 +1,29 @@
import {Fragment} from 'react';
import {Breadcrumb} from '../../../ui/breadcrumbs/breadcrumb';
import {useNavigate} from '../../../utils/hooks/use-navigate';
import {BreadcrumbItem} from '../../../ui/breadcrumbs/breadcrumb-item';
import {Trans} from '../../../i18n/trans';
import {Outlet} from 'react-router-dom';
const previousUrl = '/billing';
export function ChangePaymentMethodLayout() {
const navigate = useNavigate();
return (
<Fragment>
<Breadcrumb>
<BreadcrumbItem isLink onSelected={() => navigate(previousUrl)}>
<Trans message="Billing" />
</BreadcrumbItem>
<BreadcrumbItem>
<Trans message="Payment method" />
</BreadcrumbItem>
</Breadcrumb>
<h1 className="text-3xl font-bold my-32 md:my-64">
<Trans message="Change payment method" />
</h1>
<Outlet />
</Fragment>
);
}

View File

@@ -0,0 +1,31 @@
import {Trans} from '../../../i18n/trans';
import {Button} from '../../../ui/buttons/button';
import {Link} from 'react-router-dom';
import {StripeElementsForm} from '../../checkout/stripe/stripe-elements-form';
import {useSettings} from '../../../core/settings/use-settings';
const previousUrl = '/billing';
export function ChangePaymentMethodPage() {
const {base_url} = useSettings();
return (
<div className="max-w-[464px]">
<StripeElementsForm
type="setupIntent"
submitLabel={<Trans message="Change" />}
returnUrl={`${base_url}/billing/change-payment-method/done`}
/>
<Button
variant="outline"
className="w-full mt-16"
size="md"
to={previousUrl}
elementType={Link}
type="button"
>
<Trans message="Go back" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import {Breadcrumb} from '../../ui/breadcrumbs/breadcrumb';
import {BreadcrumbItem} from '../../ui/breadcrumbs/breadcrumb-item';
import {Trans} from '../../i18n/trans';
import {useNavigate} from '../../utils/hooks/use-navigate';
import {BillingPlanPanel} from './billing-plan-panel';
import {Product} from '../product';
import {
findBestPrice,
UpsellBillingCycle,
} from '../pricing-table/find-best-price';
import {Fragment, useState} from 'react';
import {FormattedPrice} from '../../i18n/formatted-price';
import {Button} from '../../ui/buttons/button';
import {Link} from 'react-router-dom';
import {useProducts} from '../pricing-table/use-products';
import {Price} from '../price';
import {useBillingUser} from './use-billing-user';
import {CheckIcon} from '../../icons/material/Check';
import {Skeleton} from '../../ui/skeleton/skeleton';
import {AnimatePresence, m} from 'framer-motion';
import {BillingCycleRadio} from '../pricing-table/billing-cycle-radio';
import {opacityAnimation} from '../../ui/animation/opacity-animation';
export function ChangePlanPage() {
const navigate = useNavigate();
return (
<Fragment>
<Breadcrumb>
<BreadcrumbItem isLink onSelected={() => navigate('/billing')}>
<Trans message="Billing" />
</BreadcrumbItem>
<BreadcrumbItem>
<Trans message="Plans" />
</BreadcrumbItem>
</Breadcrumb>
<h1 className="my-32 text-3xl font-bold md:my-64">
<Trans message="Change your plan" />
</h1>
<BillingPlanPanel title={<Trans message="Available plans" />}>
<AnimatePresence initial={false} mode="wait">
<PlanList />
</AnimatePresence>
</BillingPlanPanel>
</Fragment>
);
}
function PlanList() {
const query = useProducts();
const [selectedCycle, setSelectedCycle] =
useState<UpsellBillingCycle>('monthly');
if (query.isLoading) {
return <PlanSkeleton key="plan-skeleton" />;
}
return (
<Fragment key="plan-list">
<BillingCycleRadio
products={query.data?.products}
selectedCycle={selectedCycle}
onChange={setSelectedCycle}
className="mb-20"
size="md"
/>
{query.data?.products.map(plan => {
const price = findBestPrice(selectedCycle, plan.prices);
if (!price || plan.hidden) return null;
return (
<m.div
{...opacityAnimation}
key={plan.id}
className="justify-between gap-40 border-b py-32 md:flex"
>
<div className="mb-40 md:mb-0">
<div className="text-xl font-bold">{plan.name}</div>
<FormattedPrice price={price} className="text-lg" />
<div className="mt-12 text-base">{plan.description}</div>
<FeatureList plan={plan} />
</div>
<ContinueButton product={plan} price={price} />
</m.div>
);
})}
</Fragment>
);
}
interface FeatureListProps {
plan: Product;
}
function FeatureList({plan}: FeatureListProps) {
if (!plan.feature_list.length) return null;
return (
<div className="mt-32">
<div className="mb-10 text-sm font-semibold">
<Trans message="What's included" />
</div>
{plan.feature_list.map(feature => (
<div key={feature} className="flex items-center gap-10 text-sm">
<CheckIcon className="text-positive" size="sm" />
<Trans message={feature} />
</div>
))}
</div>
);
}
interface ContinueButtonProps {
product: Product;
price: Price;
}
function ContinueButton({product, price}: ContinueButtonProps) {
const {subscription} = useBillingUser();
if (!subscription?.price || !subscription?.product) return null;
if (
subscription.product_id === product.id &&
subscription.price_id === price.id
) {
return (
<div className="flex w-[168px] items-center justify-center gap-10 text-muted">
<CheckIcon size="md" />
<Trans message="Current plan" />
</div>
);
}
return (
<Button
variant="flat"
color="primary"
className="w-[168px]"
size="md"
elementType={Link}
to={`/billing/change-plan/${product.id}/${price.id}/confirm`}
>
<Trans message="Continue" />
</Button>
);
}
function PlanSkeleton() {
return (
<m.div
key="plan-skeleton"
{...opacityAnimation}
className="border-b py-32 text-2xl"
>
<Skeleton className="mb-8" />
<Skeleton className="mb-14" />
<Skeleton className="mb-24" />
<Skeleton className="mb-12" />
</m.div>
);
}

View File

@@ -0,0 +1,112 @@
import {Breadcrumb} from '../../ui/breadcrumbs/breadcrumb';
import {BreadcrumbItem} from '../../ui/breadcrumbs/breadcrumb-item';
import {Trans} from '../../i18n/trans';
import {useNavigate} from '../../utils/hooks/use-navigate';
import {BillingPlanPanel} from './billing-plan-panel';
import {Fragment} from 'react';
import {useProducts} from '../pricing-table/use-products';
import {Link} from 'react-router-dom';
import {Button} from '../../ui/buttons/button';
import {FormattedPrice} from '../../i18n/formatted-price';
import {invalidateBillingUserQuery, useBillingUser} from './use-billing-user';
import {useCancelSubscription} from './requests/use-cancel-subscription';
import {FormattedDate} from '../../i18n/formatted-date';
const previousUrl = '/billing';
export function ConfirmPlanCancellationPage() {
const navigate = useNavigate();
const query = useProducts();
const {subscription} = useBillingUser();
const cancelSubscription = useCancelSubscription();
const product = subscription?.product;
const price = subscription?.price;
if (!query.data) {
return null;
}
if (!subscription || !product || !price) {
navigate(previousUrl);
return null;
}
const renewDate = (
<span className="whitespace-nowrap">
<FormattedDate date={subscription.renews_at} preset="long" />
</span>
);
const handleSubscriptionCancel = () => {
cancelSubscription.mutate(
{
subscriptionId: subscription.id,
},
{
onSuccess: () => {
invalidateBillingUserQuery();
navigate('/billing');
},
},
);
};
return (
<Fragment>
<Breadcrumb>
<BreadcrumbItem isLink onSelected={() => navigate(previousUrl)}>
<Trans message="Billing" />
</BreadcrumbItem>
<BreadcrumbItem>
<Trans message="Cancel" />
</BreadcrumbItem>
</Breadcrumb>
<h1 className="text-3xl font-bold my-32 md:my-64">
<Trans message="Cancel your plan" />
</h1>
<BillingPlanPanel title={<Trans message="Current plan" />}>
<div className="max-w-[464px]">
<div className="text-xl font-bold">{product.name}</div>
<FormattedPrice price={price} className="text-lg" />
<div className="text-base mt-12 border-b pb-24 mb-48">
<Trans
message="Your plan will be canceled, but is still available until the end of your billing period on :date"
values={{date: renewDate}}
/>
<div className="mt-20">
<Trans message="If you change your mind, you can renew your subscription." />
</div>
</div>
<div>
<div>
<Button
variant="flat"
color="primary"
size="md"
className="w-full mb-16"
onClick={handleSubscriptionCancel}
disabled={cancelSubscription.isPending}
>
<Trans message="Cancel plan" />
</Button>
</div>
<div>
<Button
variant="outline"
className="w-full"
to={previousUrl}
elementType={Link}
>
<Trans message="Go back" />
</Button>
</div>
<div className="text-xs text-muted mt-12">
<Trans message="By cancelling your plan, you agree to our terms of service and privacy policy." />
</div>
</div>
</div>
</BillingPlanPanel>
</Fragment>
);
}

View File

@@ -0,0 +1,105 @@
import {Breadcrumb} from '../../ui/breadcrumbs/breadcrumb';
import {BreadcrumbItem} from '../../ui/breadcrumbs/breadcrumb-item';
import {Trans} from '../../i18n/trans';
import {useNavigate} from '../../utils/hooks/use-navigate';
import {BillingPlanPanel} from './billing-plan-panel';
import {Fragment} from 'react';
import {useProducts} from '../pricing-table/use-products';
import {Link, Navigate, useParams} from 'react-router-dom';
import {Button} from '../../ui/buttons/button';
import {FormattedPrice} from '../../i18n/formatted-price';
import {useBillingUser} from './use-billing-user';
import {FormattedDate} from '../../i18n/formatted-date';
import {useChangeSubscriptionPlan} from './requests/use-change-subscription-plan';
const previousUrl = '/billing/change-plan';
export function ConfirmPlanChangePage() {
const {productId, priceId} = useParams();
const navigate = useNavigate();
const query = useProducts();
const {subscription} = useBillingUser();
const changePlan = useChangeSubscriptionPlan();
if (!query.data || subscription?.price_id == priceId) {
return <Navigate to="/billing/change-plan" replace />;
}
const newProduct = query.data.products.find(p => `${p.id}` === productId);
const newPrice = newProduct?.prices.find(p => `${p.id}` === priceId);
if (!newProduct || !newPrice || !subscription) {
navigate(previousUrl);
return null;
}
const newDate = (
<span className="whitespace-nowrap">
<FormattedDate date={subscription.renews_at} preset="long" />;
</span>
);
return (
<Fragment>
<Breadcrumb>
<BreadcrumbItem isLink onSelected={() => navigate('/billing')}>
<Trans message="Billing" />
</BreadcrumbItem>
<BreadcrumbItem onSelected={() => navigate(previousUrl)}>
<Trans message="Plans" />
</BreadcrumbItem>
<BreadcrumbItem>
<Trans message="Confirm" />
</BreadcrumbItem>
</Breadcrumb>
<h1 className="text-3xl font-bold my-32 md:my-64">
<Trans message="Confirm your new plan" />
</h1>
<BillingPlanPanel title={<Trans message="Changing to" />}>
<div className="max-w-[464px]">
<div className="text-xl font-bold">{newProduct.name}</div>
<FormattedPrice price={newPrice} className="text-lg" />
<div className="text-base mt-12 border-b pb-24 mb-48">
<Trans
message="You will be charged the new price starting :date"
values={{date: newDate}}
/>
</div>
<div>
<div>
<Button
variant="flat"
color="primary"
size="md"
className="w-full mb-16"
onClick={() => {
changePlan.mutate({
subscriptionId: subscription.id,
newProductId: newProduct.id,
newPriceId: newPrice.id,
});
}}
disabled={changePlan.isPending}
>
<Trans message="Confirm" />
</Button>
</div>
<div>
<Button
variant="outline"
className="w-full"
to={previousUrl}
elementType={Link}
>
<Trans message="Go back" />
</Button>
</div>
<div className="text-xs text-muted mt-12">
<Trans message="By confirming your new plan, you agree to our terms of Service and privacy policy." />
</div>
</div>
</div>
</BillingPlanPanel>
</Fragment>
);
}

View File

@@ -0,0 +1,103 @@
import {Breadcrumb} from '../../ui/breadcrumbs/breadcrumb';
import {BreadcrumbItem} from '../../ui/breadcrumbs/breadcrumb-item';
import {Trans} from '../../i18n/trans';
import {useNavigate} from '../../utils/hooks/use-navigate';
import {BillingPlanPanel} from './billing-plan-panel';
import {Fragment} from 'react';
import {useProducts} from '../pricing-table/use-products';
import {Link} from 'react-router-dom';
import {Button} from '../../ui/buttons/button';
import {FormattedPrice} from '../../i18n/formatted-price';
import {invalidateBillingUserQuery, useBillingUser} from './use-billing-user';
import {FormattedDate} from '../../i18n/formatted-date';
import {useResumeSubscription} from './requests/use-resume-subscription';
const previousUrl = '/billing';
export function ConfirmPlanRenewalPage() {
const navigate = useNavigate();
const query = useProducts();
const {subscription} = useBillingUser();
const resumeSubscription = useResumeSubscription();
const product = subscription?.product;
const price = subscription?.price;
if (!query.data) {
return null;
}
if (!subscription || !product || !price) {
navigate(previousUrl);
return null;
}
const endDate = (
<span className="whitespace-nowrap">
<FormattedDate date={subscription.ends_at} preset="long" />;
</span>
);
const handleResumeSubscription = () => {
resumeSubscription.mutate(
{
subscriptionId: subscription.id,
},
{
onSuccess: () => {
invalidateBillingUserQuery();
navigate('/billing');
},
},
);
};
return (
<Fragment>
<Breadcrumb>
<BreadcrumbItem isLink onSelected={() => navigate(previousUrl)}>
<Trans message="Billing" />
</BreadcrumbItem>
<BreadcrumbItem>
<Trans message="Renew" />
</BreadcrumbItem>
</Breadcrumb>
<h1 className="text-3xl font-bold my-32 md:my-64">
<Trans message="Renew your plan" />
</h1>
<BillingPlanPanel title={<Trans message="Current plan" />}>
<div className="max-w-[464px]">
<div className="text-xl font-bold">{product.name}</div>
<FormattedPrice price={price} className="text-lg" />
<div className="text-base mt-12 border-b pb-24 mb-48">
<Trans
message="This plan will no longer be canceled. It will renew on :date"
values={{date: endDate}}
/>
</div>
<Button
variant="flat"
color="primary"
size="md"
className="w-full mb-16"
onClick={handleResumeSubscription}
disabled={resumeSubscription.isPending}
>
<Trans message="Renew plan" />
</Button>
<Button
variant="outline"
className="w-full"
to={previousUrl}
elementType={Link}
>
<Trans message="Go back" />
</Button>
<div className="text-xs text-muted mt-12">
<Trans message="By renewing your plan, you agree to our terms of service and privacy policy." />
</div>
</div>
</BillingPlanPanel>
</Fragment>
);
}

View File

@@ -0,0 +1,77 @@
import {useBillingUser} from '@common/billing/billing-page/use-billing-user';
import {FormattedDate} from '@common/i18n/formatted-date';
import {BillingPlanPanel} from '@common/billing/billing-page/billing-plan-panel';
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 {Fragment} from 'react';
import {SectionHelper} from '@common/ui/section-helper';
export function ActivePlanPanel() {
const {subscription} = useBillingUser();
if (!subscription?.price || !subscription?.product) return null;
const renewDate = (
<FormattedDate preset="long" date={subscription.renews_at} />
);
return (
<Fragment>
{subscription.past_due ? <PastDueMessage /> : null}
<BillingPlanPanel title={<Trans message="Current plan" />}>
<div className="mt-24 flex justify-between gap-20">
<div>
<div className="mb-2 text-xl font-bold">
{subscription.product.name}
</div>
<FormattedPrice
className="mb-2 text-xl"
price={subscription.price}
/>
<div className="text-base">
<Trans
message="Your plan renews on :date"
values={{date: renewDate}}
/>
</div>
</div>
<div className="w-[233px]">
<Button
variant="flat"
color="primary"
size="md"
className="mb-12 w-full"
elementType={Link}
to="/billing/change-plan"
disabled={subscription.gateway_name === 'none'}
>
<Trans message="Change plan" />
</Button>
<Button
variant="outline"
color="danger"
size="md"
className="w-full"
elementType={Link}
to="/billing/cancel"
>
<Trans message="Cancel plan" />
</Button>
</div>
</div>
</BillingPlanPanel>
</Fragment>
);
}
function PastDueMessage() {
return (
<SectionHelper
className="mb-24"
color="danger"
title="Payment is past due"
description="Your recent recurring payment has failed with the payment method we have on file. Please update your payment method to avoid any service interruptions."
/>
);
}

View File

@@ -0,0 +1,62 @@
import {useBillingUser} from '../use-billing-user';
import {FormattedDate} from '../../../i18n/formatted-date';
import {BillingPlanPanel} from '../billing-plan-panel';
import {Trans} from '../../../i18n/trans';
import {Chip} from '../../../ui/forms/input-field/chip-field/chip';
import {FormattedPrice} from '../../../i18n/formatted-price';
import {CalendarTodayIcon} from '../../../icons/material/CalendarToday';
import {Button} from '../../../ui/buttons/button';
import {Link} from 'react-router-dom';
export function CancelledPlanPanel() {
const {subscription} = useBillingUser();
if (!subscription?.price || !subscription?.product) return null;
const endingDate = (
<span className="whitespace-nowrap">
<FormattedDate preset="long" date={subscription.ends_at} />
</span>
);
return (
<BillingPlanPanel title={<Trans message="Current plan" />}>
<div className="mt-24 flex flex-col justify-between gap-20">
<div>
<Chip
className="mb-10 w-min"
size="xs"
radius="rounded"
color="danger"
>
<Trans message="Canceled" />
</Chip>
<div className="mb-2 text-xl font-bold">
{subscription.product.name}
</div>
<FormattedPrice className="mb-8 text-xl" price={subscription.price} />
<div className="flex items-center gap-8 text-base">
<CalendarTodayIcon size="sm" className="text-muted" />
<div className="flex-auto">
<Trans
message="Your plan will be canceled on :date"
values={{date: endingDate}}
/>
</div>
</div>
</div>
<div className="w-[233px]">
<Button
variant="flat"
color="primary"
size="md"
className="mb-12 w-full"
elementType={Link}
to="/billing/renew"
>
<Trans message="Renew plan" />
</Button>
</div>
</div>
</BillingPlanPanel>
);
}

View File

@@ -0,0 +1,99 @@
import {useBillingUser} from '../use-billing-user';
import {BillingPlanPanel} from '../billing-plan-panel';
import {Trans} from '../../../i18n/trans';
import {useInvoices} from '../requests/use-invoices';
import {FormattedDate} from '../../../i18n/formatted-date';
import {FormattedCurrency} from '../../../i18n/formatted-currency';
import {Chip} from '../../../ui/forms/input-field/chip-field/chip';
import {OpenInNewIcon} from '../../../icons/material/OpenInNew';
import {Skeleton} from '../../../ui/skeleton/skeleton';
import {AnimatePresence, m} from 'framer-motion';
import {Invoice} from '../../invoice';
import {opacityAnimation} from '../../../ui/animation/opacity-animation';
import {useSettings} from '../../../core/settings/use-settings';
export function InvoiceHistoryPanel() {
const {user} = useBillingUser();
const query = useInvoices(user?.id!);
if (!user) return null;
const invoices = query.data?.invoices;
return (
<BillingPlanPanel title={<Trans message="Payment history" />}>
<div className="max-w-[464px]">
<AnimatePresence initial={false} mode="wait">
{query.isLoading ? (
<LoadingSkeleton key="loading-skeleton" />
) : (
<InvoiceList key="invoices" invoices={invoices} />
)}
</AnimatePresence>
</div>
</BillingPlanPanel>
);
}
interface InvoiceListProps {
invoices?: Invoice[];
}
function InvoiceList({invoices}: InvoiceListProps) {
const {base_url} = useSettings();
return (
<m.div {...opacityAnimation}>
{!invoices?.length ? (
<div className="text-muted italic">
<Trans message="No invoices yet" />
</div>
) : undefined}
{invoices?.map(invoice => (
<div
className="whitespace-nowrap text-base flex items-center justify-between gap-10 mb-14"
key={invoice.id}
>
<a
href={`${base_url}/billing/invoices/${invoice.uuid}`}
target="_blank"
className="flex items-center gap-8 hover:underline"
rel="noreferrer"
>
<FormattedDate date={invoice.created_at} />
<OpenInNewIcon size="xs" />
</a>
{invoice.subscription.price && (
<div>
<FormattedCurrency
value={invoice.subscription.price.amount}
currency={invoice.subscription.price.currency}
/>
</div>
)}
<Chip
size="xs"
color={invoice.paid ? 'positive' : 'danger'}
radius="rounded"
>
{invoice.paid ? (
<Trans message="Paid" />
) : (
<Trans message="Unpaid" />
)}
</Chip>
<div>{invoice.subscription.product?.name}</div>
</div>
))}
</m.div>
);
}
function LoadingSkeleton() {
return (
<m.div {...opacityAnimation}>
<Skeleton className="mb-14" />
<Skeleton className="mb-14" />
<Skeleton className="mb-14" />
<Skeleton className="mb-14" />
<Skeleton />
</m.div>
);
}

View File

@@ -0,0 +1,77 @@
import {useBillingUser} from '../use-billing-user';
import {BillingPlanPanel} from '../billing-plan-panel';
import {Trans} from '../../../i18n/trans';
import {Link} from 'react-router-dom';
import {EditIcon} from '../../../icons/material/Edit';
import {Fragment} from 'react';
import paypalSvg from './paypal.svg';
import {SvgImage} from '../../../ui/images/svg-image/svg-image';
export function PaymentMethodPanel() {
const {user, subscription} = useBillingUser();
if (!user || !subscription) return null;
const isPaypal = subscription.gateway_name === 'paypal';
const PaymentMethod = isPaypal ? PaypalPaymentMethod : CardPaymentMethod;
return (
<BillingPlanPanel title={<Trans message="Payment method" />}>
<PaymentMethod
methodClassName="whitespace-nowrap text-base max-w-[464px] flex items-center gap-10"
linkClassName="flex items-center gap-4 text-muted mt-18 block hover:underline"
/>
</BillingPlanPanel>
);
}
interface PaymentMethodProps {
methodClassName: string;
linkClassName: string;
}
function CardPaymentMethod({
methodClassName,
linkClassName,
}: PaymentMethodProps) {
const {user} = useBillingUser();
if (!user) return null;
return (
<Fragment>
<div className={methodClassName}>
<span className="capitalize">{user.card_brand}</span>
{user.card_last_four}
{user.card_expires && (
<div className="ml-auto">
<Trans message="Expires :date" values={{date: user.card_expires}} />
</div>
)}
</div>
<Link className={linkClassName} to="/billing/change-payment-method">
<EditIcon size="sm" />
<Trans message="Change payment method" />
</Link>
</Fragment>
);
}
function PaypalPaymentMethod({
methodClassName,
linkClassName,
}: PaymentMethodProps) {
const {subscription} = useBillingUser();
return (
<Fragment>
<div className={methodClassName}>
<SvgImage src={paypalSvg} />
</div>
<a
className={linkClassName}
href={`https://www.sandbox.paypal.com/myaccount/autopay/connect/${subscription?.gateway_id}/funding`}
target="_blank"
rel="noreferrer"
>
<EditIcon size="sm" />
<Trans message="Change payment method" />
</a>
</Fragment>
);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="101px" height="32" viewBox="0 0 101 32" preserveAspectRatio="xMinYMin meet"><path fill="#003087" d="M 12.237 2.8 L 4.437 2.8 C 3.937 2.8 3.437 3.2 3.337 3.7 L 0.237 23.7 C 0.137 24.1 0.437 24.4 0.837 24.4 L 4.537 24.4 C 5.037 24.4 5.537 24 5.637 23.5 L 6.437 18.1 C 6.537 17.6 6.937 17.2 7.537 17.2 L 10.037 17.2 C 15.137 17.2 18.137 14.7 18.937 9.8 C 19.237 7.7 18.937 6 17.937 4.8 C 16.837 3.5 14.837 2.8 12.237 2.8 Z M 13.137 10.1 C 12.737 12.9 10.537 12.9 8.537 12.9 L 7.337 12.9 L 8.137 7.7 C 8.137 7.4 8.437 7.2 8.737 7.2 L 9.237 7.2 C 10.637 7.2 11.937 7.2 12.637 8 C 13.137 8.4 13.337 9.1 13.137 10.1 Z"/><path fill="#003087" d="M 35.437 10 L 31.737 10 C 31.437 10 31.137 10.2 31.137 10.5 L 30.937 11.5 L 30.637 11.1 C 29.837 9.9 28.037 9.5 26.237 9.5 C 22.137 9.5 18.637 12.6 17.937 17 C 17.537 19.2 18.037 21.3 19.337 22.7 C 20.437 24 22.137 24.6 24.037 24.6 C 27.337 24.6 29.237 22.5 29.237 22.5 L 29.037 23.5 C 28.937 23.9 29.237 24.3 29.637 24.3 L 33.037 24.3 C 33.537 24.3 34.037 23.9 34.137 23.4 L 36.137 10.6 C 36.237 10.4 35.837 10 35.437 10 Z M 30.337 17.2 C 29.937 19.3 28.337 20.8 26.137 20.8 C 25.037 20.8 24.237 20.5 23.637 19.8 C 23.037 19.1 22.837 18.2 23.037 17.2 C 23.337 15.1 25.137 13.6 27.237 13.6 C 28.337 13.6 29.137 14 29.737 14.6 C 30.237 15.3 30.437 16.2 30.337 17.2 Z"/><path fill="#003087" d="M 55.337 10 L 51.637 10 C 51.237 10 50.937 10.2 50.737 10.5 L 45.537 18.1 L 43.337 10.8 C 43.237 10.3 42.737 10 42.337 10 L 38.637 10 C 38.237 10 37.837 10.4 38.037 10.9 L 42.137 23 L 38.237 28.4 C 37.937 28.8 38.237 29.4 38.737 29.4 L 42.437 29.4 C 42.837 29.4 43.137 29.2 43.337 28.9 L 55.837 10.9 C 56.137 10.6 55.837 10 55.337 10 Z"/><path fill="#009cde" d="M 67.737 2.8 L 59.937 2.8 C 59.437 2.8 58.937 3.2 58.837 3.7 L 55.737 23.6 C 55.637 24 55.937 24.3 56.337 24.3 L 60.337 24.3 C 60.737 24.3 61.037 24 61.037 23.7 L 61.937 18 C 62.037 17.5 62.437 17.1 63.037 17.1 L 65.537 17.1 C 70.637 17.1 73.637 14.6 74.437 9.7 C 74.737 7.6 74.437 5.9 73.437 4.7 C 72.237 3.5 70.337 2.8 67.737 2.8 Z M 68.637 10.1 C 68.237 12.9 66.037 12.9 64.037 12.9 L 62.837 12.9 L 63.637 7.7 C 63.637 7.4 63.937 7.2 64.237 7.2 L 64.737 7.2 C 66.137 7.2 67.437 7.2 68.137 8 C 68.637 8.4 68.737 9.1 68.637 10.1 Z"/><path fill="#009cde" d="M 90.937 10 L 87.237 10 C 86.937 10 86.637 10.2 86.637 10.5 L 86.437 11.5 L 86.137 11.1 C 85.337 9.9 83.537 9.5 81.737 9.5 C 77.637 9.5 74.137 12.6 73.437 17 C 73.037 19.2 73.537 21.3 74.837 22.7 C 75.937 24 77.637 24.6 79.537 24.6 C 82.837 24.6 84.737 22.5 84.737 22.5 L 84.537 23.5 C 84.437 23.9 84.737 24.3 85.137 24.3 L 88.537 24.3 C 89.037 24.3 89.537 23.9 89.637 23.4 L 91.637 10.6 C 91.637 10.4 91.337 10 90.937 10 Z M 85.737 17.2 C 85.337 19.3 83.737 20.8 81.537 20.8 C 80.437 20.8 79.637 20.5 79.037 19.8 C 78.437 19.1 78.237 18.2 78.437 17.2 C 78.737 15.1 80.537 13.6 82.637 13.6 C 83.737 13.6 84.537 14 85.137 14.6 C 85.737 15.3 85.937 16.2 85.737 17.2 Z"/><path fill="#009cde" d="M 95.337 3.3 L 92.137 23.6 C 92.037 24 92.337 24.3 92.737 24.3 L 95.937 24.3 C 96.437 24.3 96.937 23.9 97.037 23.4 L 100.237 3.5 C 100.337 3.1 100.037 2.8 99.637 2.8 L 96.037 2.8 C 95.637 2.8 95.437 3 95.337 3.3 Z"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,41 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient} from '../../../http/query-client';
import {useTrans} from '../../../i18n/use-trans';
import {BackendResponse} from '../../../http/backend-response/backend-response';
import {toast} from '../../../ui/toast/toast';
import {message} from '../../../i18n/message';
import {User} from '../../../auth/user';
import {showHttpErrorToast} from '../../../utils/http/show-http-error-toast';
interface Response extends BackendResponse {
user: User;
}
interface Payload {
subscriptionId: number;
delete?: boolean;
}
export function useCancelSubscription() {
const {trans} = useTrans();
return useMutation({
mutationFn: (props: Payload) => cancelSubscription(props),
onSuccess: (response, payload) => {
toast(
payload.delete
? trans(message('Subscription deleted.'))
: trans(message('Subscription cancelled.')),
);
},
onError: err => showHttpErrorToast(err),
});
}
function cancelSubscription({
subscriptionId,
...payload
}: Payload): Promise<Response> {
return apiClient
.post(`billing/subscriptions/${subscriptionId}/cancel`, payload)
.then(r => r.data);
}

View File

@@ -0,0 +1,40 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient} from '../../../http/query-client';
import {useTrans} from '../../../i18n/use-trans';
import {BackendResponse} from '../../../http/backend-response/backend-response';
import {toast} from '../../../ui/toast/toast';
import {message} from '../../../i18n/message';
import {User} from '../../../auth/user';
import {invalidateBillingUserQuery} from '../use-billing-user';
import {useNavigate} from '../../../utils/hooks/use-navigate';
import {showHttpErrorToast} from '../../../utils/http/show-http-error-toast';
interface Response extends BackendResponse {
user: User;
}
interface Payload {
subscriptionId: number;
newProductId: number;
newPriceId: number;
}
export function useChangeSubscriptionPlan() {
const {trans} = useTrans();
const navigate = useNavigate();
return useMutation({
mutationFn: (props: Payload) => changePlan(props),
onSuccess: () => {
toast(trans(message('Plan changed.')));
invalidateBillingUserQuery();
navigate('/billing');
},
onError: err => showHttpErrorToast(err),
});
}
function changePlan({subscriptionId, ...other}: Payload): Promise<Response> {
return apiClient
.post(`billing/subscriptions/${subscriptionId}/change-plan`, other)
.then(r => r.data);
}

View File

@@ -0,0 +1,23 @@
import {useQuery} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {Invoice} from '@common/billing/invoice';
import {BackendResponse} from '@common/http/backend-response/backend-response';
const Endpoint = 'billing/invoices';
export interface FetchInvoicesResponse extends BackendResponse {
invoices: Invoice[];
}
export function useInvoices(userId: number) {
return useQuery({
queryKey: [Endpoint],
queryFn: () => fetchInvoices(userId),
});
}
function fetchInvoices(userId: number): Promise<FetchInvoicesResponse> {
return apiClient
.get(Endpoint, {params: {userId}})
.then(response => response.data);
}

View File

@@ -0,0 +1,33 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient} from '../../../http/query-client';
import {useTrans} from '../../../i18n/use-trans';
import {BackendResponse} from '../../../http/backend-response/backend-response';
import {toast} from '../../../ui/toast/toast';
import {message} from '../../../i18n/message';
import {User} from '../../../auth/user';
import {showHttpErrorToast} from '../../../utils/http/show-http-error-toast';
interface Response extends BackendResponse {
user: User;
}
interface Payload {
subscriptionId: number;
}
export function useResumeSubscription() {
const {trans} = useTrans();
return useMutation({
mutationFn: (props: Payload) => resumeSubscription(props),
onSuccess: () => {
toast(trans(message('Subscription renewed.')));
},
onError: err => showHttpErrorToast(err),
});
}
function resumeSubscription({subscriptionId}: Payload): Promise<Response> {
return apiClient
.post(`billing/subscriptions/${subscriptionId}/resume`)
.then(r => r.data);
}

View File

@@ -0,0 +1,16 @@
import {useUser} from '../../auth/ui/use-user';
import {queryClient} from '@common/http/query-client';
export function useBillingUser() {
const query = useUser('me', {
with: ['subscriptions.product', 'subscriptions.price'],
});
const subscription = query.data?.user.subscriptions?.[0];
return {subscription, isLoading: query.isLoading, user: query.data?.user};
}
export function invalidateBillingUserQuery() {
queryClient.invalidateQueries({queryKey: ['users']});
}

View File

@@ -0,0 +1,22 @@
import {Permission} from '../auth/permission';
export interface BillingPlan {
id: number;
name: string;
amount: number;
currency: string;
currency_symbol: string;
interval: 'day' | 'week' | 'month' | 'year';
interval_count: number;
parent_id: number;
parent?: BillingPlan;
permissions: Permission[];
recommended: boolean;
show_permissions: boolean;
free: boolean;
hidden: boolean;
position: number;
features: string[];
available_space: number;
updated_at: string;
}

View File

@@ -0,0 +1,70 @@
import {MessageDescriptor} from '../i18n/message-descriptor';
import {Link, To} from 'react-router-dom';
import {AnimatePresence, m} from 'framer-motion';
import {TaskAltIcon} from '../icons/material/TaskAlt';
import {ErrorIcon} from '../icons/material/Error';
import {Trans} from '../i18n/trans';
import {Button} from '../ui/buttons/button';
import {Skeleton} from '../ui/skeleton/skeleton';
import {opacityAnimation} from '../ui/animation/opacity-animation';
export interface BillingRedirectMessageConfig {
message: MessageDescriptor;
status: 'success' | 'error';
link: To;
buttonLabel: MessageDescriptor;
}
interface BillingRedirectMessageProps {
config?: BillingRedirectMessageConfig;
}
export function BillingRedirectMessage({config}: BillingRedirectMessageProps) {
return (
<AnimatePresence initial={false} mode="wait">
<div className="mt-80">
{config ? (
<m.div
className="text-center"
key="payment-status"
{...opacityAnimation}
>
{config.status === 'success' ? (
<TaskAltIcon className="text-positive text-6xl" />
) : (
<ErrorIcon className="text-danger text-6xl" />
)}
<div className="text-2xl font-semibold mt-30">
<Trans {...config.message} />
</div>
<Button
variant="flat"
color="primary"
className="w-full mt-30"
size="md"
elementType={Link}
to={config.link}
>
<Trans {...config.buttonLabel} />
</Button>
</m.div>
) : (
<LoadingSkeleton key="loading-skeleton" />
)}
</div>
</AnimatePresence>
);
}
function LoadingSkeleton() {
return (
<m.div
className="text-center max-w-280"
key="loading-skeleton"
{...opacityAnimation}
>
<Skeleton size="w-50 h-50" variant="rect" />
<Skeleton className="text-2xl mt-30" />
<Skeleton size="h-42" className="mt-30" />
</m.div>
);
}

View File

@@ -0,0 +1,32 @@
import {Route} from 'react-router-dom';
import {PricingPage} from './pricing-table/pricing-page';
import React, {Fragment} from 'react';
import {FullPageLoader} from '../ui/progress/full-page-loader';
const BillingPageRoutes = React.lazy(
() => import('./billing-page/billing-page-routes')
);
const CheckoutRoutes = React.lazy(() => import('./checkout/checkout-routes'));
export const BillingRoutes = (
<Fragment>
<Route path="/pricing" element={<PricingPage />} />
<Route
path="checkout/*"
element={
<React.Suspense fallback={<FullPageLoader screen />}>
<CheckoutRoutes />
</React.Suspense>
}
/>
<Route
path="billing/*"
element={
<React.Suspense fallback={<FullPageLoader screen />}>
<BillingPageRoutes />
</React.Suspense>
}
/>
</Fragment>
);

View File

@@ -0,0 +1,51 @@
import {Fragment, ReactElement, useEffect} from 'react';
import {Navbar} from '../../ui/navigation/navbar/navbar';
import {CustomMenu} from '../../menus/custom-menu';
import {LocaleSwitcher} from '../../i18n/locale-switcher';
import {removeFromLocalStorage} from '../../utils/hooks/local-storage';
import {StaticPageTitle} from '../../seo/static-page-title';
import {Trans} from '../../i18n/trans';
interface CheckoutLayoutProps {
children: [ReactElement, ReactElement];
}
export function CheckoutLayout({children}: CheckoutLayoutProps) {
const [left, right] = children;
useEffect(() => {
removeFromLocalStorage('be.onboarding.selected');
}, []);
return (
<Fragment>
<StaticPageTitle>
<Trans message="Checkout" />
</StaticPageTitle>
<Navbar
size="sm"
color="transparent"
className="z-10 mb-20 md:mb-0"
textColor="text-main"
logoColor="dark"
darkModeColor="transparent"
menuPosition="checkout-page-navbar"
/>
<div className="md:flex w-full mx-auto justify-between px-20 md:px-0 md:pt-128 md:max-w-950">
<div className="hidden md:block fixed right-0 top-0 w-1/2 h-full bg-alt shadow-[15px_0_30px_0_rgb(0_0_0_/_18%)]" />
<div className="md:w-400 overflow-hidden">
{left}
<CustomMenu
menu="checkout-page-footer"
className="text-xs mt-50 text-muted overflow-x-auto"
/>
<div className="mt-40">
<LocaleSwitcher />
</div>
</div>
<div className="hidden md:block w-384">
<div className="relative z-10">{right}</div>
</div>
</div>
</Fragment>
);
}

View File

@@ -0,0 +1,88 @@
import {Trans} from '../../i18n/trans';
import {FormattedPrice} from '../../i18n/formatted-price';
import {useCheckoutProduct} from '../requests/use-checkout-product';
import {m} from 'framer-motion';
import {Skeleton} from '../../ui/skeleton/skeleton';
import {Product} from '../product';
import {Price} from '../price';
import {FormattedCurrency} from '../../i18n/formatted-currency';
import {ProductFeatureList} from '../pricing-table/product-feature-list';
import {opacityAnimation} from '../../ui/animation/opacity-animation';
interface CheckoutProductSummaryProps {
showBillingLine?: boolean;
}
export function CheckoutProductSummary({
showBillingLine = true,
}: CheckoutProductSummaryProps) {
const {status, product, price} = useCheckoutProduct();
if (status === 'error' || (status !== 'pending' && (!product || !price))) {
return null;
}
return (
<div>
<h2 className="text-2xl mb-30">
<Trans message="Summary" />
</h2>
{status === 'pending' ? (
<LoadingSkeleton key="loading-skeleton" />
) : (
<ProductSummary
product={product!}
price={price!}
showBillingLine={showBillingLine}
/>
)}
</div>
);
}
interface ProductSummaryProps {
product: Product;
price: Price;
showBillingLine: boolean;
}
function ProductSummary({
product,
price,
showBillingLine,
}: ProductSummaryProps) {
return (
<m.div>
<div className="text-xl font-semibold mb-6">{product.name}</div>
{product.description && (
<div className="text-sm text-muted">{product.description}</div>
)}
<FormattedPrice
priceClassName="font-bold text-4xl"
periodClassName="text-muted text-xs"
variant="separateLine"
price={price}
className="mt-32"
/>
<ProductFeatureList product={product} />
{showBillingLine && (
<div className="flex items-center justify-between gap-24 border-t pt-24 mt-32 font-medium">
<div>
<Trans message="Billed today" />
</div>
<div>
<FormattedCurrency value={price.amount} currency={price.currency} />
</div>
</div>
)}
</m.div>
);
}
function LoadingSkeleton() {
return (
<m.div {...opacityAnimation} className="max-w-180">
<Skeleton className="text-xl mb-6" />
<Skeleton className="text-sm" />
<Skeleton className="text-4xl mt-32" />
</m.div>
);
}

View File

@@ -0,0 +1,37 @@
import {Route, Routes} from 'react-router-dom';
import {NotSubscribedRoute} from '../../auth/guards/not-subscribed-route';
import {Checkout} from './checkout';
import React from 'react';
import {CheckoutStripeDone} from './stripe/checkout-stripe-done';
import {CheckoutPaypalDone} from './paypal/checkout-paypal-done';
export default function CheckoutRoutes() {
return (
<Routes>
<Route
path=":productId/:priceId"
element={
<NotSubscribedRoute>
<Checkout />
</NotSubscribedRoute>
}
/>
<Route
path=":productId/:priceId/stripe/done"
element={
<NotSubscribedRoute>
<CheckoutStripeDone />
</NotSubscribedRoute>
}
/>
<Route
path=":productId/:priceId/paypal/done"
element={
<NotSubscribedRoute>
<CheckoutPaypalDone />
</NotSubscribedRoute>
}
/>
</Routes>
);
}

View File

@@ -0,0 +1,74 @@
import {Navigate, useParams} from 'react-router-dom';
import {Trans} from '../../i18n/trans';
import {CheckoutLayout} from './checkout-layout';
import {CheckoutProductSummary} from './checkout-product-summary';
import {usePaypal} from './paypal/use-paypal';
import {StripeElementsForm} from './stripe/stripe-elements-form';
import {Fragment} from 'react';
import {useProducts} from '../pricing-table/use-products';
import {FullPageLoader} from '../../ui/progress/full-page-loader';
import {useSettings} from '../../core/settings/use-settings';
export function Checkout() {
const {productId, priceId} = useParams();
const productQuery = useProducts();
const {paypalElementRef} = usePaypal({
productId,
priceId,
});
const {
base_url,
billing: {stripe},
} = useSettings();
if (productQuery.isLoading) {
return <FullPageLoader screen />;
}
const product = productQuery.data?.products.find(
p => p.id === parseInt(productId!)
);
const price = product?.prices.find(p => p.id === parseInt(priceId!));
// make sure product and price exists in backend
if (!product || !price || productQuery.status === 'error') {
return <Navigate to="/pricing" replace />;
}
return (
<CheckoutLayout>
<Fragment>
<h1 className="mb-40 text-4xl">
<Trans message="Checkout" />
</h1>
{stripe.enable ? (
<Fragment>
<StripeElementsForm
productId={productId}
priceId={priceId}
submitLabel={<Trans message="Upgrade" />}
type="subscription"
returnUrl={`${base_url}/checkout/${productId}/${priceId}/stripe/done`}
/>
<Separator />
</Fragment>
) : null}
<div ref={paypalElementRef} />
<div className="mt-30 text-xs text-muted">
<Trans message="Youll be charged until you cancel your subscription. Previous charges wont be refunded when you cancel unless its legally required. Your payment data is encrypted and secure. By subscribing your agree to our terms of service and privacy policy." />
</div>
</Fragment>
<CheckoutProductSummary />
</CheckoutLayout>
);
}
function Separator() {
return (
<div className="relative my-20 text-center before:absolute before:left-0 before:top-1/2 before:h-1 before:w-full before:-translate-y-1/2 before:bg-divider">
<span className="relative z-10 bg px-10 text-sm text-muted">
<Trans message="or" />
</span>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import {CheckoutLayout} from '../checkout-layout';
import {useParams, useSearchParams} from 'react-router-dom';
import {useEffect, useState} from 'react';
import {message} from '@common/i18n/message';
import {CheckoutProductSummary} from '../checkout-product-summary';
import {
BillingRedirectMessage,
BillingRedirectMessageConfig,
} from '../../billing-redirect-message';
import {apiClient} from '@common/http/query-client';
import {useBootstrapData} from '@common/core/bootstrap-data/bootstrap-data-context';
export function CheckoutPaypalDone() {
const {invalidateBootstrapData} = useBootstrapData();
const {productId, priceId} = useParams();
const [params] = useSearchParams();
const [messageConfig, setMessageConfig] =
useState<BillingRedirectMessageConfig>();
useEffect(() => {
const subscriptionId = params.get('subscriptionId');
const status = params.get('status');
if (subscriptionId && status === 'success') {
storeSubscriptionDetailsLocally(subscriptionId).then(() => {
setMessageConfig(
getRedirectMessageConfig('success', productId, priceId),
);
window.location.href = '/billing';
});
} else {
setMessageConfig(getRedirectMessageConfig(status, productId, priceId));
}
}, [priceId, productId, params, invalidateBootstrapData]);
return (
<CheckoutLayout>
<BillingRedirectMessage config={messageConfig} />
<CheckoutProductSummary showBillingLine={false} />
</CheckoutLayout>
);
}
function getRedirectMessageConfig(
status?: 'success' | 'error' | string | null,
productId?: string,
priceId?: string,
): BillingRedirectMessageConfig {
switch (status) {
case 'success':
return {
message: message('Subscription successful!'),
status: 'success',
buttonLabel: message('Return to site'),
link: '/billing',
};
default:
return {
message: message('Something went wrong. Please try again.'),
status: 'error',
buttonLabel: message('Go back'),
link: errorLink(productId, priceId),
};
}
}
function errorLink(productId?: string, priceId?: string): string {
return productId && priceId ? `/checkout/${productId}/${priceId}` : '/';
}
function storeSubscriptionDetailsLocally(subscriptionId: string) {
return apiClient.post('billing/paypal/store-subscription-details-locally', {
paypal_subscription_id: subscriptionId,
});
}

View File

@@ -0,0 +1,85 @@
import {useEffect, useRef, useState} from 'react';
import {loadScript} from '@paypal/paypal-js';
import {useProducts} from '@common/billing/pricing-table/use-products';
import {useSettings} from '@common/core/settings/use-settings';
interface UsePaypalProps {
productId?: string;
priceId?: string;
}
export function usePaypal({productId, priceId}: UsePaypalProps) {
const {data} = useProducts();
const paypalLoadStarted = useRef<boolean>(false);
const paypalButtonsRendered = useRef<boolean>(false);
const [paypalIsLoaded, setPaypalIsLoaded] = useState(false);
const paypalElementRef = useRef<HTMLDivElement>(null);
const {
base_url,
billing: {
stripe: {enable: stripeEnabled},
paypal: {enable: paypalEnabled, public_key},
},
} = useSettings();
useEffect(() => {
if (!paypalEnabled || !public_key || paypalLoadStarted.current) return;
loadScript({
clientId: public_key,
intent: 'subscription',
vault: true,
disableFunding: stripeEnabled ? 'card' : undefined,
}).then(() => {
setPaypalIsLoaded(true);
});
paypalLoadStarted.current = true;
}, [public_key, paypalEnabled, stripeEnabled]);
useEffect(() => {
if (
!paypalIsLoaded ||
!window.paypal?.Buttons ||
!paypalElementRef.current ||
!data?.products.length ||
!productId ||
!priceId ||
paypalButtonsRendered.current
)
return;
const product = data.products.find(p => p.id === parseInt(productId));
const price = product?.prices.find(p => p.id === parseInt(priceId));
window.paypal
.Buttons({
style: {
label: 'pay',
},
createSubscription: (data, actions) => {
return actions.subscription.create({
application_context: {
shipping_preference: 'NO_SHIPPING',
},
plan_id: price?.paypal_id!,
});
},
onApprove: (data, actions) => {
actions.redirect(
`${base_url}/checkout/${productId}/${priceId}/paypal/done?subscriptionId=${data.subscriptionID}&status=success`
);
return Promise.resolve();
},
onError: e => {
location.href = `${base_url}/checkout/${productId}/${priceId}/paypal/done?status=error`;
},
})
.render(paypalElementRef.current)
.then(() => {
paypalButtonsRendered.current = true;
});
}, [productId, priceId, data, paypalIsLoaded, base_url]);
return {
paypalElementRef,
stripeIsEnabled: public_key != null && paypalEnabled,
};
}

View File

@@ -0,0 +1,128 @@
import {CheckoutLayout} from '../checkout-layout';
import {useParams, useSearchParams} from 'react-router-dom';
import {loadStripe, PaymentIntent} from '@stripe/stripe-js';
import {useEffect, useRef, useState} from 'react';
import {message} from '../../../i18n/message';
import {CheckoutProductSummary} from '../checkout-product-summary';
import {
BillingRedirectMessage,
BillingRedirectMessageConfig,
} from '../../billing-redirect-message';
import {useNavigate} from '../../../utils/hooks/use-navigate';
import {apiClient} from '../../../http/query-client';
import {useSettings} from '../../../core/settings/use-settings';
import {useBootstrapData} from '../../../core/bootstrap-data/bootstrap-data-context';
export function CheckoutStripeDone() {
const {invalidateBootstrapData} = useBootstrapData();
const {productId, priceId} = useParams();
const navigate = useNavigate();
const {
billing: {stripe_public_key},
} = useSettings();
const [params] = useSearchParams();
const clientSecret = params.get('payment_intent_client_secret');
const [messageConfig, setMessageConfig] =
useState<BillingRedirectMessageConfig>();
const stripeInitiated = useRef<boolean>();
useEffect(() => {
if (stripeInitiated.current) return;
loadStripe(stripe_public_key!).then(async stripe => {
if (!stripe || !clientSecret) {
setMessageConfig(getRedirectMessageConfig());
return;
}
stripe
.retrievePaymentIntent(clientSecret)
.then(async ({paymentIntent}) => {
if (paymentIntent?.status === 'succeeded') {
await storeSubscriptionDetailsLocally(paymentIntent.id);
setMessageConfig(
getRedirectMessageConfig('succeeded', productId, priceId),
);
window.location.href = '/billing';
} else {
setMessageConfig(
getRedirectMessageConfig(
paymentIntent?.status,
productId,
priceId,
),
);
}
});
});
stripeInitiated.current = true;
}, [
stripe_public_key,
clientSecret,
priceId,
productId,
invalidateBootstrapData,
]);
if (!clientSecret) {
navigate('/');
return null;
}
return (
<CheckoutLayout>
<BillingRedirectMessage config={messageConfig} />
<CheckoutProductSummary showBillingLine={false} />
</CheckoutLayout>
);
}
function getRedirectMessageConfig(
status?: PaymentIntent.Status,
productId?: string,
priceId?: string,
): BillingRedirectMessageConfig {
switch (status) {
case 'succeeded':
return {
message: message('Subscription successful!'),
status: 'success',
buttonLabel: message('Return to site'),
link: '/billing',
};
case 'processing':
return {
message: message(
"Payment processing. We'll update you when payment is received.",
),
status: 'success',
buttonLabel: message('Return to site'),
link: '/billing',
};
case 'requires_payment_method':
return {
message: message('Payment failed. Please try another payment method.'),
status: 'error',
buttonLabel: message('Go back'),
link: errorLink(productId, priceId),
};
default:
return {
message: message('Something went wrong'),
status: 'error',
buttonLabel: message('Go back'),
link: errorLink(productId, priceId),
};
}
}
function errorLink(productId?: string, priceId?: string): string {
return productId && priceId ? `/checkout/${productId}/${priceId}` : '/';
}
function storeSubscriptionDetailsLocally(paymentIntentId: string) {
return apiClient.post('billing/stripe/store-subscription-details-locally', {
payment_intent_id: paymentIntentId,
});
}

View File

@@ -0,0 +1,31 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '../../../../http/query-client';
import {useTrans} from '../../../../i18n/use-trans';
import {BackendResponse} from '../../../../http/backend-response/backend-response';
import {toast} from '../../../../ui/toast/toast';
import {message} from '../../../../i18n/message';
import {showHttpErrorToast} from '../../../../utils/http/show-http-error-toast';
interface Response extends BackendResponse {
clientSecret: string;
}
export function useCreateStripeSubscription(productId: string | number) {
const {trans} = useTrans();
return useMutation({
mutationFn: () => createStripeSubscription(productId),
onSuccess: () => {
toast(trans(message('Mutation performed')));
queryClient.invalidateQueries({queryKey: ['Query Key']});
},
onError: err => showHttpErrorToast(err),
});
}
function createStripeSubscription(
productId: string | number,
): Promise<Response> {
return apiClient
.post('billing/subscriptions/stripe/create', {product_id: productId})
.then(r => r.data);
}

View File

@@ -0,0 +1,94 @@
import clsx from 'clsx';
import {Button} from '@common/ui/buttons/button';
import {FormEventHandler, Fragment, ReactNode, useState} from 'react';
import {useStripe} from '@common/billing/checkout/stripe/use-stripe';
import {Skeleton} from '@common/ui/skeleton/skeleton';
interface StripeElementsFormProps {
productId?: string | number;
priceId?: string | number;
type: 'setupIntent' | 'subscription';
submitLabel: ReactNode;
returnUrl: string;
}
export function StripeElementsForm({
productId,
priceId,
type = 'subscription',
submitLabel,
returnUrl,
}: StripeElementsFormProps) {
const {stripe, elements, paymentElementRef, stripeIsEnabled} = useStripe({
type,
productId,
priceId,
});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// disable upgrade button if stripe is enabled, but not loaded yet
const stripeIsReady: boolean =
!stripeIsEnabled || (elements != null && stripe != null);
const handleSubmit: FormEventHandler = async e => {
e.preventDefault();
// stripe has not loaded yet
if (!stripe || !elements) return;
setIsSubmitting(true);
try {
const method =
type === 'subscription' ? 'confirmPayment' : 'confirmSetup';
const result = await stripe[method]({
elements,
confirmParams: {
return_url: returnUrl,
},
});
// don't show validation error as it will be shown already by stripe payment element
if (result.error?.type !== 'validation_error' && result.error?.message) {
setErrorMessage(result.error.message);
}
} catch {}
setIsSubmitting(false);
};
return (
<form onSubmit={handleSubmit}>
<div
ref={paymentElementRef}
className={clsx('min-h-[303px]', !stripeIsEnabled && 'hidden')}
>
{stripeIsEnabled && <StripeSkeleton />}
</div>
{errorMessage && !isSubmitting && (
<div className="mt-20 text-danger">{errorMessage}</div>
)}
<Button
variant="flat"
color="primary"
size="md"
className="mt-40 w-full"
type="submit"
disabled={isSubmitting || !stripeIsReady}
>
{submitLabel}
</Button>
</form>
);
}
function StripeSkeleton() {
return (
<Fragment>
<Skeleton className="mb-20 h-30" />
<Skeleton className="mb-20 h-30" />
<Skeleton className="mb-20 h-30" />
<Skeleton className="h-30" />
</Fragment>
);
}

View File

@@ -0,0 +1,108 @@
import {useEffect, useRef, useState} from 'react';
import {loadStripe, Stripe, StripeElements} from '@stripe/stripe-js';
import {apiClient} from '@common/http/query-client';
import {useSelectedLocale} from '@common/i18n/selected-locale';
import {useAuth} from '@common/auth/use-auth';
import {useIsDarkMode} from '@common/ui/themes/use-is-dark-mode';
import {useSettings} from '@common/core/settings/use-settings';
interface UseStripeProps {
type: 'setupIntent' | 'subscription';
productId?: string | number;
priceId?: string | number;
}
export function useStripe({type, productId, priceId}: UseStripeProps) {
const {user} = useAuth();
const isDarkMode = useIsDarkMode();
const isInitiatedRef = useRef<boolean>(false);
const paymentElementRef = useRef<HTMLDivElement>(null);
const {localeCode} = useSelectedLocale();
const [stripe, setStripe] = useState<Stripe | null>(null);
const [elements, setElements] = useState<StripeElements | null>(null);
const {
branding: {site_name},
billing: {
stripe_public_key,
stripe: {enable},
},
} = useSettings();
useEffect(() => {
if (!enable || !stripe_public_key || isInitiatedRef.current) return;
Promise.all([
// load stripe js library
loadStripe(stripe_public_key, {
apiVersion: '2022-08-01',
locale: localeCode as any,
}),
// create partial subscription for clientSecret
type === 'setupIntent'
? createSetupIntent()
: createSubscription(productId!, priceId),
]).then(([stripe, {clientSecret}]) => {
if (stripe && paymentElementRef.current) {
const elements = stripe.elements({
clientSecret,
appearance: {
theme: isDarkMode ? 'night' : 'stripe',
},
});
// Create and mount the Payment Element
const paymentElement = elements.create('payment', {
business: {name: site_name},
terms: {card: 'never'},
fields: {
billingDetails: {
address: 'auto',
},
},
defaultValues: {
billingDetails: {
email: user?.email,
},
},
});
paymentElement.mount(paymentElementRef.current);
setStripe(stripe);
setElements(elements);
}
});
isInitiatedRef.current = true;
}, [
productId,
stripe_public_key,
enable,
isDarkMode,
localeCode,
site_name,
type,
user?.email,
]);
return {
stripe,
elements,
paymentElementRef,
stripeIsEnabled: stripe_public_key != null && enable,
};
}
function createSetupIntent(): Promise<{clientSecret: string}> {
return apiClient.post('billing/stripe/create-setup-intent').then(r => r.data);
}
function createSubscription(
productId: number | string,
priceId?: number | string
): Promise<{clientSecret: string}> {
return apiClient
.post('billing/stripe/create-partial-subscription', {
product_id: productId,
price_id: priceId,
})
.then(r => r.data);
}

View File

@@ -0,0 +1,11 @@
import {Subscription} from './subscription';
export interface Invoice {
id: string;
notes: string;
subscription_id: number;
subscription: Subscription;
uuid: string;
paid: boolean;
created_at: string;
}

View File

@@ -0,0 +1,10 @@
export interface Price {
id: number;
amount: number;
currency: string;
interval: 'day' | 'week' | 'month' | 'year';
interval_count: number;
subscriptions_count?: number;
stripe_id?: string;
paypal_id?: string;
}

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

View File

@@ -0,0 +1,14 @@
import {Price} from './price';
export interface Product {
id: number;
name: string;
description: string;
feature_list: string[];
free?: boolean;
hidden?: boolean;
prices: Price[];
recommended: boolean;
created_at: string;
updated_at: string;
}

View File

@@ -0,0 +1,35 @@
import {keepPreviousData, useQuery} from '@tanstack/react-query';
import {apiClient} from '../../http/query-client';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {Product} from '../product';
import {useParams} from 'react-router-dom';
const endpoint = (productId: string | number) =>
`billing/products/${productId}`;
export interface FetchProductResponse extends BackendResponse {
product: Product;
}
export function useCheckoutProduct() {
const {productId, priceId} = useParams();
const query = useQuery({
queryKey: [endpoint(productId!)],
queryFn: () => fetchProduct(productId!),
placeholderData: keepPreviousData,
enabled: productId != null && priceId != null,
});
const product = query.data?.product;
const price =
product?.prices.find(p => p.id === parseInt(priceId!)) ||
product?.prices[0];
return {status: query.status, product, price};
}
function fetchProduct(
productId: string | number,
): Promise<FetchProductResponse> {
return apiClient.get(endpoint(productId)).then(response => response.data);
}

View File

@@ -0,0 +1,28 @@
import {User} from '../auth/user';
import {Price} from './price';
import {Product} from './product';
export interface Subscription {
id: number;
price_id: number;
product_id: number;
user_id: number;
on_grace_period?: boolean;
gateway_name: 'stripe' | 'paypal' | 'none';
gateway_id: string;
gateway_status: string;
valid?: boolean;
past_due?: boolean;
active?: boolean;
cancelled?: boolean;
on_trial?: boolean;
price?: Price;
product?: Product;
trial_ends_at: string;
ends_at: string;
updated_at: string;
created_at: string;
description: string;
renews_at: string;
user?: User;
}

View File

@@ -0,0 +1,25 @@
import {Trans} from '@common/i18n/trans';
import {ReactNode} from 'react';
import {UpgradeDialog} from '@common/billing/upgrade/upgrade-dialog';
interface FeatureLockedDialogProps {
message?: ReactNode;
messageSuffix?: ReactNode;
}
export function FeatureLockedDialog({
message,
messageSuffix,
}: FeatureLockedDialogProps) {
return (
<UpgradeDialog
message={message}
messageSuffix={
messageSuffix === undefined ? (
<Trans message="Upgrade to unlock this feature and many more." />
) : (
messageSuffix
)
}
/>
);
}

View File

@@ -0,0 +1,63 @@
import {LockIcon} from '@common/icons/material/Lock';
import {Trans} from '@common/i18n/trans';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {ReactNode} from 'react';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {useSettings} from '@common/core/settings/use-settings';
import {FeatureLockedDialog} from '@common/billing/upgrade/feature-locked-dialog';
import clsx from 'clsx';
import {IconButton} from '@common/ui/buttons/icon-button';
import {Button} from '@common/ui/buttons/button';
interface UpgradeButtonProps {
message?: ReactNode;
className?: string;
iconButton?: boolean;
}
export function NoPermissionButton({
message,
className,
iconButton,
}: UpgradeButtonProps) {
const {billing} = useSettings();
if (!billing.enable) {
return <GenericButton className={className} />;
}
return (
<DialogTrigger type="popover" triggerOnHover>
{iconButton ? (
<IconButton className={className} color="primary" size="sm">
<LockIcon />
</IconButton>
) : (
<Button
variant="flat"
color="primary"
size="2xs"
startIcon={<LockIcon />}
className={className}
>
<Trans message="Upgrade" />
</Button>
)}
<FeatureLockedDialog message={message} />
</DialogTrigger>
);
}
interface GenericButtonProps {
className?: string;
}
function GenericButton({className}: GenericButtonProps) {
return (
<Tooltip
label={
<Trans message="You don't have permissions to access this feature." />
}
>
<LockIcon size="sm" className={clsx('text-muted', className)} />
</Tooltip>
);
}

View File

@@ -0,0 +1,24 @@
import {Trans} from '@common/i18n/trans';
import {MessageDescriptor} from '@common/i18n/message-descriptor';
import {useTrans} from '@common/i18n/use-trans';
import {UpgradeDialog} from '@common/billing/upgrade/upgrade-dialog';
interface FeatureLockedDialogProps {
resourceName: MessageDescriptor;
}
export function OverQuotaDialog({resourceName}: FeatureLockedDialogProps) {
const {trans} = useTrans();
return (
<UpgradeDialog
message={
<Trans
message="You've reached the maximum number of :resource allowed for your current plan."
values={{resource: trans(resourceName)}}
/>
}
messageSuffix={
<Trans message="Upgrade to increase this limit and unlock other features." />
}
/>
);
}

View File

@@ -0,0 +1,84 @@
import {Trans} from '@common/i18n/trans';
import {Link} from 'react-router-dom';
import {LinkStyle} from '@common/ui/buttons/external-link';
import {ReactElement, ReactNode} from 'react';
import {SectionHelper, SectionHelperProps} from '@common/ui/section-helper';
import {useSettings} from '@common/core/settings/use-settings';
import {PolicyFailReason} from '@common/billing/upgrade/policy-fail-reason';
interface Props {
className?: string;
// plural name in lowercase (e.g. 'projects')
resourceName?: ReactElement | string;
reason?: PolicyFailReason;
size?: SectionHelperProps['size'];
color?: SectionHelperProps['color'];
message?: ReactNode;
}
export function PolicyFailMessage({
resourceName,
className,
size = 'md',
color = 'bgAlt',
reason = 'overQuota',
...other
}: Props) {
const message = other.message ?? (
<MessageText resourceName={resourceName!} reason={reason} />
);
return (
<SectionHelper
color={color}
size={size}
className={className}
description={message}
/>
);
}
interface MessageTextProps {
resourceName: ReactElement | string;
reason?: PolicyFailReason;
}
function MessageText({resourceName, reason}: MessageTextProps) {
const {billing} = useSettings();
if (reason === 'noWorkspacePermission') {
return (
<Trans
message="You can't create new :name in this workspace."
values={{name: resourceName}}
/>
);
}
const upgradeMsgValues = {
name: resourceName,
a: (text: ReactNode) => (
<Link className={LinkStyle} to="/pricing">
{text}
</Link>
),
};
if (reason === 'overQuota' && billing.enable) {
return (
<Trans
message="Your plan is at its maximum number of :name allowed. <a>Upgrade to add more.</a>"
values={upgradeMsgValues}
/>
);
}
if (reason === 'noPermission' && billing.enable) {
return (
<Trans
message="To unlock ability to create :name. <a>Upgrade your plan.</a>"
values={upgradeMsgValues}
/>
);
}
return <Trans message="You don't have permissions to create :name." />;
}

View File

@@ -0,0 +1,4 @@
export type PolicyFailReason =
| 'overQuota'
| 'noPermission'
| 'noWorkspacePermission';

View File

@@ -0,0 +1,58 @@
import {ReactNode} from 'react';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {Dialog} from '@common/ui/overlays/dialog/dialog';
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
import {Trans} from '@common/i18n/trans';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {SvgImage} from '@common/ui/images/svg-image/svg-image';
import upgradeSvg from '@common/billing/upgrade/upgrade.svg';
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
import {Button} from '@common/ui/buttons/button';
import {Link} from 'react-router-dom';
interface UpgradeDialogProps {
message?: ReactNode;
messageSuffix?: ReactNode;
}
export function UpgradeDialog({message, messageSuffix}: UpgradeDialogProps) {
const {close} = useDialogContext();
return (
<Dialog size="sm">
<DialogHeader>
<Trans message="Join the PROs" />
</DialogHeader>
<DialogBody>
<div className="mb-20 text-center">
<SvgImage src={upgradeSvg} className="mx-auto" height="h-100" />
</div>
<div>
{message} {messageSuffix}
</div>
</DialogBody>
<DialogFooter>
<Button
variant="text"
size="xs"
onClick={() => {
close();
}}
>
<Trans message="Maybe later" />
</Button>
<Button
autoFocus
variant="flat"
size="xs"
color="primary"
elementType={Link}
to="/pricing"
target="_blank"
onClick={() => close()}
>
<Trans message="Find out more" />
</Button>
</DialogFooter>
</Dialog>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB