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