Payload Logo
Insurance,  CX,  Service design

Once off VS in App subscription purchases

Author

james

Date Published

Estimate

Package:Per Night
Base Rate:R1600/night
Duration:1 night
Total:R1600.00

Using a proven S.A.A.S design pattern, you are able tocheck the subscription and provide the redirect where the user can update the booking object.

Read case study
Trust aggregators assigning permissions to their clients roles


inApp Purchase are not the same as single once off purchase as they require the appstore to handle traffic

frontend / subscribe


1'use client'
2
3import React, { useEffect, useState } from 'react'
4import { useUserContext } from '@/context/UserContext'
5import { useRevenueCat } from '@/providers/RevenueCat'
6import { useSubscription } from '@/hooks/useSubscription'
7import { Purchases, Package, PurchasesError, ErrorCode } from '@revenuecat/purchases-js'
8import { useRouter } from 'next/navigation'
9
10export default function SubscribePage() {
11 const router = useRouter()
12 const { currentUser } = useUserContext()
13 const { customerInfo, isInitialized } = useRevenueCat()
14 const { isSubscribed } = useSubscription()
15 const [offerings, setOfferings] = useState<Package[]>([])
16 const [loading, setLoading] = useState(true)
17 const [error, setError] = useState<string | null>(null)
18
19 useEffect(() => {
20 if (isInitialized) {
21 loadOfferings()
22 }
23 }, [isInitialized])
24
25 // Redirect to dashboard if already subscribed
26 useEffect(() => {
27 if (isSubscribed) {
28 router.push('/admin')
29 }
30 }, [isSubscribed, router])
31
32 const loadOfferings = async () => {
33 try {
34 const offerings = await Purchases.getSharedInstance().getOfferings()
35 if (offerings.current && offerings.current.availablePackages.length > 0) {
36 setOfferings(offerings.current.availablePackages)
37 }
38 setLoading(false)
39 } catch (err) {
40 setError('Failed to load subscription offerings')
41 setLoading(false)
42 console.error('Error loading offerings:', err)
43 }
44 }
45
46 const handlePurchase = async (pkg: Package) => {
47 try {
48 await Purchases.getSharedInstance().purchase({
49 rcPackage: pkg
50 });
51 router.push('/admin');
52 } catch (error) {
53 if (error.code === 'RECEIPT_ALREADY_IN_USE') {
54 router.push('/admin');
55 return;
56 }
57 if (error.code === 'CANCELLED') {
58 return;
59 }
60 console.error('Error purchasing package:', error);
61 setError('Failed to complete purchase. Please try again.');
62 }
63 };
64
65 if (!currentUser) {
66 return (
67 <div className="p-4">
68 <h1 className="text-2xl font-bold mb-4">Subscribe</h1>
69 <p>Please log in to view subscription options.</p>
70 </div>
71 )
72 }
73
74 if (!isInitialized || loading) {
75 return (
76 <div className="p-4">
77 <h1 className="text-2xl font-bold mb-4">Subscribe</h1>
78 <p>Loading subscription options...</p>
79 </div>
80 )
81 }
82
83 if (error) {
84 return (
85 <div className="p-4">
86 <h1 className="text-2xl font-bold mb-4">Subscribe</h1>
87 <p className="text-red-500">{error}</p>
88 </div>
89 )
90 }
91
92 const hasActiveSubscription = customerInfo && Object.keys(customerInfo.entitlements.active).length > 0
93
94 if (hasActiveSubscription) {
95 // Redirect to admin instead of showing subscription active message
96 router.push('/admin')
97 return null
98 }
99
100 return (
101 <div className="p-4">
102 <h1 className="text-2xl font-bold mb-4">Subscribe</h1>
103 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
104 {offerings.map((pkg) => {
105 const product = pkg.webBillingProduct
106 return (
107 <div key={pkg.identifier} className="border rounded-lg p-4">
108 <h2 className="text-xl font-semibold mb-2">{product.displayName}</h2>
109 <p className="mb-4">{product.description}</p>
110 <p className="text-lg font-bold mb-4">
111 {product.currentPrice.formattedPrice}
112 </p>
113 <button
114 onClick={() => handlePurchase(pkg)}
115 className="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 transition-colors"
116 >
117 Subscribe
118 </button>
119 </div>
120 )
121 })}
122 </div>
123 </div>
124 )
125}

Subscriptions hook

src/hooks/useSuscritption.ts

1'use client'
2
3import { useEffect, useState } from 'react'
4import { useRevenueCat } from '@/providers/RevenueCat'
5
6export type SubscriptionStatus = {
7 isSubscribed: boolean
8 entitlements: string[]
9 expirationDate: Date | null
10 isLoading: boolean
11 error: Error | null
12}
13
14export const useSubscription = (entitlementId?: string): SubscriptionStatus => {
15 const { customerInfo, isLoading, error } = useRevenueCat()
16 const [subscriptionStatus, setSubscriptionStatus] = useState<SubscriptionStatus>({
17 isSubscribed: false,
18 entitlements: [],
19 expirationDate: null,
20 isLoading: true,
21 error: null,
22 })
23
24 useEffect(() => {
25 if (isLoading || !customerInfo) {
26 setSubscriptionStatus(prev => ({ ...prev, isLoading }))
27 return
28 }
29
30 try {
31 // Extract entitlements from customer info
32 const entitlements = customerInfo.entitlements || {}
33 const activeEntitlements = Object.keys(entitlements).filter(
34 key => entitlements[key]?.isActive
35 )
36
37 // Check if the user has the specific entitlement or any entitlement
38 const isSubscribed = entitlementId
39 ? activeEntitlements.includes(entitlementId)
40 : activeEntitlements.length > 0
41
42 // Get expiration date of the entitlement
43 let expirationDate: Date | null = null
44 if (entitlementId && entitlements[entitlementId]?.expirationDate) {
45 expirationDate = new Date(entitlements[entitlementId].expirationDate)
46 } else if (activeEntitlements.length > 0 && entitlements[activeEntitlements[0]]?.expirationDate) {
47 expirationDate = new Date(entitlements[activeEntitlements[0]].expirationDate)
48 }
49
50 setSubscriptionStatus({
51 isSubscribed,
52 entitlements: activeEntitlements,
53 expirationDate,
54 isLoading: false,
55 error: null,
56 })
57 } catch (err) {
58 console.error('Error checking subscription status:', err)
59 setSubscriptionStatus(prev => ({
60 ...prev,
61 isLoading: false,
62 error: err instanceof Error ? err : new Error('Unknown error checking subscription status'),
63 }))
64 }
65 }, [customerInfo, isLoading, entitlementId, error])
66
67 return subscriptionStatus
68}

Provider

1import React from 'react'
2
3import { HeaderThemeProvider } from './HeaderTheme'
4import { ThemeProvider } from './Theme'
5import { RevenueCatProvider } from './RevenueCat'
6
7export const Providers: React.FC<{
8 children: React.ReactNode
9}> = ({ children }) => {
10 return (
11 <ThemeProvider>
12 <HeaderThemeProvider>
13 <RevenueCatProvider>
14 {children}
15 </RevenueCatProvider>
16 </HeaderThemeProvider>
17 </ThemeProvider>
18 )
19}
20

RevenueCat provider

1'use client'
2
3import { createContext, useContext, useEffect, useState } from 'react'
4import { useUserContext } from '@/context/UserContext'
5import { Purchases } from '@revenuecat/purchases-js'
6
7// Define types for RevenueCat
8type CustomerInfo = any
9
10type RevenueCatContextType = {
11 customerInfo: CustomerInfo | null
12 isLoading: boolean
13 isInitialized: boolean
14 error: Error | null
15 refreshCustomerInfo: () => Promise<CustomerInfo | void>
16 restorePurchases: () => Promise<CustomerInfo | void>
17}
18
19const RevenueCatContext = createContext<RevenueCatContextType | undefined>(undefined)
20
21export const RevenueCatProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
22 const { currentUser } = useUserContext()
23 const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null)
24 const [isLoading, setIsLoading] = useState<boolean>(true)
25 const [isInitialized, setIsInitialized] = useState<boolean>(false)
26 const [error, setError] = useState<Error | null>(null)
27
28 useEffect(() => {
29 // Only run in browser
30 if (typeof window === 'undefined') return
31
32 const initRevenueCat = async () => {
33 try {
34 setIsLoading(true)
35
36 if (!process.env.NEXT_PUBLIC_REVENUECAT_PUBLIC_SDK_KEY) {
37 throw new Error('RevenueCat public SDK key is not defined')
38 }
39
40 // Configure RevenueCat with user ID or anonymous ID
41 let userId: string
42 if (currentUser?.id) {
43 userId = String(currentUser.id)
44 } else {
45 // Generate an anonymous ID if no user is logged in
46 userId = Purchases.generateRevenueCatAnonymousAppUserId()
47 }
48
49 // Initialize RevenueCat with the public key and user ID
50 const purchases = Purchases.configure(
51 process.env.NEXT_PUBLIC_REVENUECAT_PUBLIC_SDK_KEY,
52 userId
53 )
54
55 setIsInitialized(true)
56
57 // Get customer info
58 const info = await purchases.getCustomerInfo()
59 setCustomerInfo(info)
60 setError(null)
61 } catch (err) {
62 console.error('Failed to initialize RevenueCat:', err)
63 setError(err instanceof Error ? err : new Error('Unknown error initializing RevenueCat'))
64 } finally {
65 setIsLoading(false)
66 }
67 }
68
69 initRevenueCat()
70 }, [currentUser?.id])
71
72 const refreshCustomerInfo = async () => {
73 if (typeof window === 'undefined') return
74
75 try {
76 setIsLoading(true)
77 const purchases = Purchases.getSharedInstance()
78 const info = await purchases.getCustomerInfo()
79 setCustomerInfo(info)
80 return info
81 } catch (err) {
82 console.error('Failed to refresh customer info:', err)
83 setError(err instanceof Error ? err : new Error('Unknown error refreshing customer info'))
84 } finally {
85 setIsLoading(false)
86 }
87 }
88
89 const restorePurchases = async () => {
90 if (typeof window === 'undefined') return
91
92 try {
93 setIsLoading(true)
94 const purchases = Purchases.getSharedInstance()
95 const info = await purchases.getCustomerInfo()
96 setCustomerInfo(info)
97 return info
98 } catch (err) {
99 console.error('Failed to restore purchases:', err)
100 setError(err instanceof Error ? err : new Error('Unknown error restoring purchases'))
101 } finally {
102 setIsLoading(false)
103 }
104 }
105
106 return (
107 <RevenueCatContext.Provider
108 value={{
109 customerInfo,
110 isLoading,
111 isInitialized,
112 error,
113 refreshCustomerInfo,
114 restorePurchases,
115 }}
116 >
117 {children}
118 </RevenueCatContext.Provider>
119 )
120}
121
122export const useRevenueCat = () => {
123 const context = useContext(RevenueCatContext)
124 if (context === undefined) {
125 throw new Error('useRevenueCat must be used within a RevenueCatProvider')
126 }
127 return context
128}

5 files 2 dependencies


Storing the cookie after subscription

The idea is the /admin is a protected route not visible to non subscribers

|_ 📁 src
| |_ 📁 app
| |_ 📁 blocks
| |_ 📁 collection
| |_ 📁 providers
| | |_ 📁 HeaderTheme
| | |_ 📁 RevenueCat
| | | | |_ 📄 index.tsx
| | |_ 📁 Theme
| | | |_ 📄 index.ts
|_ 📄 middleware.ts
|_ 📄 paylaod types.ts
|_ 📄 payload.config.ts

1import { NextRequest, NextResponse } from 'next/server'
2import { getServerSideURL } from './utilities/getURL'
3
4// Paths that require authentication
5const PROTECTED_PATHS = ['/admin']
6
7// Paths that are always allowed
8const PUBLIC_PATHS = ['/login', '/subscribe']
9
10export async function middleware(request: NextRequest) {
11 const { pathname } = request.nextUrl
12
13 // Allow public paths
14 if (PUBLIC_PATHS.includes(pathname)) {
15 return NextResponse.next()
16 }
17
18 // Check if path requires protection
19 const isProtectedPath = PROTECTED_PATHS.some((path) => pathname.startsWith(path))
20 if (!isProtectedPath) {
21 return NextResponse.next()
22 }
23
24 // Get auth cookie
25 const authCookie = request.cookies.get('payload-token')
26 if (!authCookie?.value) {
27 return NextResponse.redirect(new URL('/login', request.url))
28 }
29
30 // Check subscription status for admin routes
31 const subscriptionCookie = request.cookies.get('rc-subscription')
32 if (pathname.startsWith('/admin') && !subscriptionCookie?.value) {
33 return NextResponse.redirect(new URL('/subscribe', request.url))
34 }
35
36 return NextResponse.next()
37}
38
39export const config = {
40 matcher: [
41 /*
42 * Match all request paths except:
43 * - _next/static (static files)
44 * - _next/image (image optimization files)
45 * - favicon.ico (favicon file)
46 */
47 '/((?!_next/static|_next/image|favicon.ico).*)',
48 ],
49}
Health,  Figma prototype,  Components

Add a component to your design system. Use a hooks to join/relate User Agreement creating the ideal unchallenged User experience