Add billing (#329)

This commit is contained in:
Riccardo Graziosi
2024-05-03 18:11:07 +02:00
committed by GitHub
parent fc36c967af
commit bea146e612
63 changed files with 1354 additions and 27 deletions

View File

@@ -0,0 +1,90 @@
import * as React from 'react';
interface Props {
prices: Array<any>;
currentPrice: string;
setCurrentPrice: (priceId: string) => void;
setChosenPrice: (priceId: string) => void;
stripeMonthlyLookupKey: string;
stripeYearlyLookupKey: string;
}
const PricingTable = ({
prices,
currentPrice,
setCurrentPrice,
setChosenPrice,
stripeMonthlyLookupKey,
stripeYearlyLookupKey,
}: Props) => {
const monthlyPlanUnitAmount = prices.find(p => p.lookup_key === stripeMonthlyLookupKey).unit_amount;
const yearlyPlanUnitAmount = prices.find(p => p.lookup_key === stripeYearlyLookupKey).unit_amount;
const yearlyPlanDiscount = 1 - yearlyPlanUnitAmount / (monthlyPlanUnitAmount*12)
return (
<div className="pricingTable">
<h3>Choose your plan</h3>
<ul className="pricingPlansNav">
{
prices && prices.map((price) => (
<li key={price.id} className="nav-item">
<a className={`nav-link${currentPrice === price.id ? ' active' : ''}`} onClick={() => setCurrentPrice(price.id)}>
{price.lookup_key}
{
price.lookup_key === stripeYearlyLookupKey &&
<span className="yearlyPlanDiscount">-{yearlyPlanDiscount * 100}%</span>
}
</a>
</li>
))
}
</ul>
{
prices && prices.filter(price => price.id === currentPrice).map((price) => (
<div key={price.id} className="pricingTableColumn">
<h4>{ price.lookup_key === stripeMonthlyLookupKey ? 'Monthly subscription' : 'Yearly subscription' }</h4>
<div className="priceContainer">
<p className="price">
<span className="amount">{price.unit_amount / 100.0}</span>
&nbsp;
<span className="currency">{price.currency}</span>
&nbsp;/&nbsp;
<span className="period">{price.recurring.interval}</span>
</p>
{
price.lookup_key === stripeYearlyLookupKey &&
<p className="priceYearly">
(
<span className="amount">{price.unit_amount / 100.0 / 12}</span>
&nbsp;
<span className="currency">{price.currency}</span>
&nbsp;/&nbsp;
<span className="period">month</span>
)
</p>
}
</div>
<p className="description">
For most small-medium organizations.<br />
Bigger organizations can <a className="link" href="mailto:info@astuto.io">contact us</a> for a custom plan.
</p>
<ul className="features">
<li>All features</li>
<li>Unlimited feedback</li>
<li>Unlimited boards</li>
</ul>
<button onClick={() => setChosenPrice(price.id)} className="btnPrimary">Subscribe</button>
</div>
))
}
</div>
);
};
export default PricingTable;

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { useEffect, useState } from "react";
import Box from '../common/Box';
import ActionLink from '../common/ActionLink';
import { BackIcon } from '../common/Icons';
import ITenantBilling from '../../interfaces/ITenantBilling';
interface Props {
tenantBilling: ITenantBilling;
homeUrl: string;
billingUrl: string;
}
const Return = ({ tenantBilling, homeUrl, billingUrl }: Props) => {
const [status, setStatus] = useState(null);
const [session, setSession] = useState(null);
useEffect(() => {
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const sessionId = urlParams.get('session_id');
fetch(`/session_status?session_id=${sessionId}&tenant_id=${tenantBilling.slug}`)
.then(res => res.json())
.then(data => {
setStatus(data.status);
setSession(data.session);
});
}, []);
if (status === 'open') {
return (
<Box customClass="billingContainer">
<h2>Error</h2>
<p>Unfortunately, there was an error processing your payment. Please try again.</p>
<ActionLink onClick={() => window.location.href = billingUrl} icon={<BackIcon />}>
Back to billing
</ActionLink>
</Box>
)
}
if (status === 'complete') {
return (
<Box customClass="billingContainer">
<h2>Success</h2>
<p>Thank you for choosing Astuto! Your subscription will be activated shortly.</p>
<ActionLink onClick={() => window.location.href = homeUrl} icon={<BackIcon />}>
Back to home
</ActionLink>
</Box>
)
}
return null;
}
export default Return;

View File

@@ -0,0 +1,186 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { loadStripe } from '@stripe/stripe-js';
import ITenantBilling, { TENANT_BILLING_STATUS_ACTIVE, TENANT_BILLING_STATUS_CANCELED, TENANT_BILLING_STATUS_TRIAL } from '../../interfaces/ITenantBilling';
import Box from '../common/Box';
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import { SmallMutedText } from '../common/CustomTexts';
import ActionLink from '../common/ActionLink';
import { BackIcon, LearnMoreIcon } from '../common/Icons';
import PricingTable from './PricingTable';
interface Props {
tenantBilling: ITenantBilling;
prices: Array<any>;
createCheckoutSessionUrl: string;
billingUrl: string;
manageSubscriptionUrl: string;
stripeMonthlyLookupKey: string;
stripeYearlyLookupKey: string;
stripePublicKey: string;
authenticityToken: string;
}
const Billing = ({
tenantBilling,
prices,
createCheckoutSessionUrl,
billingUrl,
manageSubscriptionUrl,
stripeMonthlyLookupKey,
stripeYearlyLookupKey,
stripePublicKey,
authenticityToken,
}: Props) => {
const [stripePromise, setStripePromise] = React.useState(null);
const [currentPrice, setCurrentPrice] = React.useState(null);
const [chosenPrice, setChosenPrice] = React.useState(null);
const [showBackLink, setShowBackLink] = React.useState(false);
React.useEffect(() => {
setStripePromise(loadStripe(stripePublicKey));
}, []);
React.useEffect(() => {
if (prices && prices.length > 0) {
setCurrentPrice(prices[0].id);
}
}, [prices]);
const fetchClientSecret = React.useCallback(() => {
// Create a Checkout Session
return fetch(`${createCheckoutSessionUrl}?price_id=${chosenPrice}&tenant_id=${tenantBilling.slug}`, {
method: "POST",
headers: buildRequestHeaders(authenticityToken),
})
.then((res) => res.json())
.then((data) => data.clientSecret);
}, [chosenPrice]);
React.useEffect(() => {
if (chosenPrice) {
// scroll to checkout
const checkoutElement = document.getElementById('checkout');
setTimeout(() => {
checkoutElement.scrollIntoView({behavior: 'smooth'});
}, 300);
// show back link after 5 seconds
const timer = setTimeout(() => {
setShowBackLink(true);
}, 5000);
return () => clearTimeout(timer); // cleanup on unmount or when chosenPrice changes
} else {
setShowBackLink(false); // reset state when chosenPrice becomes null
}
}, [chosenPrice]);
const options = {fetchClientSecret};
const currentTime = new Date();
const trialEndsAt = new Date(tenantBilling.trial_ends_at);
const trialEndsAtFormatted = trialEndsAt.toLocaleString(undefined, {year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit'});
const subscriptionEndsAt = new Date(tenantBilling.subscription_ends_at);
const subscriptionEndsAtFormatted = subscriptionEndsAt.toLocaleString(undefined, {year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit'});
const isExpired = (
(tenantBilling.status === TENANT_BILLING_STATUS_TRIAL && trialEndsAt < currentTime) ||
((tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE || tenantBilling.status === TENANT_BILLING_STATUS_CANCELED) && subscriptionEndsAt < currentTime)
);
return (
<Box customClass="billingContainer">
<h2>{ I18n.t('billing.title') }</h2>
<p>
<span className="billingStatusBadge">{tenantBilling.status}</span>
{ isExpired && <span className="billingStatusBadge billingStatusBadgeExpired">Expired</span> }
</p>
{
tenantBilling.status === TENANT_BILLING_STATUS_TRIAL &&
<p>Trial {isExpired ? 'expired' : 'expires'} on <b>{trialEndsAtFormatted}</b></p>
}
{
tenantBilling.status === TENANT_BILLING_STATUS_TRIAL && isExpired &&
<p>Your trial has expired. Please choose a subscription plan to continue using the service.</p>
}
{
tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE &&
<p>Subscription {isExpired ? 'expired' : 'renews'} on {subscriptionEndsAtFormatted}</p>
}
{
tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE && isExpired &&
<p>Your subscription has expired because automatic renewal failed. Please update your payment details by managing your subscription.</p>
}
{
tenantBilling.status === TENANT_BILLING_STATUS_CANCELED &&
<p>Subscription {isExpired ? 'expired' : 'expires'} on {subscriptionEndsAtFormatted}</p>
}
{
(tenantBilling.status === TENANT_BILLING_STATUS_TRIAL) && chosenPrice === null &&
<PricingTable
prices={prices}
currentPrice={currentPrice}
setCurrentPrice={setCurrentPrice}
setChosenPrice={setChosenPrice}
stripeMonthlyLookupKey={stripeMonthlyLookupKey}
stripeYearlyLookupKey={stripeYearlyLookupKey}
/>
}
{
chosenPrice &&
<div className="checkoutContainer">
{ showBackLink ?
<ActionLink onClick={() => window.location.href = billingUrl} icon={<BackIcon />}>
Choose another plan
</ActionLink>
:
<br />
}
<div id="checkout">
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={options}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
</div>
}
{
(tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE || tenantBilling.status === TENANT_BILLING_STATUS_CANCELED) &&
<div className="billingAction">
<button className="btnPrimary" onClick={() => window.open(manageSubscriptionUrl, '_blank')}>
{tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE ? 'Manage subscription' : 'Renew subscription'}
</button>
<SmallMutedText>
You will be redirected to Stripe, our billing partner.
</SmallMutedText>
</div>
}
<div className="billingUsefulLinks">
<ActionLink onClick={() => window.open('https://astuto.io/terms-of-service', '_blank')} icon={<LearnMoreIcon />}>
Terms of Service
</ActionLink>
<ActionLink onClick={() => window.open('https://astuto.io/privacy-policy', '_blank')} icon={<LearnMoreIcon />}>
Privacy Policy
</ActionLink>
</div>
</Box>
);
}
export default Billing;

View File

@@ -18,7 +18,7 @@ const ConfirmSignUpPage = ({
<img src={pendingTenantImage} width={64} height={64} style={{margin: '12px auto'}} />
<p style={{textAlign: 'center'}}>
Check your email <b>{userEmail}</b> to activate your new feedback space {subdomain}.astuto.io!
Check your email <b>{userEmail}</b> to activate your new feedback space <a href={`https://${subdomain}.astuto.io`} className="link">{`${subdomain}.astuto.io`}</a>!
</p>
</Box>
);

View File

@@ -4,7 +4,7 @@ import { SubmitHandler, useForm } from 'react-hook-form';
import Box from '../common/Box';
import Button from '../common/Button';
import Spinner from '../common/Spinner';
import { DangerText } from '../common/CustomTexts';
import { DangerText, SmallMutedText } from '../common/CustomTexts';
import { ITenantSignUpTenantForm } from './TenantSignUpP';
import HttpStatus from '../../constants/http_status';
import { getLabel, getValidationMessage } from '../../helpers/formUtils';
@@ -13,12 +13,14 @@ interface Props {
isSubmitting: boolean;
error: string;
handleSignUpSubmit(siteName: string, subdomain: string): void;
trialPeriodDays: number;
}
const TenantSignUpForm = ({
isSubmitting,
error,
handleSignUpSubmit,
trialPeriodDays,
}: Props) => {
const { register, handleSubmit, formState: { errors } } = useForm<ITenantSignUpTenantForm>();
const onSubmit: SubmitHandler<ITenantSignUpTenantForm> = data => {
@@ -80,6 +82,12 @@ const TenantSignUpForm = ({
>
{ isSubmitting ? <Spinner /> : 'Create feedback space' }
</Button>
<p className="smallMutedText" style={{textAlign: 'center'}}>
Your trial starts now and ends in {trialPeriodDays.toString()} days.
</p>
<p className="smallMutedText" style={{textAlign: 'center'}}>
By clicking "Create", you agree to our <a href="https://astuto.io/terms-of-service" target="_blank" className="link">Terms of Service</a> and <a href="https://astuto.io/privacy-policy" target="_blank" className="link">Privacy Policy</a>.
</p>
{ error !== '' && <DangerText>{ error }</DangerText> }
</form>

View File

@@ -30,6 +30,8 @@ interface Props {
pendingTenantImage: string;
baseUrl: string;
trialPeriodDays: number;
authenticityToken: string;
}
@@ -58,6 +60,7 @@ const TenantSignUpP = ({
astutoLogoImage,
pendingTenantImage,
baseUrl,
trialPeriodDays,
authenticityToken
}: Props) => {
// authMethod is either 'none', 'email' or 'oauth'
@@ -124,6 +127,7 @@ const TenantSignUpP = ({
isSubmitting={isSubmitting}
error={error}
handleSignUpSubmit={handleSignUpSubmit}
trialPeriodDays={trialPeriodDays}
/>
}

View File

@@ -15,6 +15,7 @@ interface Props {
baseUrl: string;
astutoLogoImage: string;
pendingTenantImage: string;
trialPeriodDays: number;
authenticityToken: string;
}
@@ -36,6 +37,7 @@ class TenantSignUpRoot extends React.Component<Props> {
astutoLogoImage,
pendingTenantImage,
baseUrl,
trialPeriodDays,
authenticityToken,
} = this.props;
@@ -49,6 +51,7 @@ class TenantSignUpRoot extends React.Component<Props> {
astutoLogoImage={astutoLogoImage}
pendingTenantImage={pendingTenantImage}
baseUrl={baseUrl}
trialPeriodDays={trialPeriodDays}
authenticityToken={authenticityToken}
/>
</Provider>