Once off VS in App subscription purchases
Author
james
Date Published

Estimate
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'23import 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'910export 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)1819 useEffect(() => {20 if (isInitialized) {21 loadOfferings()22 }23 }, [isInitialized])2425 // Redirect to dashboard if already subscribed26 useEffect(() => {27 if (isSubscribed) {28 router.push('/admin')29 }30 }, [isSubscribed, router])3132 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 }4546 const handlePurchase = async (pkg: Package) => {47 try {48 await Purchases.getSharedInstance().purchase({49 rcPackage: pkg50 });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 };6465 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 }7374 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 }8283 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 }9192 const hasActiveSubscription = customerInfo && Object.keys(customerInfo.entitlements.active).length > 09394 if (hasActiveSubscription) {95 // Redirect to admin instead of showing subscription active message96 router.push('/admin')97 return null98 }99100 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.webBillingProduct106 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 <button114 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 Subscribe118 </button>119 </div>120 )121 })}122 </div>123 </div>124 )125}
Subscriptions hook
src/hooks/useSuscritption.ts
1'use client'23import { useEffect, useState } from 'react'4import { useRevenueCat } from '@/providers/RevenueCat'56export type SubscriptionStatus = {7 isSubscribed: boolean8 entitlements: string[]9 expirationDate: Date | null10 isLoading: boolean11 error: Error | null12}1314export 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 })2324 useEffect(() => {25 if (isLoading || !customerInfo) {26 setSubscriptionStatus(prev => ({ ...prev, isLoading }))27 return28 }2930 try {31 // Extract entitlements from customer info32 const entitlements = customerInfo.entitlements || {}33 const activeEntitlements = Object.keys(entitlements).filter(34 key => entitlements[key]?.isActive35 )3637 // Check if the user has the specific entitlement or any entitlement38 const isSubscribed = entitlementId39 ? activeEntitlements.includes(entitlementId)40 : activeEntitlements.length > 04142 // Get expiration date of the entitlement43 let expirationDate: Date | null = null44 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 }4950 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])6667 return subscriptionStatus68}
Provider
1import React from 'react'23import { HeaderThemeProvider } from './HeaderTheme'4import { ThemeProvider } from './Theme'5import { RevenueCatProvider } from './RevenueCat'67export const Providers: React.FC<{8 children: React.ReactNode9}> = ({ 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'23import { createContext, useContext, useEffect, useState } from 'react'4import { useUserContext } from '@/context/UserContext'5import { Purchases } from '@revenuecat/purchases-js'67// Define types for RevenueCat8type CustomerInfo = any910type RevenueCatContextType = {11 customerInfo: CustomerInfo | null12 isLoading: boolean13 isInitialized: boolean14 error: Error | null15 refreshCustomerInfo: () => Promise<CustomerInfo | void>16 restorePurchases: () => Promise<CustomerInfo | void>17}1819const RevenueCatContext = createContext<RevenueCatContextType | undefined>(undefined)2021export 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)2728 useEffect(() => {29 // Only run in browser30 if (typeof window === 'undefined') return3132 const initRevenueCat = async () => {33 try {34 setIsLoading(true)3536 if (!process.env.NEXT_PUBLIC_REVENUECAT_PUBLIC_SDK_KEY) {37 throw new Error('RevenueCat public SDK key is not defined')38 }3940 // Configure RevenueCat with user ID or anonymous ID41 let userId: string42 if (currentUser?.id) {43 userId = String(currentUser.id)44 } else {45 // Generate an anonymous ID if no user is logged in46 userId = Purchases.generateRevenueCatAnonymousAppUserId()47 }4849 // Initialize RevenueCat with the public key and user ID50 const purchases = Purchases.configure(51 process.env.NEXT_PUBLIC_REVENUECAT_PUBLIC_SDK_KEY,52 userId53 )5455 setIsInitialized(true)5657 // Get customer info58 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 }6869 initRevenueCat()70 }, [currentUser?.id])7172 const refreshCustomerInfo = async () => {73 if (typeof window === 'undefined') return7475 try {76 setIsLoading(true)77 const purchases = Purchases.getSharedInstance()78 const info = await purchases.getCustomerInfo()79 setCustomerInfo(info)80 return info81 } 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 }8889 const restorePurchases = async () => {90 if (typeof window === 'undefined') return9192 try {93 setIsLoading(true)94 const purchases = Purchases.getSharedInstance()95 const info = await purchases.getCustomerInfo()96 setCustomerInfo(info)97 return info98 } 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 }105106 return (107 <RevenueCatContext.Provider108 value={{109 customerInfo,110 isLoading,111 isInitialized,112 error,113 refreshCustomerInfo,114 restorePurchases,115 }}116 >117 {children}118 </RevenueCatContext.Provider>119 )120}121122export 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 context128}
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'34// Paths that require authentication5const PROTECTED_PATHS = ['/admin']67// Paths that are always allowed8const PUBLIC_PATHS = ['/login', '/subscribe']910export async function middleware(request: NextRequest) {11 const { pathname } = request.nextUrl1213 // Allow public paths14 if (PUBLIC_PATHS.includes(pathname)) {15 return NextResponse.next()16 }1718 // Check if path requires protection19 const isProtectedPath = PROTECTED_PATHS.some((path) => pathname.startsWith(path))20 if (!isProtectedPath) {21 return NextResponse.next()22 }2324 // Get auth cookie25 const authCookie = request.cookies.get('payload-token')26 if (!authCookie?.value) {27 return NextResponse.redirect(new URL('/login', request.url))28 }2930 // Check subscription status for admin routes31 const subscriptionCookie = request.cookies.get('rc-subscription')32 if (pathname.startsWith('/admin') && !subscriptionCookie?.value) {33 return NextResponse.redirect(new URL('/subscribe', request.url))34 }3536 return NextResponse.next()37}3839export 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}

Creating, reading and updating the bookings collection depending on the role of the user

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