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