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