mirror of
https://github.com/astuto/astuto.git
synced 2025-12-16 03:37:56 +01:00
Add billing (#329)
This commit is contained in:
committed by
GitHub
parent
fc36c967af
commit
bea146e612
90
app/javascript/components/Billing/PricingTable.tsx
Normal file
90
app/javascript/components/Billing/PricingTable.tsx
Normal 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>
|
||||
|
||||
<span className="currency">{price.currency}</span>
|
||||
/
|
||||
<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>
|
||||
|
||||
<span className="currency">{price.currency}</span>
|
||||
/
|
||||
<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;
|
||||
60
app/javascript/components/Billing/Return.tsx
Normal file
60
app/javascript/components/Billing/Return.tsx
Normal 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;
|
||||
186
app/javascript/components/Billing/index.tsx
Normal file
186
app/javascript/components/Billing/index.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user