From 3ba290031d51fb21d5151437930e4b7c26479818 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Fri, 4 Sep 2026 13:04:27 +0200 Subject: [PATCH] feat: fix payment flow --- src/components/Overlay.js | 8 +- src/providers/StripeProvider.js | 236 +++++++++++++++++++-- src/screens/Production/PlaybackDownload.js | 18 +- src/utils/projectStages.js | 16 +- 4 files changed, 248 insertions(+), 30 deletions(-) diff --git a/src/components/Overlay.js b/src/components/Overlay.js index 2ff629a..88fbba7 100644 --- a/src/components/Overlay.js +++ b/src/components/Overlay.js @@ -19,6 +19,7 @@ const Overlay = ({ contentContainerStyle = {}, hasPortal = true, + keepMounted = false, }) => { const { isNative } = useLayoutType() @@ -27,15 +28,17 @@ const Overlay = ({ const shouldUseBlur = resolvedBlurIntensity > 0 const BackgroundView = shouldUseBlur ? BlurView : View const backgroundProps = shouldUseBlur ? { intensity: resolvedBlurIntensity, tint: 'dark' } : {} + const shouldRender = isVisible || keepMounted + const isKeptHidden = keepMounted && !isVisible return ( - {isVisible ? ( + {shouldRender ? ( { const canUseEmbeddedCheckout = isWeb && Boolean(stripePromise) const [clientSecret, setClientSecret] = React.useState(null) + const [isEmbeddedCheckoutVisible, setIsEmbeddedCheckoutVisible] = React.useState(false) + const [embeddedCheckoutAttempt, setEmbeddedCheckoutAttempt] = React.useState(0) + const [embeddedCheckoutInstanceKey, setEmbeddedCheckoutInstanceKey] = React.useState(0) + const [embeddedCheckoutPhase, setEmbeddedCheckoutPhase] = React.useState('idle') + const embeddedCheckoutSessionKeyRef = React.useRef(null) const [subscriptions, setSubscriptions] = React.useState({ monthly: [], annual: [], @@ -103,9 +111,32 @@ const StripeProvider = ({ children }) => { null const closeEmbeddedCheckout = React.useCallback(() => { + setIsEmbeddedCheckoutVisible(false) + }, []) + + const resetEmbeddedCheckout = React.useCallback(() => { + embeddedCheckoutSessionKeyRef.current = null + setIsEmbeddedCheckoutVisible(false) + setEmbeddedCheckoutAttempt(0) + setEmbeddedCheckoutPhase('idle') setClientSecret(null) }, []) + const retryEmbeddedCheckout = React.useCallback(() => { + setEmbeddedCheckoutPhase('loading') + setEmbeddedCheckoutAttempt((currentAttempt) => currentAttempt + 1) + setEmbeddedCheckoutInstanceKey((currentKey) => currentKey + 1) + }, []) + + const openEmbeddedCheckout = React.useCallback((nextClientSecret) => { + embeddedCheckoutSessionKeyRef.current = null + setEmbeddedCheckoutAttempt(0) + setEmbeddedCheckoutPhase('loading') + setEmbeddedCheckoutInstanceKey((currentKey) => currentKey + 1) + setClientSecret(nextClientSecret) + setIsEmbeddedCheckoutVisible(true) + }, []) + const redirectToCheckout = React.useCallback(async (checkoutUrl = {}) => { if (!checkoutUrl) { throw new Error('Session Stripe introuvable.') @@ -144,6 +175,20 @@ const StripeProvider = ({ children }) => { } const wantsEmbeddedCheckout = useEmbeddedFlow && canUseEmbeddedCheckout + const embeddedCheckoutSessionKey = wantsEmbeddedCheckout + ? JSON.stringify({ callableName, payload }) + : null + + if ( + wantsEmbeddedCheckout && + clientSecret && + embeddedCheckoutPhase !== 'failed' && + embeddedCheckoutSessionKeyRef.current === embeddedCheckoutSessionKey + ) { + setIsEmbeddedCheckoutVisible(true) + return + } + const safariWindow = wantsEmbeddedCheckout ? null : openSafariCheckoutWindow() try { const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(callableName) @@ -155,7 +200,12 @@ const StripeProvider = ({ children }) => { if (!clientSecret) { throw new Error('Session Stripe introuvable.') } + embeddedCheckoutSessionKeyRef.current = embeddedCheckoutSessionKey + setEmbeddedCheckoutAttempt(0) + setEmbeddedCheckoutPhase('loading') + setEmbeddedCheckoutInstanceKey((currentKey) => currentKey + 1) setClientSecret(clientSecret) + setIsEmbeddedCheckoutVisible(true) return } @@ -175,7 +225,12 @@ const StripeProvider = ({ children }) => { ) } }, - [canUseEmbeddedCheckout, redirectToCheckout, setClientSecret] + [ + canUseEmbeddedCheckout, + clientSecret, + embeddedCheckoutPhase, + redirectToCheckout, + ] ) const createSubscriptionCheckout = React.useCallback( @@ -334,6 +389,96 @@ const StripeProvider = ({ children }) => { } }, [stripeCustomerId, localSubscriptionId]) + const handleEmbeddedCheckoutComplete = React.useCallback(() => { + resetEmbeddedCheckout() + fetchActiveSubscription() + }, [fetchActiveSubscription, resetEmbeddedCheckout]) + + const embeddedCheckoutOptions = React.useMemo( + () => ({ + clientSecret, + onComplete: handleEmbeddedCheckoutComplete, + }), + [clientSecret, handleEmbeddedCheckoutComplete] + ) + + React.useEffect(() => { + if ( + !isWeb || + !clientSecret || + embeddedCheckoutPhase !== 'loading' || + typeof document === 'undefined' || + typeof MutationObserver !== 'function' + ) { + return undefined + } + + let checkoutFrameObserver = null + let documentObserver = null + let observedFrame = null + let isReady = false + + const markCheckoutReady = () => { + if (isReady) return + isReady = true + setEmbeddedCheckoutPhase('ready') + } + + const inspectCheckoutFrame = () => { + const checkoutFrame = document.querySelector( + '#musicland-embedded-checkout iframe[title="Embedded checkout"]' + ) + if (!checkoutFrame) return + + const frameHeight = Number.parseFloat(checkoutFrame.style.height) + if ( + Number.isFinite(frameHeight) && + Math.abs(frameHeight - EMBEDDED_CHECKOUT_BOOTSTRAP_HEIGHT) > 1 + ) { + markCheckoutReady() + return + } + + if (checkoutFrame === observedFrame) return + checkoutFrameObserver?.disconnect() + observedFrame = checkoutFrame + checkoutFrameObserver = new MutationObserver(inspectCheckoutFrame) + checkoutFrameObserver.observe(checkoutFrame, { + attributes: true, + attributeFilter: ['style'], + }) + } + + documentObserver = new MutationObserver(inspectCheckoutFrame) + documentObserver.observe(document.body, { + childList: true, + subtree: true, + }) + inspectCheckoutFrame() + + const loadingTimeout = setTimeout(() => { + if (isReady) return + if (embeddedCheckoutAttempt === 0) { + console.warn('[StripeProvider] Embedded Checkout stalled, retrying initialization') + retryEmbeddedCheckout() + return + } + console.error('[StripeProvider] Embedded Checkout failed to become ready') + setEmbeddedCheckoutPhase('failed') + }, EMBEDDED_CHECKOUT_TIMEOUT_MS) + + return () => { + clearTimeout(loadingTimeout) + checkoutFrameObserver?.disconnect() + documentObserver?.disconnect() + } + }, [ + clientSecret, + embeddedCheckoutAttempt, + embeddedCheckoutPhase, + retryEmbeddedCheckout, + ]) + const fetchStripeCatalog = React.useCallback(async () => { setIsCatalogLoading(true) setCatalogError(null) @@ -410,7 +555,7 @@ const StripeProvider = ({ children }) => { createSongDownloadCheckout, createPlaybackDownloadCheckout, createCoinPackCheckout, - openEmbeddedCheckout: setClientSecret, + openEmbeddedCheckout, closeEmbeddedCheckout, activeSubscription: activeSubscriptionInfo.subscription, activeSubscriptionInfo, @@ -428,7 +573,7 @@ const StripeProvider = ({ children }) => { createSongDownloadCheckout, createPlaybackDownloadCheckout, createCoinPackCheckout, - setClientSecret, + openEmbeddedCheckout, closeEmbeddedCheckout, activeSubscriptionInfo, isActiveSubscriptionLoading, @@ -441,27 +586,46 @@ const StripeProvider = ({ children }) => { {children} {clientSecret && stripePromise ? ( - - { - closeEmbeddedCheckout() - fetchActiveSubscription() - }} - /> + + ) : null} + {embeddedCheckoutPhase === 'loading' ? ( + + + + ) : null} + {embeddedCheckoutPhase === 'failed' ? ( + + Le paiement ne s’est pas chargé. + + Vérifie ta connexion puis réessaie. + +