first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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