mirror of
https://github.com/overleaf/overleaf.git
synced 2024-12-04 23:05:56 -05:00
357 lines
9.5 KiB
TypeScript
357 lines
9.5 KiB
TypeScript
|
import {
|
||
|
useState,
|
||
|
useEffect,
|
||
|
useLayoutEffect,
|
||
|
useMemo,
|
||
|
useCallback,
|
||
|
useRef,
|
||
|
useContext,
|
||
|
createContext,
|
||
|
} from 'react'
|
||
|
import { useTranslation } from 'react-i18next'
|
||
|
import getMeta from '../../../utils/meta'
|
||
|
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||
|
import {
|
||
|
PaymentContextValue,
|
||
|
PricingFormState,
|
||
|
} from './types/payment-context-value'
|
||
|
import { Plan } from '../../../../../types/subscription/plan'
|
||
|
import {
|
||
|
RecurlyOptions,
|
||
|
SubscriptionPricingStateTax,
|
||
|
} from 'recurly__recurly-js'
|
||
|
import { SubscriptionPricingInstanceCustom } from '../../../../../types/recurly/pricing/subscription'
|
||
|
|
||
|
function usePayment({ publicKey }: RecurlyOptions) {
|
||
|
const { t } = useTranslation()
|
||
|
const plan: Plan = getMeta('ol-plan')
|
||
|
const initialCountry: PricingFormState['country'] = getMeta(
|
||
|
'ol-countryCode',
|
||
|
''
|
||
|
)
|
||
|
const initialCouponCode: PricingFormState['coupon'] = getMeta(
|
||
|
'ol-couponCode',
|
||
|
''
|
||
|
)
|
||
|
const currencySymbols: Record<
|
||
|
PaymentContextValue['currencyCode'],
|
||
|
PaymentContextValue['currencySymbol']
|
||
|
> = getMeta('ol-currencySymbols')
|
||
|
const initiallySelectedCurrencyCode: string = getMeta(
|
||
|
'ol-recommendedCurrency'
|
||
|
)
|
||
|
|
||
|
const [recurlyLoading, setRecurlyLoading] = useState(true)
|
||
|
const [recurlyLoadError, setRecurlyLoadError] = useState(false)
|
||
|
const [recurlyPrice, setRecurlyPrice] = useState<{
|
||
|
subtotal: string
|
||
|
plan: string
|
||
|
addons: string
|
||
|
setup_fee: string
|
||
|
discount: string
|
||
|
tax: string
|
||
|
total: string
|
||
|
}>()
|
||
|
const [monthlyBilling, setMonthlyBilling] = useState<boolean>()
|
||
|
const [taxes, setTaxes] = useState<SubscriptionPricingStateTax[]>([])
|
||
|
const [coupon, setCoupon] = useState<{
|
||
|
discountMonths?: number
|
||
|
discountRate?: number
|
||
|
singleUse: boolean
|
||
|
normalPrice: number
|
||
|
name: string
|
||
|
normalPriceWithoutTax: number
|
||
|
}>()
|
||
|
const [couponError, setCouponError] = useState('')
|
||
|
const [trialLength, setTrialLength] = useState<number>()
|
||
|
const [currencyCode, setCurrencyCode] = useState(
|
||
|
initiallySelectedCurrencyCode
|
||
|
)
|
||
|
const [pricingFormState, setPricingFormState] = useState<PricingFormState>({
|
||
|
first_name: '',
|
||
|
last_name: '',
|
||
|
postal_code: '',
|
||
|
address1: '',
|
||
|
address2: '',
|
||
|
state: '',
|
||
|
city: '',
|
||
|
company: '',
|
||
|
vat_number: '',
|
||
|
country: initialCountry,
|
||
|
coupon: initialCouponCode,
|
||
|
})
|
||
|
const pricing = useRef<SubscriptionPricingInstanceCustom>()
|
||
|
|
||
|
const limitedCurrencyCodes = Array.from(
|
||
|
new Set([initiallySelectedCurrencyCode, 'USD', 'EUR', 'GBP'])
|
||
|
)
|
||
|
const limitedCurrencies = limitedCurrencyCodes.reduce((prev, cur) => {
|
||
|
return { ...prev, [cur]: currencySymbols[cur] }
|
||
|
}, {} as Record<string, string>)
|
||
|
const currencySymbol = limitedCurrencies[currencyCode]
|
||
|
|
||
|
useLayoutEffect(() => {
|
||
|
if (typeof recurly === 'undefined' || !recurly) {
|
||
|
setRecurlyLoadError(true)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
eventTracking.sendMB('payment-page-view', { plan: plan.planCode })
|
||
|
eventTracking.send(
|
||
|
'subscription-funnel',
|
||
|
'subscription-form-viewed',
|
||
|
plan.planCode
|
||
|
)
|
||
|
|
||
|
recurly.configure({ publicKey })
|
||
|
pricing.current =
|
||
|
recurly.Pricing.Subscription() as SubscriptionPricingInstanceCustom
|
||
|
|
||
|
const setupPricing = () => {
|
||
|
setRecurlyLoading(true)
|
||
|
|
||
|
pricing.current
|
||
|
?.plan(plan.planCode, { quantity: 1 })
|
||
|
.address({
|
||
|
first_name: '',
|
||
|
last_name: '',
|
||
|
country: initialCountry,
|
||
|
})
|
||
|
.tax({ tax_code: 'digital', vat_number: '' })
|
||
|
.currency(initiallySelectedCurrencyCode)
|
||
|
.coupon(initialCouponCode)
|
||
|
.catch(function (err) {
|
||
|
if (
|
||
|
initiallySelectedCurrencyCode !== 'USD' &&
|
||
|
err.name === 'invalid-currency'
|
||
|
) {
|
||
|
setCurrencyCode('USD')
|
||
|
setupPricing()
|
||
|
} else if (err.name === 'api-error' && err.code === 'not-found') {
|
||
|
// not-found here should refer to the coupon code, plan_code should be valid
|
||
|
setCouponError(t('coupon_code_is_not_valid_for_selected_plan'))
|
||
|
} else {
|
||
|
// Bail out on other errors, form state will not be correct
|
||
|
setRecurlyLoadError(true)
|
||
|
throw err
|
||
|
}
|
||
|
})
|
||
|
.done(() => {
|
||
|
setRecurlyLoading(false)
|
||
|
})
|
||
|
}
|
||
|
|
||
|
setupPricing()
|
||
|
}, [
|
||
|
initialCountry,
|
||
|
initialCouponCode,
|
||
|
initiallySelectedCurrencyCode,
|
||
|
plan.planCode,
|
||
|
publicKey,
|
||
|
t,
|
||
|
])
|
||
|
|
||
|
useEffect(() => {
|
||
|
pricing.current?.on('change', function () {
|
||
|
if (!pricing.current) return
|
||
|
|
||
|
const trialLength = pricing.current.items.plan?.trial?.length
|
||
|
setTrialLength(trialLength)
|
||
|
|
||
|
const recurlyPrice = trialLength
|
||
|
? pricing.current.price.next
|
||
|
: pricing.current.price.now
|
||
|
setRecurlyPrice(recurlyPrice)
|
||
|
|
||
|
const monthlyBilling = pricing.current.items.plan?.period.length === 1
|
||
|
setMonthlyBilling(monthlyBilling)
|
||
|
|
||
|
setTaxes(pricing.current.price.taxes)
|
||
|
|
||
|
// TODO availableCurrencies for preview section (limitedCurrencies is implemented)
|
||
|
// for (const currencyCode in pricing.items.plan.price) {
|
||
|
// if (MultiCurrencyPricing.plans[currencyCode]) {
|
||
|
// $scope.availableCurrencies[currencyCode] =
|
||
|
// MultiCurrencyPricing.plans[currencyCode]
|
||
|
// }
|
||
|
// }
|
||
|
|
||
|
const couponData = (() => {
|
||
|
if (pricing.current.items.coupon?.discount.type === 'percent') {
|
||
|
const coupon = pricing.current.items.coupon
|
||
|
const basePrice = parseInt(pricing.current.price.base.plan.unit, 10)
|
||
|
const discountData =
|
||
|
coupon.applies_for_months > 0 && coupon.discount.rate
|
||
|
? {
|
||
|
discountMonths: coupon.applies_for_months,
|
||
|
discountRate: coupon.discount.rate * 100,
|
||
|
}
|
||
|
: {}
|
||
|
|
||
|
const couponData = {
|
||
|
singleUse: coupon.single_use,
|
||
|
normalPrice: basePrice,
|
||
|
name: coupon.name,
|
||
|
normalPriceWithoutTax: basePrice,
|
||
|
...discountData,
|
||
|
}
|
||
|
|
||
|
if (pricing.current.price.taxes[0]?.rate) {
|
||
|
couponData.normalPrice +=
|
||
|
basePrice * parseFloat(pricing.current.price.taxes[0].rate)
|
||
|
}
|
||
|
|
||
|
return couponData
|
||
|
}
|
||
|
})()
|
||
|
setCoupon(couponData)
|
||
|
})
|
||
|
}, [])
|
||
|
|
||
|
const addCoupon = useCallback(
|
||
|
(coupon: PricingFormState['coupon']) => {
|
||
|
setRecurlyLoading(true)
|
||
|
setCouponError('')
|
||
|
|
||
|
pricing.current
|
||
|
?.coupon(coupon)
|
||
|
.catch(function (err) {
|
||
|
if (err.name === 'api-error' && err.code === 'not-found') {
|
||
|
setCouponError(t('coupon_code_is_not_valid_for_selected_plan'))
|
||
|
} else {
|
||
|
setCouponError(
|
||
|
t('an_error_occurred_when_verifying_the_coupon_code')
|
||
|
)
|
||
|
throw err
|
||
|
}
|
||
|
})
|
||
|
.done(() => {
|
||
|
setRecurlyLoading(false)
|
||
|
})
|
||
|
},
|
||
|
[t]
|
||
|
)
|
||
|
|
||
|
const updateCountry = useCallback(
|
||
|
(country: PricingFormState['country']) => {
|
||
|
setRecurlyLoading(true)
|
||
|
|
||
|
pricing.current
|
||
|
?.address({
|
||
|
country,
|
||
|
first_name: pricingFormState.first_name,
|
||
|
last_name: pricingFormState.last_name,
|
||
|
})
|
||
|
.done(() => {
|
||
|
setRecurlyLoading(false)
|
||
|
})
|
||
|
},
|
||
|
[pricingFormState.first_name, pricingFormState.last_name]
|
||
|
)
|
||
|
|
||
|
const applyVatNumber = useCallback(
|
||
|
(vatNumber: PricingFormState['vat_number']) => {
|
||
|
setRecurlyLoading(true)
|
||
|
|
||
|
pricing.current
|
||
|
?.tax({ tax_code: 'digital', vat_number: vatNumber })
|
||
|
.done(() => {
|
||
|
setRecurlyLoading(false)
|
||
|
})
|
||
|
},
|
||
|
[]
|
||
|
)
|
||
|
|
||
|
// TODO - for preview panel
|
||
|
const changeCurrency = useCallback(
|
||
|
(newCurrency: string) => {
|
||
|
setRecurlyLoading(true)
|
||
|
setCurrencyCode(newCurrency)
|
||
|
|
||
|
pricing.current
|
||
|
?.currency(newCurrency)
|
||
|
.catch(function (err) {
|
||
|
if (currencyCode !== 'USD' && err.name === 'invalid-currency') {
|
||
|
setCurrencyCode('USD')
|
||
|
} else {
|
||
|
throw err
|
||
|
}
|
||
|
})
|
||
|
.done(() => {
|
||
|
setRecurlyLoading(false)
|
||
|
})
|
||
|
},
|
||
|
[currencyCode]
|
||
|
)
|
||
|
|
||
|
const value = useMemo<PaymentContextValue>(
|
||
|
() => ({
|
||
|
currencyCode,
|
||
|
setCurrencyCode,
|
||
|
currencySymbol,
|
||
|
limitedCurrencies,
|
||
|
pricingFormState,
|
||
|
setPricingFormState,
|
||
|
plan,
|
||
|
pricing,
|
||
|
recurlyLoading,
|
||
|
recurlyLoadError,
|
||
|
recurlyPrice,
|
||
|
monthlyBilling,
|
||
|
taxes,
|
||
|
coupon,
|
||
|
couponError,
|
||
|
trialLength,
|
||
|
addCoupon,
|
||
|
applyVatNumber,
|
||
|
changeCurrency,
|
||
|
updateCountry,
|
||
|
}),
|
||
|
[
|
||
|
currencyCode,
|
||
|
setCurrencyCode,
|
||
|
currencySymbol,
|
||
|
limitedCurrencies,
|
||
|
pricingFormState,
|
||
|
setPricingFormState,
|
||
|
plan,
|
||
|
pricing,
|
||
|
recurlyLoading,
|
||
|
recurlyLoadError,
|
||
|
recurlyPrice,
|
||
|
monthlyBilling,
|
||
|
taxes,
|
||
|
coupon,
|
||
|
couponError,
|
||
|
trialLength,
|
||
|
addCoupon,
|
||
|
applyVatNumber,
|
||
|
changeCurrency,
|
||
|
updateCountry,
|
||
|
]
|
||
|
)
|
||
|
|
||
|
return { value }
|
||
|
}
|
||
|
|
||
|
const PaymentContext = createContext<PaymentContextValue | undefined>(undefined)
|
||
|
|
||
|
type PaymentProviderProps = {
|
||
|
publicKey: string
|
||
|
children: React.ReactNode
|
||
|
}
|
||
|
|
||
|
export function PaymentProvider({ publicKey, ...props }: PaymentProviderProps) {
|
||
|
const { value } = usePayment({ publicKey })
|
||
|
|
||
|
return <PaymentContext.Provider value={value} {...props} />
|
||
|
}
|
||
|
|
||
|
export function usePaymentContext() {
|
||
|
const context = useContext(PaymentContext)
|
||
|
if (!context) {
|
||
|
throw new Error('PaymentContext is only available inside PaymentProvider')
|
||
|
}
|
||
|
return context
|
||
|
}
|