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']});
|
||||
}
|
||||
Reference in New Issue
Block a user