feat: apple pay, google pay buttons and card tokenize

This commit is contained in:
Aidar Shaikhutdin @makeweb.space 2023-06-17 14:53:03 +03:00
parent 0cf8f2898d
commit 888b081393
17 changed files with 185 additions and 100 deletions

View File

@ -1,6 +1,7 @@
import routes from "../../routes"
import { AuthPayload } from "../types"
import { getAuthHeaders } from "../utils"
import { PaymentIntent } from '@chargebee/chargebee-js-types'
import { AuthPayload } from '../types'
import { getAuthHeaders } from '../utils'
import routes from '../../routes'
export interface Payload extends AuthPayload {
paymentMethod: PaymentMethod
@ -14,18 +15,7 @@ export interface Response {
export type PaymentMethod = 'apple_pay' | 'google_pay' | 'card'
export type CurrencyCode = 'USD'
export interface PaymentIntent {
id: string
status: 'inited' | 'in_progress' | 'authorized' | 'consumed' | 'expired'
amount: number
gateway_account_id: string
payment_method_type: PaymentMethod
expires_at: number
created_at: number
modified_at: number
currency_code: CurrencyCode
gateway: string
}
export interface Customer {
id: string
email: string

View File

@ -9,13 +9,13 @@ export interface GetPayload extends AuthPayload {
export interface ChargebeeReceiptPayload extends AuthPayload {
itemPriceId: string
gwToken: string
referenceId: string
referenceId?: string
}
export interface AppleReceiptPayload extends AuthPayload {
receiptData: string
autorenewable: boolean
sandbox: boolean
autorenewable?: boolean
sandbox?: boolean
}
export type Payload = ChargebeeReceiptPayload | AppleReceiptPayload
@ -48,7 +48,7 @@ export interface Response {
}
function createRequest({ token, itemPriceId, gwToken, referenceId }: ChargebeeReceiptPayload): Request
function createRequest({ token, receiptData, autorenewable, sandbox }: AppleReceiptPayload): Request
function createRequest({ token, receiptData, autorenewable = true, sandbox = true }: AppleReceiptPayload): Request
function createRequest(payload: Payload): Request
function createRequest(payload: Payload): Request {
const url = new URL(routes.server.subscriptionReceipts())

View File

@ -12,7 +12,7 @@ export interface ApiError {
}
export interface ErrorResponse {
errors: AuthError[] | ApiError
errors: AuthError[] | ApiError | string[]
}
export type AuthToken = string

View File

@ -3,13 +3,13 @@ import { useState, useEffect } from "react";
interface HookResult<T> {
isPending: boolean
error: Error | null
data: T
data: T | null
}
type ApiMethod<T> = () => Promise<T>
export function useApiCall<T>(apiMethod: ApiMethod<T>): HookResult<T> {
const [data, setData] = useState<T>({} as T)
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const [isPending, setIsPending] = useState<boolean>(true)

View File

@ -1,6 +1,13 @@
import { AuthToken } from "./types"
import { ErrorResponse } from "./types"
class ApiError extends Error {
constructor(message: string) {
super(message)
this.name = this.constructor.name
}
}
export function createMethod<P, R>(createRequest: (payload: P) => Request) {
return async (payload: P): Promise<R> => {
const request = createRequest(payload)
@ -9,9 +16,10 @@ export function createMethod<P, R>(createRequest: (payload: P) => Request) {
if (response.ok) return data
const error = Array.isArray(data.errors) ? data.errors[0]?.title : data.errors.base[0]
const error = Array.isArray(data.errors) ? data.errors[0] : data.errors.base[0]
const errorMessage = typeof error === 'object' ? error.title : error
throw new Error(error)
throw new ApiError(errorMessage)
}
}

View File

@ -1,3 +1,4 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { selectors } from '../../store'
@ -12,10 +13,9 @@ import {
} from './methods'
import UserHeader from '../UserHeader'
import Title from '../Title'
import Loader from '../Loader'
import secure from './secure.png'
import './styles.css'
import { useState } from 'react'
import Loader from '../Loader'
function PaymentPage(): JSX.Element {
const { t } = useTranslation()

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" baseProfile="tiny" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px" y="0px" viewBox="0 0 512 210.2" xml:space="preserve">
<path fill="#fff" id="XMLID_34_" d="M93.6,27.1C87.6,34.2,78,39.8,68.4,39c-1.2-9.6,3.5-19.8,9-26.1c6-7.3,16.5-12.5,25-12.9
C103.4,10,99.5,19.8,93.6,27.1 M102.3,40.9c-13.9-0.8-25.8,7.9-32.4,7.9c-6.7,0-16.8-7.5-27.8-7.3c-14.3,0.2-27.6,8.3-34.9,21.2
c-15,25.8-3.9,64,10.6,85c7.1,10.4,15.6,21.8,26.8,21.4c10.6-0.4,14.8-6.9,27.6-6.9c12.9,0,16.6,6.9,27.8,6.7
c11.6-0.2,18.9-10.4,26-20.8c8.1-11.8,11.4-23.3,11.6-23.9c-0.2-0.2-22.4-8.7-22.6-34.3c-0.2-21.4,17.5-31.6,18.3-32.2
C123.3,42.9,107.7,41.3,102.3,40.9 M182.6,11.9v155.9h24.2v-53.3h33.5c30.6,0,52.1-21,52.1-51.4c0-30.4-21.1-51.2-51.3-51.2H182.6z
M206.8,32.3h27.9c21,0,33,11.2,33,30.9c0,19.7-12,31-33.1,31h-27.8V32.3z M336.6,169c15.2,0,29.3-7.7,35.7-19.9h0.5v18.7h22.4V90.2
c0-22.5-18-37-45.7-37c-25.7,0-44.7,14.7-45.4,34.9h21.8c1.8-9.6,10.7-15.9,22.9-15.9c14.8,0,23.1,6.9,23.1,19.6v8.6l-30.2,1.8
c-28.1,1.7-43.3,13.2-43.3,33.2C298.4,155.6,314.1,169,336.6,169z M343.1,150.5c-12.9,0-21.1-6.2-21.1-15.7c0-9.8,7.9-15.5,23-16.4
l26.9-1.7v8.8C371.9,140.1,359.5,150.5,343.1,150.5z M425.1,210.2c23.6,0,34.7-9,44.4-36.3L512,54.7h-24.6l-28.5,92.1h-0.5
l-28.5-92.1h-25.3l41,113.5l-2.2,6.9c-3.7,11.7-9.7,16.2-20.4,16.2c-1.9,0-5.6-0.2-7.1-0.4v18.7C417.3,210,423.3,210.2,425.1,210.2z
"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,14 +1,55 @@
import { useCallback, useEffect, useId } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import MainButton from '../../../MainButton'
import { PaymentIntent } from '@chargebee/chargebee-js-types'
import { useApi, useApiCall } from '../../../../api'
import { usePayment, ApplePayButtonOptions } from '../../../../payment'
import { useAuth } from '../../../../auth'
import Loader from '../../../Loader'
import ErrorText from '../../../ErrorText'
import routes from '../../../../routes'
import ApplePay from './Apple-Pay.svg'
const currencyCode = 'USD'
const paymentMethod = 'apple_pay'
export function ApplePayButton(): JSX.Element {
const api = useApi()
const navigate = useNavigate()
const handleClick = () => navigate(routes.client.wallpaper())
return (
<MainButton onClick={handleClick}>
<img className='payment-btn' src={ApplePay} alt='Apple Pay' />
</MainButton>
const buttonId = useId()
const { i18n } = useTranslation()
const { token } = useAuth()
const { applePay } = usePayment()
const loadData = useCallback(() => {
return api.createPaymentIntent({ token, paymentMethod, currencyCode })
.then(({ payment_intent }) => payment_intent)
}, [api, token])
const { data, error, isPending } = useApiCall<PaymentIntent>(loadData)
if (error) console.error(error)
useEffect(() => {
if (data === null) return
const buttonOptions: ApplePayButtonOptions = {
buttonColor: 'black',
buttonType: 'pay',
locale: i18n.language
}
applePay?.setPaymentIntent(data)
applePay?.mountPaymentButton(`#${buttonId}`, buttonOptions)
.then(() => applePay?.handlePayment())
.then((paymentIntent) => {
console.log('Success payment by ApplePay', paymentIntent)
return api.createSubscriptionReceipt({
token, receiptData: paymentIntent.id, autorenewable: true, sandbox: true,
})
})
.then(() => navigate(routes.client.wallpaper()))
.catch(console.error)
}, [data, applePay, buttonId, navigate, i18n.language, api, token])
return isPending ? <Loader /> : (
<div id={buttonId} style={{ height: 60 }}>{
error ? <ErrorText message={error.message} isShown={true} size='large'/> : null
}</div>
)
}

View File

@ -1,10 +1,13 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState, ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import {
CardCVV, CardComponent, CardExpiry, CardNumber, Provider
} from '@chargebee/chargebee-js-react-wrapper'
import ChargebeeComponents from '@chargebee/chargebee-js-react-wrapper/dist/components/ComponentGroup'
import { usePayment } from '../../../../payment'
import { useApi } from '../../../../api'
import { useAuth } from '../../../../auth'
import Modal from '../../../Modal'
import Title from '../../../Title'
import MainButton from '../../../MainButton'
@ -15,6 +18,7 @@ import amex from './amex.svg'
import diners from './diners.svg'
import discover from './discover.svg'
import { cardStyles } from './styles'
import routes from '../../../../routes'
interface CardModalProps {
open: boolean
@ -31,6 +35,11 @@ interface Field {
type: string
}
interface ChargebeeTokenizeResult {
token: string
vaultToken: string
}
type Status = 'idle' | 'loading' | 'filling' | 'tokenizing' | 'ready' | 'success' | 'error'
const initCompletedFields = {
@ -43,10 +52,15 @@ type CompletedFields = typeof initCompletedFields
const isReady = (fields: CompletedFields) => Object.values(fields).every((complete: boolean) => complete)
const itemPriceId = 'aura-membership-2-week-USD'
export function CardModal({ open, onClose }: CardModalProps): JSX.Element {
const api = useApi()
const navigate = useNavigate()
const cardRef = useRef<ChargebeeComponents>(null)
const [status, setStatus] = useState<Status>('idle')
const [fields, setFields] = useState(initCompletedFields)
const { token } = useAuth()
const { t, i18n } = useTranslation()
const locale = i18n.language
const isInit = status === 'idle'
@ -65,7 +79,11 @@ export function CardModal({ open, onClose }: CardModalProps): JSX.Element {
const payWithCard = () => {
setStatus('tokenizing')
cardRef.current?.tokenize({})
.then(console.log)
.then((result: ChargebeeTokenizeResult) => {
return api.createSubscriptionReceipt({ token, itemPriceId, gwToken: result.token })
})
.then(() => console.log('Success payment by Card'))
.then(() => navigate(routes.client.wallpaper()))
.catch(console.error)
.finally(() => setStatus('success'))
}

View File

@ -1,14 +1,54 @@
import { useCallback, useEffect, useId } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { PaymentIntent } from '@chargebee/chargebee-js-types'
import { useApi, useApiCall } from '../../../../api'
import { usePayment, GooglePayButtonOptions } from '../../../../payment'
import { useAuth } from '../../../../auth'
import Loader from '../../../Loader'
import ErrorText from '../../../ErrorText'
import routes from '../../../../routes'
import MainButton from '../../../MainButton'
import GooglePay from './G-Pay.svg'
const currencyCode = 'USD'
const paymentMethod = 'google_pay'
export function GooglePayButton(): JSX.Element {
const api = useApi()
const navigate = useNavigate()
const handleClick = () => navigate(routes.client.wallpaper())
return (
<MainButton onClick={handleClick}>
<img className='payment-btn' src={GooglePay} alt='Google Pay' />
</MainButton>
const buttonId = useId()
const { i18n } = useTranslation()
const { token } = useAuth()
const { googlePay } = usePayment()
const loadData = useCallback(() => {
return api.createPaymentIntent({ token, paymentMethod, currencyCode })
.then(({ payment_intent }) => payment_intent)
}, [api, token])
const { data, error, isPending } = useApiCall<PaymentIntent>(loadData)
if (error) console.error(error)
useEffect(() => {
if (data === null) return
const buttonOptions: GooglePayButtonOptions = {
buttonColor: 'black',
buttonType: 'pay',
buttonLocale: i18n.language,
buttonSizeMode: 'fill',
}
googlePay?.setPaymentIntent(data)
googlePay?.mountPaymentButton(`#${buttonId}`, buttonOptions)
.then(() => googlePay?.handlePayment())
.then((result) => {
console.log('Success payment by GooglePay', result)
// TODO: implement api.createSubscriptionReceipt for GooglePay
})
.then(() => navigate(routes.client.wallpaper()))
.catch(console.error)
}, [data, googlePay, buttonId, navigate, i18n.language])
return isPending ? <Loader /> : (
<div id={buttonId} style={{ height: 60 }}>{
error ? <ErrorText message={error.message} isShown={true} size='large'/> : null
}</div>
)
}

View File

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 80 38.1" style="enable-background:new 0 0 80 38.1;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFF;}
.st1{fill:#4285F4;}
.st2{fill:#34A853;}
.st3{fill:#FBBC04;}
.st4{fill:#EA4335;}
</style>
<path class="st0" d="M37.8,19.7V29h-3V6h7.8c1.9,0,3.7,0.7,5.1,2c1.4,1.2,2.1,3,2.1,4.9c0,1.9-0.7,3.6-2.1,4.9c-1.4,1.3-3.1,2-5.1,2
L37.8,19.7L37.8,19.7z M37.8,8.8v8h5c1.1,0,2.2-0.4,2.9-1.2c1.6-1.5,1.6-4,0.1-5.5c0,0-0.1-0.1-0.1-0.1c-0.8-0.8-1.8-1.3-2.9-1.2
L37.8,8.8L37.8,8.8z"/>
<path class="st0" d="M56.7,12.8c2.2,0,3.9,0.6,5.2,1.8s1.9,2.8,1.9,4.8V29H61v-2.2h-0.1c-1.2,1.8-2.9,2.7-4.9,2.7
c-1.7,0-3.2-0.5-4.4-1.5c-1.1-1-1.8-2.4-1.8-3.9c0-1.6,0.6-2.9,1.8-3.9c1.2-1,2.9-1.4,4.9-1.4c1.8,0,3.2,0.3,4.3,1v-0.7
c0-1-0.4-2-1.2-2.6c-0.8-0.7-1.8-1.1-2.9-1.1c-1.7,0-3,0.7-3.9,2.1l-2.6-1.6C51.8,13.8,53.9,12.8,56.7,12.8z M52.9,24.2
c0,0.8,0.4,1.5,1,1.9c0.7,0.5,1.5,0.8,2.3,0.8c1.2,0,2.4-0.5,3.3-1.4c1-0.9,1.5-2,1.5-3.2c-0.9-0.7-2.2-1.1-3.9-1.1
c-1.2,0-2.2,0.3-3,0.9C53.3,22.6,52.9,23.3,52.9,24.2z"/>
<path class="st0" d="M80,13.3l-9.9,22.7h-3l3.7-7.9l-6.5-14.7h3.2l4.7,11.3h0.1l4.6-11.3H80z"/>
<path class="st1" d="M25.9,17.7c0-0.9-0.1-1.8-0.2-2.7H13.2v5.1h7.1c-0.3,1.6-1.2,3.1-2.6,4v3.3H22C24.5,25.1,25.9,21.7,25.9,17.7z"
/>
<path class="st2" d="M13.2,30.6c3.6,0,6.6-1.2,8.8-3.2l-4.3-3.3c-1.2,0.8-2.7,1.3-4.5,1.3c-3.4,0-6.4-2.3-7.4-5.5H1.4v3.4
C3.7,27.8,8.2,30.6,13.2,30.6z"/>
<path class="st3" d="M5.8,19.9c-0.6-1.6-0.6-3.4,0-5.1v-3.4H1.4c-1.9,3.7-1.9,8.1,0,11.9L5.8,19.9z"/>
<path class="st4" d="M13.2,9.4c1.9,0,3.7,0.7,5.1,2l0,0l3.8-3.8c-2.4-2.2-5.6-3.5-8.8-3.4c-5,0-9.6,2.8-11.8,7.3l4.4,3.4
C6.8,11.7,9.8,9.4,13.2,9.4z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -17,11 +17,12 @@ function StaticPage(): JSX.Element {
return api.getElement({ type, locale })
.then((resp: Element.Response) => resp.data.element)
}, [api, typeId, locale])
const { data, isPending, error } = useApiCall(loadData)
const { data, isPending, error } = useApiCall<Element.Element>(loadData)
const content = data ? parse(data.body) : null
return (
<section className='page page-static'>
{isPending ? <Loader /> : <div className='page-static__content'>{parse(data?.body)}</div>}
{isPending ? <Loader /> : <div className='page-static__content'>{content}</div>}
{error && <NotFoundPage />}
</section>
)

View File

@ -10,21 +10,22 @@ import UserHeader from '../UserHeader'
import CallToAction from '../CallToAction'
import routes from '../../routes'
const itemPriceId = 'aura-membership-2-week-USD'
const paymentItems = [
{
title: 'Per 7-Day Trial For',
price: 1.00,
description: '2-Week Plan',
},
]
function SubscriptionPage(): JSX.Element {
const { t } = useTranslation()
const navigate = useNavigate()
const email = useSelector(selectors.selectEmail)
const itemPriceId = 'aura-membership-2-week-USD'
const itemPrice = useSelector(selectors.selectPlanById(itemPriceId))
const currency = Currency.USD
const locale = Locale.EN
const paymentItems = [
{
title: 'Per 7-Day Trial For',
price: 1.00,
description: '2-Week Plan',
},
]
const handleClick = () => navigate(routes.client.paymentMethod())
console.log({ itemPrice })
return (

View File

@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { useApi, useApiCall, Assets, DailyForecasts } from '../../api'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../../auth'
import { useApi, useApiCall, Assets, DailyForecasts } from '../../api'
import { saveFile, buildFilename } from './utils'
import Loader, { LoaderColor } from '../Loader'
import './styles.css'
@ -14,6 +15,7 @@ interface WallpaperData {
function WallpaperPage(): JSX.Element {
const api = useApi()
const { t } = useTranslation()
const { user, token } = useAuth()
const category = user?.profile.sign?.sign || ''
const loadData = useCallback(() => {
@ -27,8 +29,8 @@ function WallpaperPage(): JSX.Element {
}))
}, [api, category, token])
const { data, isPending } = useApiCall<WallpaperData>(loadData)
const { assets, forecasts } = data
const asset = assets?.at(0)
const forecasts = data ? data.forecasts : []
const asset = data ? data.assets.at(0) : null
const handleClick = () => asset && saveFile(asset.url, buildFilename(category))
@ -42,7 +44,7 @@ function WallpaperPage(): JSX.Element {
<div className='wallpaper-content'>
{isPending ? null : (
<>
<h1 className='wallpaper-title'>Analysis of personal background</h1>
<h1 className='wallpaper-title'>{t('analysis_background')}</h1>
{forecasts.map((forecast) => (
<div key={forecast.category_name} className='wallpaper-forecast'>
<h2 className='wallpaper-subtitle'>{forecast.category}</h2>

View File

@ -43,5 +43,6 @@ export default {
will_be_charged: "You will be charged only <strongText>. We'll email your a reminder before your trial period ends.",
trial_price: "$1 for your 7-day trial",
start_trial: "Start 7-Day Trial",
analysis_background: "Analysis of personal background",
},
}

View File

@ -1,2 +1,3 @@
export * from './PaymentContext'
export * from './usePayment'
export * from './types'

View File

@ -1,18 +1,42 @@
import { PaymentIntent } from '@chargebee/chargebee-js-types'
interface Handler {
mountPaymentButton: (id: string) => Promise<void>
handlePayment: () => Promise<PaymentIntent>
setPaymentIntent: (paymentIntent: PaymentIntent) => void
}
export interface GooglePayButtonOptions {
buttonColor: 'default' | 'black' | 'white'
buttonType: 'long' | 'short' | 'book' | 'buy' | 'checkout' | 'donate' | 'order' | 'pay' | 'plain' | 'subscribe'
buttonSizeMode: 'static' | 'fill'
buttonLocale: string
}
export interface PaymentOptions {
requestPayerEmail: boolean
requestBillingAddress: boolean
requestShippingAddress: boolean
}
export interface ApplePayButtonOptions {
locale: string
buttonColor: 'black' | 'white' | 'white-outline'
buttonType: 'add-money' | 'book' | 'buy' | 'check-out' | 'continue' | 'contribute' | 'donate' | 'order' | 'pay' | 'plain' | 'reload' | 'rent' | 'set-up' | 'subscribe' | 'support' | 'tip' | 'top-up'
}
interface ApplePay extends Handler {
canMakePayments: () => boolean
mountPaymentButton: (selector: string, options?: ApplePayButtonOptions) => Promise<void>
}
interface GooglePay extends Handler {
getPaymentIntent: () => PaymentIntent
setPaymentIntent: (paymentIntent: PaymentIntent) => void
updatePaymentIntent: (paymentIntent: PaymentIntent) => void
mountPaymentButton: (
selector: string,
buttonOptions?: GooglePayButtonOptions,
paymentOptions?: PaymentOptions
) => Promise<void>
}
export type ApplePayHandler = ApplePay | null