From de0ef3d3b41593b871c282d3964978bf89e96b4f Mon Sep 17 00:00:00 2001 From: Antoine Jaussoin Date: Sun, 13 Mar 2022 18:11:09 +0000 Subject: [PATCH] Adding annual payment support (#362) --- README.md | 5 + backend/package.json | 2 +- backend/src/common/payloads.ts | 1 + backend/src/stripe/products.ts | 8 +- backend/src/stripe/router.ts | 7 +- docs/package.json | 2 +- frontend/package.json | 2 +- frontend/public/index.html | 6 +- frontend/src/Layout.tsx | 27 ++++- frontend/src/common/payloads.ts | 1 + frontend/src/components/ProButton/index.tsx | 3 +- frontend/src/translations/ar.ts | 2 + frontend/src/translations/de.ts | 2 + frontend/src/translations/en.ts | 3 + frontend/src/translations/es.ts | 2 + frontend/src/translations/fr.ts | 3 + frontend/src/translations/hu.ts | 2 + frontend/src/translations/it.ts | 2 + frontend/src/translations/ja.ts | 2 + frontend/src/translations/nl.ts | 2 + frontend/src/translations/pl.ts | 2 + frontend/src/translations/pt-br.ts | 2 + frontend/src/translations/ru.ts | 2 + frontend/src/translations/types.ts | 2 + frontend/src/translations/zh-cn.ts | 2 + frontend/src/translations/zh-tw.ts | 2 + .../src/views/subscribe/SubscribePage.tsx | 98 ++++++++++++------- frontend/src/views/subscribe/api.ts | 4 +- .../views/subscribe/components/Product.tsx | 14 ++- .../subscribe/components/ProductPicker.tsx | 3 + integration/package.json | 2 +- package.json | 2 +- 32 files changed, 164 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 51a7b3d25..6fd4a8ea4 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,11 @@ This will run a demo version, which you can turn into a fully licenced version b ## Versions History +### Version 4.13.0 (unreleased) + +- Adding the option of paying for Retrospected Pro annually, getting one month free in the process +- Update prices, especially for USD + ### Version 4.12.1 (hotfix) - Adding users to a Pro Team subscription wasn't working anymore, because of Webpack 5. diff --git a/backend/package.json b/backend/package.json index ea75c6aa8..98cf27262 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@retrospected/backend", - "version": "4.12.1", + "version": "4.13.0", "license": "GNU GPLv3", "private": true, "scripts": { diff --git a/backend/src/common/payloads.ts b/backend/src/common/payloads.ts index 32488c721..9fdf11fec 100644 --- a/backend/src/common/payloads.ts +++ b/backend/src/common/payloads.ts @@ -36,6 +36,7 @@ export interface CreateSubscriptionPayload { currency: Currency; locale: StripeLocales; domain: string | null; + yearly: boolean; } export interface CreateSessionPayload { diff --git a/backend/src/stripe/products.ts b/backend/src/stripe/products.ts index fd3ea86f0..6c277078e 100644 --- a/backend/src/stripe/products.ts +++ b/backend/src/stripe/products.ts @@ -9,9 +9,9 @@ export const teamPlan: InternalProduct = { priceId: config.STRIPE_TEAM_PRICE, recurring: true, seats: 20, - eur: 995, - gbp: 895, - usd: 995, + eur: 1190, + gbp: 990, + usd: 1290, }; export const companyPlan: InternalProduct = { @@ -35,7 +35,7 @@ export const selfHostedPlan: InternalProduct = { seats: null, eur: 59900, gbp: 49900, - usd: 59900, + usd: 64900, paymentsUrls: { eur: config.STRIPE_SELF_HOSTED_URL_EUR, gbp: config.STRIPE_SELF_HOSTED_URL_GBP, diff --git a/backend/src/stripe/router.ts b/backend/src/stripe/router.ts index 39bee4d38..4a89e9eac 100644 --- a/backend/src/stripe/router.ts +++ b/backend/src/stripe/router.ts @@ -179,6 +179,7 @@ function stripeRouter(): Router { router.post('/create-checkout-session', csrfProtection, async (req, res) => { const payload = req.body as CreateSubscriptionPayload; + const { yearly, ...actualPayload } = payload; const identity = await getIdentityFromRequest(req); const product = getProduct(payload.plan); @@ -194,7 +195,7 @@ function stripeRouter(): Router { client_reference_id: identity.user.id, customer: customerId, metadata: { - ...payload, + ...actualPayload, }, line_items: [ { @@ -203,10 +204,10 @@ function stripeRouter(): Router { product: product.productId, currency: payload.currency, recurring: { - interval: 'month', + interval: yearly ? 'year' : 'month', interval_count: 1, }, - unit_amount: product[payload.currency], + unit_amount: product[payload.currency] * (yearly ? 11 : 1), }, }, ], diff --git a/docs/package.json b/docs/package.json index b4d5a5778..e04f68f7b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "docs", - "version": "4.12.1", + "version": "4.13.0", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/frontend/package.json b/frontend/package.json index f7d25157e..0a2a0ac87 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@retrospected/frontend", - "version": "4.12.1", + "version": "4.13.0", "license": "GNU GPLv3", "private": true, "dependencies": { diff --git a/frontend/public/index.html b/frontend/public/index.html index 6279f04bc..5cd05e6ac 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -634,7 +634,7 @@

Retrospected Pro
For complete data safety and privacy

pricing img
-
$9.95
+
$12.90
/month
@@ -681,7 +681,7 @@

Retrospected Pro
For complete data safety and privacy

pricing img
-
$599
+
$649
One-time fee
@@ -714,7 +714,7 @@
How can I pay for this?
Is it possible to pay yearly?
-

Not yet, but we might add this possibility in the future.

+

You can! Simply select this option at checkout and you will get one month free per year.

diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx index 5048b7961..e0a8786d2 100644 --- a/frontend/src/Layout.tsx +++ b/frontend/src/Layout.tsx @@ -16,8 +16,10 @@ import { HomeOutlined } from '@mui/icons-material'; import ProPill from './components/ProPill'; import { CodeSplitLoader } from './CodeSplitLoader'; import useSidePanel from './views/panel/useSidePanel'; -import { Alert, AlertTitle } from '@mui/material'; +import { Alert, AlertTitle, Button, Hidden } from '@mui/material'; import useBackendCapabilities from './global/useBackendCapabilities'; +import useIsPro from 'auth/useIsPro'; +import ProButton from 'components/ProButton'; const Home = lazy(() => import('./views/Home' /* webpackChunkName: "home" */)); const Game = lazy(() => import('./views/Game' /* webpackChunkName: "game" */)); @@ -90,6 +92,7 @@ function App() { const { toggle: togglePanel } = useSidePanel(); const isInitialised = useIsInitialised(); const user = useUser(); + const isPro = useIsPro(); const goToHome = useCallback(() => history.push('/'), [history]); useEffect(() => { trackPageView(window.location.pathname); @@ -144,6 +147,18 @@ function App() { + {!isPro ? ( + + + + + + + + ) : null} + {isInitialised ? ( @@ -193,10 +208,16 @@ const HomeButton = styled.div` margin-right: 10px; `; -const ProPillContainer = styled.div` - flex: 1; +const ProPillContainer = styled.div``; + +const GoProContainer = styled.div` + margin-left: 20px; `; const Initialising = styled.div``; +const Spacer = styled.div` + flex: 1; +`; + export default App; diff --git a/frontend/src/common/payloads.ts b/frontend/src/common/payloads.ts index 32488c721..9fdf11fec 100644 --- a/frontend/src/common/payloads.ts +++ b/frontend/src/common/payloads.ts @@ -36,6 +36,7 @@ export interface CreateSubscriptionPayload { currency: Currency; locale: StripeLocales; domain: string | null; + yearly: boolean; } export interface CreateSessionPayload { diff --git a/frontend/src/components/ProButton/index.tsx b/frontend/src/components/ProButton/index.tsx index 355e9401c..356a991d5 100644 --- a/frontend/src/components/ProButton/index.tsx +++ b/frontend/src/components/ProButton/index.tsx @@ -47,8 +47,9 @@ function ProButton({ children, quota }: ProButtonProps) { e.preventDefault(); trackEvent('trial/modal/subscribe'); history.push('/subscribe'); + close(); }, - [history] + [history, close] ); const handleStartTrial = useCallback( diff --git a/frontend/src/translations/ar.ts b/frontend/src/translations/ar.ts index 70bb8b32e..908d3ac1d 100644 --- a/frontend/src/translations/ar.ts +++ b/frontend/src/translations/ar.ts @@ -357,6 +357,8 @@ export default { users: undefined, unlimited_seats: undefined, month: undefined, + year: undefined, + wantToPayYearly: undefined, }, Encryption: { createEncryptedSession: undefined, diff --git a/frontend/src/translations/de.ts b/frontend/src/translations/de.ts index 9fac1540a..4ebbaa2f0 100644 --- a/frontend/src/translations/de.ts +++ b/frontend/src/translations/de.ts @@ -364,6 +364,8 @@ export default { users: undefined, unlimited_seats: undefined, month: undefined, + year: undefined, + wantToPayYearly: undefined, }, Encryption: { createEncryptedSession: undefined, diff --git a/frontend/src/translations/en.ts b/frontend/src/translations/en.ts index 4de58ad3f..69e57efb5 100644 --- a/frontend/src/translations/en.ts +++ b/frontend/src/translations/en.ts @@ -426,6 +426,9 @@ export default { users: (users: number) => `${users} users`, unlimited_seats: 'Unlimited', month: 'month', + year: 'year', + wantToPayYearly: + 'I want to pay annually (every 12 months), and get one month free per year!', }, Encryption: { createEncryptedSession: 'Encrypted Session', diff --git a/frontend/src/translations/es.ts b/frontend/src/translations/es.ts index 94eb07d02..432295b5c 100644 --- a/frontend/src/translations/es.ts +++ b/frontend/src/translations/es.ts @@ -360,6 +360,8 @@ export default { users: undefined, unlimited_seats: undefined, month: undefined, + year: undefined, + wantToPayYearly: undefined, }, Encryption: { createEncryptedSession: undefined, diff --git a/frontend/src/translations/fr.ts b/frontend/src/translations/fr.ts index 8aa5dac97..1593a22b1 100644 --- a/frontend/src/translations/fr.ts +++ b/frontend/src/translations/fr.ts @@ -427,6 +427,9 @@ export default { users: (users: number) => `${users} utilisateurs`, unlimited_seats: 'Illimité', month: 'mois', + year: 'an', + wantToPayYearly: + 'Je souhaite payer annuellement, et obtenir un mois gratuit par an !', }, Encryption: { createEncryptedSession: 'Session cryptée', diff --git a/frontend/src/translations/hu.ts b/frontend/src/translations/hu.ts index 17f28b292..a21fe5d34 100644 --- a/frontend/src/translations/hu.ts +++ b/frontend/src/translations/hu.ts @@ -360,6 +360,8 @@ export default { users: undefined, unlimited_seats: undefined, month: undefined, + year: undefined, + wantToPayYearly: undefined, }, Encryption: { createEncryptedSession: undefined, diff --git a/frontend/src/translations/it.ts b/frontend/src/translations/it.ts index bebe26331..fa5159261 100644 --- a/frontend/src/translations/it.ts +++ b/frontend/src/translations/it.ts @@ -370,6 +370,8 @@ export default { users: undefined, unlimited_seats: undefined, month: undefined, + year: undefined, + wantToPayYearly: undefined, }, Encryption: { createEncryptedSession: undefined, diff --git a/frontend/src/translations/ja.ts b/frontend/src/translations/ja.ts index 4d3c42da0..595995735 100644 --- a/frontend/src/translations/ja.ts +++ b/frontend/src/translations/ja.ts @@ -358,6 +358,8 @@ export default { users: undefined, unlimited_seats: undefined, month: undefined, + year: undefined, + wantToPayYearly: undefined, }, Encryption: { createEncryptedSession: undefined, diff --git a/frontend/src/translations/nl.ts b/frontend/src/translations/nl.ts index 01d4332a8..2fea7dca7 100644 --- a/frontend/src/translations/nl.ts +++ b/frontend/src/translations/nl.ts @@ -370,6 +370,8 @@ export default { users: undefined, unlimited_seats: undefined, month: undefined, + year: undefined, + wantToPayYearly: undefined, }, Encryption: { createEncryptedSession: undefined, diff --git a/frontend/src/translations/pl.ts b/frontend/src/translations/pl.ts index 1dc3487bc..f1a8a4c75 100644 --- a/frontend/src/translations/pl.ts +++ b/frontend/src/translations/pl.ts @@ -360,6 +360,8 @@ export default { users: undefined, unlimited_seats: undefined, month: undefined, + year: undefined, + wantToPayYearly: undefined, }, Encryption: { createEncryptedSession: undefined, diff --git a/frontend/src/translations/pt-br.ts b/frontend/src/translations/pt-br.ts index 388f1e6f6..7e10462cc 100644 --- a/frontend/src/translations/pt-br.ts +++ b/frontend/src/translations/pt-br.ts @@ -360,6 +360,8 @@ export default { users: undefined, unlimited_seats: undefined, month: undefined, + year: undefined, + wantToPayYearly: undefined, }, Encryption: { createEncryptedSession: undefined, diff --git a/frontend/src/translations/ru.ts b/frontend/src/translations/ru.ts index 645b8faef..ee1359b8a 100644 --- a/frontend/src/translations/ru.ts +++ b/frontend/src/translations/ru.ts @@ -358,6 +358,8 @@ export default { users: undefined, unlimited_seats: undefined, month: undefined, + year: undefined, + wantToPayYearly: undefined, }, Encryption: { createEncryptedSession: undefined, diff --git a/frontend/src/translations/types.ts b/frontend/src/translations/types.ts index b6adc2426..9bdb1bfcc 100644 --- a/frontend/src/translations/types.ts +++ b/frontend/src/translations/types.ts @@ -360,6 +360,8 @@ export interface Translation { users?: (users: number) => string; unlimited_seats?: string; month?: string; + year?: string; + wantToPayYearly?: string; }; Encryption: { createEncryptedSession?: string; diff --git a/frontend/src/translations/zh-cn.ts b/frontend/src/translations/zh-cn.ts index b80e09be4..e175c9635 100644 --- a/frontend/src/translations/zh-cn.ts +++ b/frontend/src/translations/zh-cn.ts @@ -358,6 +358,8 @@ export default { users: undefined, unlimited_seats: undefined, month: undefined, + year: undefined, + wantToPayYearly: undefined, }, Encryption: { createEncryptedSession: undefined, diff --git a/frontend/src/translations/zh-tw.ts b/frontend/src/translations/zh-tw.ts index d3be83a31..5e95d2011 100644 --- a/frontend/src/translations/zh-tw.ts +++ b/frontend/src/translations/zh-tw.ts @@ -358,6 +358,8 @@ export default { users: undefined, unlimited_seats: undefined, month: undefined, + year: undefined, + wantToPayYearly: undefined, }, Encryption: { createEncryptedSession: undefined, diff --git a/frontend/src/views/subscribe/SubscribePage.tsx b/frontend/src/views/subscribe/SubscribePage.tsx index 6a05451ed..a806fc592 100644 --- a/frontend/src/views/subscribe/SubscribePage.tsx +++ b/frontend/src/views/subscribe/SubscribePage.tsx @@ -6,7 +6,7 @@ import { useCallback } from 'react'; import styled from '@emotion/styled'; import Step from './components/Step'; import Button from '@mui/material/Button'; -import { colors } from '@mui/material'; +import { colors, FormControlLabel, Switch } from '@mui/material'; import { Currency, FullUser, Plan } from 'common'; import CurrencyPicker from './components/CurrencyPicker'; import ProductPicker from './components/ProductPicker'; @@ -39,6 +39,7 @@ function SubscriberPage() { : 'team'; const [currency, setCurrency] = useState('eur'); const [plan, setPlan] = useState(defaultProduct); + const [yearly, setYearly] = useState(false); const product = useMemo(() => { if (!plan || !products) { return null; @@ -47,7 +48,8 @@ function SubscriberPage() { }, [plan, products]); const [domain, setDomain] = useState(DEFAULT_DOMAIN); const stripe = useStripe(); - const { SubscribePage: translations } = useTranslations(); + const { SubscribePage: translations, Products: productsTranslations } = + useTranslations(); const language = useLanguage(); const needDomain = product && product.plan === 'unlimited'; const needLogin = @@ -91,7 +93,8 @@ function SubscriberPage() { product.plan, currency, language.stripeLocale, - !product.seats ? domain : null + !product.seats ? domain : null, + yearly ); if (session && stripe) { @@ -101,22 +104,14 @@ function SubscriberPage() { } } } - }, [stripe, product, currency, domain, language]); + }, [stripe, product, currency, domain, language, yearly]); const validForm = (!needDomain || validDomain) && !!product && !needLogin; - return ( - -
Retrospected Pro
- {user && user.pro && !user.subscriptionsId ? ( - {translations.alertAlreadyPro} - ) : null} - {user && user.subscriptionsId && !user.trial ? ( - {translations.alertAlreadySubscribed} - ) : null} - + const steps = [ + (index: number) => ( @@ -124,26 +119,46 @@ function SubscriberPage() { value={plan} products={products} currency={currency} + yearly={yearly} onChange={setPlan} /> + + setYearly(evt.target.checked)} + name="yearly" + size="medium" + /> + } + label={`🎁 ${productsTranslations.wantToPayYearly!}`} + /> - {needDomain ? ( - - - - ) : null} + ), + needDomain + ? (index: number) => ( + + + + ) + : null, + (index: number) => ( @@ -158,9 +173,10 @@ function SubscriberPage() { onChange={setCurrency} /> - + ), + (index: number) => ( + ), + ].filter(Boolean) as Array<(index: number) => JSX.Element>; + + return ( + +
Retrospected Pro
+ {user && user.pro && !user.subscriptionsId ? ( + {translations.alertAlreadyPro} + ) : null} + {user && user.subscriptionsId && !user.trial ? ( + {translations.alertAlreadySubscribed} + ) : null} + + {steps.map((step, index) => { + return step(index + 1); + })}
); } diff --git a/frontend/src/views/subscribe/api.ts b/frontend/src/views/subscribe/api.ts index ff80512eb..43f37e2b4 100644 --- a/frontend/src/views/subscribe/api.ts +++ b/frontend/src/views/subscribe/api.ts @@ -14,13 +14,15 @@ export async function createCheckoutSession( plan: Plan, currency: Currency, locale: StripeLocales, - domain: string | null + domain: string | null, + yearly: boolean ): Promise { const payload: CreateSubscriptionPayload = { plan, currency, domain, locale, + yearly, }; return await fetchPostGet< CreateSubscriptionPayload, diff --git a/frontend/src/views/subscribe/components/Product.tsx b/frontend/src/views/subscribe/components/Product.tsx index 1515ab3d0..878a5eb5b 100644 --- a/frontend/src/views/subscribe/components/Product.tsx +++ b/frontend/src/views/subscribe/components/Product.tsx @@ -9,6 +9,7 @@ interface ProductDisplayProps { product: Product; currency: Currency; selected: boolean; + yearly: boolean; onSelect: (product: Product) => void; } @@ -16,14 +17,21 @@ function ProductDisplay({ product, selected, currency, + yearly, onSelect, }: ProductDisplayProps) { const { Products: translations, SubscribeModal: subscribeTranslations } = useTranslations(); + const handleOrder = useCallback(() => { onSelect(product); }, [onSelect, product]); + const price = + yearly && product.recurring + ? (product[currency] / 100) * 11 + : product[currency] / 100; + return ( @@ -36,9 +44,11 @@ function ProductDisplay({ - {(product[currency] / 100).toFixed(2)} {currency.toUpperCase()} + {price.toFixed(2)} {currency.toUpperCase()} {product.recurring ? ( - / {translations.month} + + / {yearly ? translations.year : translations.month} + ) : null} diff --git a/frontend/src/views/subscribe/components/ProductPicker.tsx b/frontend/src/views/subscribe/components/ProductPicker.tsx index 0977c33d0..1aebc2184 100644 --- a/frontend/src/views/subscribe/components/ProductPicker.tsx +++ b/frontend/src/views/subscribe/components/ProductPicker.tsx @@ -8,6 +8,7 @@ interface ProductPickerProps { value: Plan | null; currency: Currency; products: Product[]; + yearly: boolean; onChange: (value: Plan) => void; } @@ -15,6 +16,7 @@ function ProductPicker({ value, currency, products, + yearly, onChange, }: ProductPickerProps) { const handleChange = useCallback( @@ -30,6 +32,7 @@ function ProductPicker({ key={product.plan} product={product} currency={currency} + yearly={yearly} onSelect={handleChange} selected={value === product.plan} /> diff --git a/integration/package.json b/integration/package.json index caca860ad..7028acb10 100644 --- a/integration/package.json +++ b/integration/package.json @@ -1,6 +1,6 @@ { "name": "retro-board-integration", - "version": "4.12.1", + "version": "4.13.0", "description": "Integrations tests", "main": "index.js", "directories": { diff --git a/package.json b/package.json index 529a6a3ee..447895ab2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "retrospected", - "version": "4.12.1", + "version": "4.13.0", "description": "An agile retrospective board - Powering www.retrospected.com", "private": true, "scripts": {