515 lines
17 KiB
JavaScript
515 lines
17 KiB
JavaScript
import React from 'react'
|
|
import { Linking, Platform, StyleSheet, View } from 'react-native'
|
|
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js'
|
|
import { loadStripe } from '@stripe/stripe-js'
|
|
import * as WebBrowser from 'expo-web-browser'
|
|
import Overlay from '../components/Overlay'
|
|
import Button from '../components/Button'
|
|
import { Palette } from '../styles'
|
|
import { mainBorderRadius } from '../styles/Style'
|
|
import { STRIPE_PUBLISHABLE_KEY_LIVE, STRIPE_PUBLISHABLE_KEY_TEST } from '../data/keys'
|
|
import { getFunctionsClient } from '../config/firebase'
|
|
import { isWeb } from '../hooks/useLayoutType'
|
|
import { useUserData } from './UserDataProvider'
|
|
import { formatDate, toDate } from '../utils/dateFormatting'
|
|
|
|
const FUNCTIONS_REGION = 'europe-west1'
|
|
const STRIPE_SUCCESS_URL =
|
|
'https://dashboard.stripe.com/test/billing/starter-guide/checkout-success'
|
|
const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL
|
|
|
|
const isStripeTesting = true
|
|
const rawStripePublishableKey = isStripeTesting
|
|
? STRIPE_PUBLISHABLE_KEY_TEST
|
|
: STRIPE_PUBLISHABLE_KEY_LIVE
|
|
const stripePublishableKey =
|
|
typeof rawStripePublishableKey === 'string' ? rawStripePublishableKey.trim() : ''
|
|
const stripePromise = stripePublishableKey ? loadStripe(stripePublishableKey) : null
|
|
|
|
if (!stripePublishableKey) {
|
|
console.warn(
|
|
'[StripeProvider] Aucune clé publique Stripe fournie, le checkout intégré sera désactivé.'
|
|
)
|
|
}
|
|
|
|
const isSafariBrowser = () => {
|
|
if (typeof navigator !== 'object' || typeof navigator.userAgent !== 'string') {
|
|
return false
|
|
}
|
|
return (
|
|
/safari/i.test(navigator.userAgent) &&
|
|
!/(chrome|crios|android|fxios|edgios|opr|ucbrowser)/i.test(navigator.userAgent)
|
|
)
|
|
}
|
|
|
|
const openSafariCheckoutWindow = () => {
|
|
if (!isWeb || typeof window === 'undefined') {
|
|
return null
|
|
}
|
|
if (!isSafariBrowser()) {
|
|
return null
|
|
}
|
|
try {
|
|
const placeholder = window.open('', '_blank', 'noopener,noreferrer')
|
|
if (!placeholder) {
|
|
return null
|
|
}
|
|
try {
|
|
placeholder.document.write(
|
|
"<p style='font-family: sans-serif; color: #111; padding: 16px;'>Chargement du paiement Stripe...</p>"
|
|
)
|
|
placeholder.document.title = 'Stripe Checkout'
|
|
} catch {
|
|
// Ignore failures when the window gets reused or is cross-domain.
|
|
}
|
|
placeholder.focus?.()
|
|
return placeholder
|
|
} catch (error) {
|
|
console.warn('[StripeProvider] Safari window pre-open failed', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export const StripeContext = React.createContext(null)
|
|
|
|
export const useStripe = () => {
|
|
const context = React.useContext(StripeContext)
|
|
if (!context) {
|
|
throw new Error('useStripe must be used within a StripeProvider')
|
|
}
|
|
return context
|
|
}
|
|
|
|
const StripeProvider = ({ children }) => {
|
|
const { currentUserData } = useUserData() || {}
|
|
const canUseEmbeddedCheckout = isWeb && Boolean(stripePromise)
|
|
|
|
const [clientSecret, setClientSecret] = React.useState(null)
|
|
const [subscriptions, setSubscriptions] = React.useState({
|
|
monthly: [],
|
|
annual: [],
|
|
})
|
|
const [coinPacks, setCoinPacks] = React.useState([])
|
|
const [isCatalogLoading, setIsCatalogLoading] = React.useState(true)
|
|
const [catalogError, setCatalogError] = React.useState(null)
|
|
const [activeSubscription, setActiveSubscription] = React.useState(null)
|
|
const [isActiveSubscriptionLoading, setIsActiveSubscriptionLoading] = React.useState(false)
|
|
const [activeSubscriptionError, setActiveSubscriptionError] = React.useState(null)
|
|
|
|
const stripeCustomerId = currentUserData?.stripeCustomerId || null
|
|
const localSubscriptionId =
|
|
currentUserData?.stripeSubscription?.id ||
|
|
currentUserData?.stripeSubscription?.subscriptionId ||
|
|
null
|
|
|
|
const closeEmbeddedCheckout = React.useCallback(() => {
|
|
setClientSecret(null)
|
|
}, [])
|
|
|
|
const redirectToCheckout = React.useCallback(async (checkoutUrl = {}) => {
|
|
if (!checkoutUrl) {
|
|
throw new Error('Session Stripe introuvable.')
|
|
}
|
|
|
|
if (isWeb) {
|
|
throw new Error("Impossible d'ouvrir le paiement Stripe sans checkout intégré.")
|
|
}
|
|
|
|
const isMobileApp = Platform.OS === 'ios' || Platform.OS === 'android'
|
|
|
|
if (isMobileApp) {
|
|
try {
|
|
await WebBrowser.openBrowserAsync(checkoutUrl, {
|
|
enableDefaultShareMenu: false,
|
|
dismissButtonStyle: 'close',
|
|
presentationStyle: WebBrowser?.WebBrowserPresentationStyle?.PAGE_SHEET,
|
|
})
|
|
return
|
|
} catch (webBrowserError) {
|
|
console.warn('[StripeProvider] WebBrowser checkout fallback', webBrowserError)
|
|
}
|
|
}
|
|
|
|
const canOpen = await Linking.canOpenURL(checkoutUrl)
|
|
if (!canOpen) {
|
|
throw new Error("Impossible d'ouvrir l'URL de paiement.")
|
|
}
|
|
await Linking.openURL(checkoutUrl)
|
|
}, [])
|
|
|
|
const runCheckoutSession = React.useCallback(
|
|
async ({ callableName, payload, logTag, useEmbeddedFlow = false }) => {
|
|
if (useEmbeddedFlow && !canUseEmbeddedCheckout) {
|
|
throw new Error('Le paiement intégré Stripe est indisponible sur cette plateforme.')
|
|
}
|
|
|
|
const wantsEmbeddedCheckout = useEmbeddedFlow && canUseEmbeddedCheckout
|
|
const safariWindow = wantsEmbeddedCheckout ? null : openSafariCheckoutWindow()
|
|
try {
|
|
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(callableName)
|
|
const { data } = await callable(payload)
|
|
const checkoutUrl = data?.url
|
|
const clientSecret = data?.client_secret || data?.clientSecret
|
|
|
|
if (wantsEmbeddedCheckout) {
|
|
if (!clientSecret) {
|
|
throw new Error('Session Stripe introuvable.')
|
|
}
|
|
setClientSecret(clientSecret)
|
|
return
|
|
}
|
|
|
|
if (!checkoutUrl) {
|
|
throw new Error('Session Stripe introuvable.')
|
|
}
|
|
|
|
console.log('[StripeProvider] Stripe checkout URL', checkoutUrl)
|
|
await redirectToCheckout(checkoutUrl, { safariWindow })
|
|
} catch (error) {
|
|
if (safariWindow && !safariWindow.closed) {
|
|
safariWindow.close()
|
|
}
|
|
console.error(`[StripeProvider] ${logTag}`, error)
|
|
throw new Error(
|
|
error?.message || 'Une erreur est survenue lors de la création de la session Stripe.'
|
|
)
|
|
}
|
|
},
|
|
[canUseEmbeddedCheckout, redirectToCheckout, setClientSecret]
|
|
)
|
|
|
|
const createSubscriptionCheckout = React.useCallback(
|
|
async (priceId) => {
|
|
if (!priceId) {
|
|
throw new Error('Aucun abonnement sélectionné.')
|
|
}
|
|
if (isWeb && !canUseEmbeddedCheckout) {
|
|
console.error('[StripeProvider] checkout blocked (missing embedded support)', {
|
|
hasStripeKey: Boolean(stripePublishableKey),
|
|
isClientSecretReady: false,
|
|
})
|
|
throw new Error(
|
|
'Le paiement intégré Stripe est indisponible pour le moment (clé Stripe ou client secret absent).'
|
|
)
|
|
}
|
|
const shouldUseEmbeddedCheckout = canUseEmbeddedCheckout
|
|
await runCheckoutSession({
|
|
callableName: 'subscription-createSubscriptionCheckoutSession',
|
|
payload: {
|
|
priceId,
|
|
returnUrls: {
|
|
successUrl: STRIPE_SUCCESS_URL,
|
|
cancelUrl: STRIPE_CANCEL_URL,
|
|
},
|
|
uiMode: shouldUseEmbeddedCheckout ? 'embedded' : 'hosted',
|
|
},
|
|
logTag: 'subscription checkout error',
|
|
useEmbeddedFlow: shouldUseEmbeddedCheckout,
|
|
})
|
|
},
|
|
[canUseEmbeddedCheckout, runCheckoutSession]
|
|
)
|
|
|
|
const createSongDownloadCheckout = React.useCallback(
|
|
async (projectId) => {
|
|
if (!projectId) {
|
|
throw new Error('Aucun projet sélectionné.')
|
|
}
|
|
if (isWeb && !canUseEmbeddedCheckout) {
|
|
console.error('[StripeProvider] checkout blocked (missing embedded support)', {
|
|
hasStripeKey: Boolean(stripePublishableKey),
|
|
isClientSecretReady: false,
|
|
})
|
|
throw new Error(
|
|
'Le paiement intégré Stripe est indisponible pour le moment (clé Stripe ou client secret absent).'
|
|
)
|
|
}
|
|
const shouldUseEmbeddedCheckout = canUseEmbeddedCheckout
|
|
await runCheckoutSession({
|
|
callableName: 'subscription-createSongDownloadCheckoutSession',
|
|
payload: {
|
|
projectId,
|
|
returnUrls: {
|
|
successUrl: STRIPE_SUCCESS_URL,
|
|
cancelUrl: STRIPE_CANCEL_URL,
|
|
},
|
|
uiMode: shouldUseEmbeddedCheckout ? 'embedded' : 'hosted',
|
|
},
|
|
logTag: 'song download checkout error',
|
|
useEmbeddedFlow: shouldUseEmbeddedCheckout,
|
|
})
|
|
},
|
|
[canUseEmbeddedCheckout, runCheckoutSession]
|
|
)
|
|
|
|
const createPlaybackDownloadCheckout = React.useCallback(
|
|
async (projectId) => {
|
|
if (!projectId) {
|
|
throw new Error('Aucun projet sélectionné.')
|
|
}
|
|
if (isWeb && !canUseEmbeddedCheckout) {
|
|
console.error('[StripeProvider] checkout blocked (missing embedded support)', {
|
|
hasStripeKey: Boolean(stripePublishableKey),
|
|
isClientSecretReady: false,
|
|
})
|
|
throw new Error(
|
|
'Le paiement intégré Stripe est indisponible pour le moment (clé Stripe ou client secret absent).'
|
|
)
|
|
}
|
|
const shouldUseEmbeddedCheckout = canUseEmbeddedCheckout
|
|
await runCheckoutSession({
|
|
callableName: 'subscription-createPlaybackDownloadCheckoutSession',
|
|
payload: {
|
|
projectId,
|
|
returnUrls: {
|
|
successUrl: STRIPE_SUCCESS_URL,
|
|
cancelUrl: STRIPE_CANCEL_URL,
|
|
},
|
|
uiMode: shouldUseEmbeddedCheckout ? 'embedded' : 'hosted',
|
|
},
|
|
logTag: 'playback download checkout error',
|
|
useEmbeddedFlow: shouldUseEmbeddedCheckout,
|
|
})
|
|
},
|
|
[canUseEmbeddedCheckout, runCheckoutSession]
|
|
)
|
|
|
|
const createCoinPackCheckout = React.useCallback(
|
|
async (productId) => {
|
|
if (!productId) {
|
|
throw new Error('Aucun pack sélectionné.')
|
|
}
|
|
if (isWeb && !canUseEmbeddedCheckout) {
|
|
console.error('[StripeProvider] checkout blocked (missing embedded support)', {
|
|
hasStripeKey: Boolean(stripePublishableKey),
|
|
isClientSecretReady: false,
|
|
})
|
|
throw new Error(
|
|
'Le paiement intégré Stripe est indisponible pour le moment (clé Stripe ou client secret absent).'
|
|
)
|
|
}
|
|
const shouldUseEmbeddedCheckout = canUseEmbeddedCheckout
|
|
await runCheckoutSession({
|
|
callableName: 'subscription-createCoinPackCheckoutSession',
|
|
payload: {
|
|
productId,
|
|
returnUrls: {
|
|
successUrl: STRIPE_SUCCESS_URL,
|
|
cancelUrl: STRIPE_CANCEL_URL,
|
|
},
|
|
uiMode: shouldUseEmbeddedCheckout ? 'embedded' : 'hosted',
|
|
},
|
|
logTag: 'coin pack checkout error',
|
|
useEmbeddedFlow: shouldUseEmbeddedCheckout,
|
|
})
|
|
},
|
|
[canUseEmbeddedCheckout, runCheckoutSession]
|
|
)
|
|
|
|
const fetchActiveSubscription = React.useCallback(async () => {
|
|
const hasLookupContext = Boolean(stripeCustomerId) || Boolean(localSubscriptionId)
|
|
if (!hasLookupContext) {
|
|
setActiveSubscription(null)
|
|
setActiveSubscriptionError(null)
|
|
setIsActiveSubscriptionLoading(false)
|
|
return
|
|
}
|
|
|
|
setIsActiveSubscriptionLoading(true)
|
|
setActiveSubscriptionError(null)
|
|
try {
|
|
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
|
'subscription-getActiveSubscription'
|
|
)
|
|
const { data } = await callable()
|
|
setActiveSubscription(data?.subscription || null)
|
|
} catch (error) {
|
|
console.error('[StripeProvider] getActiveSubscription error', error)
|
|
setActiveSubscriptionError(
|
|
error?.message || "Impossible de mettre à jour les informations d'abonnement."
|
|
)
|
|
setActiveSubscription(null)
|
|
} finally {
|
|
setIsActiveSubscriptionLoading(false)
|
|
}
|
|
}, [stripeCustomerId, localSubscriptionId])
|
|
|
|
const fetchStripeCatalog = React.useCallback(async () => {
|
|
setIsCatalogLoading(true)
|
|
setCatalogError(null)
|
|
const functionsClient = getFunctionsClient(FUNCTIONS_REGION)
|
|
let lastError = null
|
|
|
|
try {
|
|
const callable = functionsClient.httpsCallable('subscription-listSubscriptionPlans')
|
|
const { data } = await callable()
|
|
const nextPlans = data?.plans || {}
|
|
setSubscriptions({
|
|
monthly: Array.isArray(nextPlans?.monthly) ? nextPlans.monthly : [],
|
|
annual: Array.isArray(nextPlans?.annual) ? nextPlans.annual : [],
|
|
})
|
|
} catch (error) {
|
|
lastError = error
|
|
console.error('[StripeProvider] listSubscriptionPlans error', error)
|
|
}
|
|
|
|
try {
|
|
const callable = functionsClient.httpsCallable('subscription-listCoinPacks')
|
|
const { data } = await callable()
|
|
setCoinPacks(Array.isArray(data?.packs) ? data.packs : [])
|
|
} catch (error) {
|
|
lastError = error
|
|
console.error('[StripeProvider] listCoinPacks error', error)
|
|
}
|
|
|
|
if (lastError) {
|
|
setCatalogError(
|
|
lastError?.message || 'Impossible de récupérer les informations Stripe pour le moment.'
|
|
)
|
|
} else {
|
|
setCatalogError(null)
|
|
}
|
|
setIsCatalogLoading(false)
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
fetchStripeCatalog()
|
|
}, [fetchStripeCatalog])
|
|
|
|
React.useEffect(() => {
|
|
fetchActiveSubscription()
|
|
}, [fetchActiveSubscription])
|
|
|
|
const activeSubscriptionInfo = React.useMemo(() => {
|
|
const subscription = activeSubscription || null
|
|
const nextRenewalDate =
|
|
toDate(subscription?.currentPeriodEnd) || toDate(subscription?.current_period_end) || null
|
|
const subscribedSinceDate =
|
|
toDate(subscription?.created) ||
|
|
toDate(subscription?.createdAt) ||
|
|
toDate(subscription?.created_at) ||
|
|
null
|
|
|
|
return {
|
|
subscription,
|
|
nextRenewalDate,
|
|
nextRenewalLabel: nextRenewalDate ? formatDate(nextRenewalDate) : null,
|
|
subscribedSinceDate,
|
|
subscribedSinceLabel: subscribedSinceDate ? formatDate(subscribedSinceDate) : null,
|
|
}
|
|
}, [activeSubscription])
|
|
|
|
const providerValue = React.useMemo(
|
|
() => ({
|
|
subscriptions,
|
|
coinPacks,
|
|
isCatalogLoading,
|
|
catalogError,
|
|
refreshCatalog: fetchStripeCatalog,
|
|
createSubscriptionCheckout,
|
|
createSongDownloadCheckout,
|
|
createPlaybackDownloadCheckout,
|
|
createCoinPackCheckout,
|
|
openEmbeddedCheckout: setClientSecret,
|
|
closeEmbeddedCheckout,
|
|
activeSubscription: activeSubscriptionInfo.subscription,
|
|
activeSubscriptionInfo,
|
|
isActiveSubscriptionLoading,
|
|
activeSubscriptionError,
|
|
refreshActiveSubscription: fetchActiveSubscription,
|
|
}),
|
|
[
|
|
subscriptions,
|
|
coinPacks,
|
|
isCatalogLoading,
|
|
catalogError,
|
|
fetchStripeCatalog,
|
|
createSubscriptionCheckout,
|
|
createSongDownloadCheckout,
|
|
createPlaybackDownloadCheckout,
|
|
createCoinPackCheckout,
|
|
setClientSecret,
|
|
closeEmbeddedCheckout,
|
|
activeSubscriptionInfo,
|
|
isActiveSubscriptionLoading,
|
|
activeSubscriptionError,
|
|
fetchActiveSubscription,
|
|
]
|
|
)
|
|
|
|
return (
|
|
<StripeContext.Provider value={providerValue}>
|
|
{children}
|
|
<Overlay
|
|
isVisible={clientSecret !== null}
|
|
setIsVisible={closeEmbeddedCheckout}
|
|
contentContainerStyle={styles.overlayContentFull}
|
|
>
|
|
<View style={[StyleSheet.absoluteFillObject, styles.overlayContent]}>
|
|
<View style={[styles.embeddedWrapper, styles.embeddedWrapperFull]}>
|
|
{clientSecret && stripePromise ? (
|
|
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>
|
|
<EmbeddedCheckout
|
|
onComplete={() => {
|
|
closeEmbeddedCheckout()
|
|
fetchActiveSubscription()
|
|
}}
|
|
/>
|
|
</EmbeddedCheckoutProvider>
|
|
) : null}
|
|
</View>
|
|
<Button
|
|
type="primary"
|
|
isAbsoluteBottom
|
|
text="Fermer"
|
|
onPress={closeEmbeddedCheckout}
|
|
containerStyle={styles.closeButton}
|
|
textStyle={styles.closeButtonText}
|
|
/>
|
|
</View>
|
|
</Overlay>
|
|
</StripeContext.Provider>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
overlayContent: {
|
|
position: 'absolute',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
overlayContentFull: {
|
|
justifyContent: 'flex-start',
|
|
alignItems: 'stretch',
|
|
},
|
|
embeddedWrapper: {
|
|
alignSelf: 'center',
|
|
borderRadius: mainBorderRadius,
|
|
overflow: 'hidden',
|
|
},
|
|
embeddedWrapperFull: {
|
|
width: '100%',
|
|
height: '100%',
|
|
maxWidth: '100%',
|
|
borderRadius: 0,
|
|
alignSelf: 'stretch',
|
|
},
|
|
closeButton: {
|
|
height: 56,
|
|
borderWidth: 2,
|
|
borderColor: Palette.white,
|
|
shadowColor: Palette.primary,
|
|
shadowOpacity: 0.6,
|
|
shadowRadius: 10,
|
|
shadowOffset: { width: 0, height: 6 },
|
|
elevation: 6,
|
|
},
|
|
closeButtonText: {
|
|
color: Palette.white,
|
|
fontSize: 16,
|
|
letterSpacing: 0.3,
|
|
},
|
|
})
|
|
|
|
export default StripeProvider
|