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,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>
);
}

View 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>
);
}

View 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>
);
}

View 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="Youll be charged until you cancel your subscription. Previous charges wont be refunded when you cancel unless its 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>
);
}

View 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,
});
}

View 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,
};
}

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);
}