feat: fix payment flow

This commit is contained in:
2026-09-04 13:04:27 +02:00
parent efbb7994f2
commit 3ba290031d
4 changed files with 248 additions and 30 deletions
+6 -2
View File
@@ -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 (
<ContentContainerView>
<AnimatePresence>
{isVisible ? (
{shouldRender ? (
<Motion.View
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
animate={{ opacity: isVisible ? 1 : 0 }}
exit={{ opacity: 0 }}
transition={{
type: 'tween',
@@ -47,6 +50,7 @@ const Overlay = ({
backgroundColor: Palette.ultraLightBlack,
flex: 1,
...Style.containerCenter,
pointerEvents: isKeptHidden ? 'none' : 'auto',
}}
>
<BackgroundView
+221 -15
View File
@@ -1,10 +1,11 @@
import React from 'react'
import { Linking, Platform, StyleSheet, View } from 'react-native'
import { Linking, Platform, StyleSheet, Text, 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 ActivityLoader from '../components/ActivityLoader'
import { Palette } from '../styles'
import { mainBorderRadius } from '../styles/Style'
import { STRIPE_PUBLISHABLE_KEY_LIVE, STRIPE_PUBLISHABLE_KEY_TEST } from '../data/keys'
@@ -14,6 +15,8 @@ import { useUserData } from './UserDataProvider'
import { formatDate, toDate } from '../utils/dateFormatting'
const FUNCTIONS_REGION = 'europe-west1'
const EMBEDDED_CHECKOUT_BOOTSTRAP_HEIGHT = 620
const EMBEDDED_CHECKOUT_TIMEOUT_MS = 6000
const STRIPE_SUCCESS_URL =
'https://dashboard.stripe.com/test/billing/starter-guide/checkout-success'
const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL
@@ -85,6 +88,11 @@ const StripeProvider = ({ children }) => {
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 }) => {
<StripeContext.Provider value={providerValue}>
{children}
<Overlay
isVisible={clientSecret !== null}
isVisible={isEmbeddedCheckoutVisible}
setIsVisible={closeEmbeddedCheckout}
contentContainerStyle={styles.overlayContentFull}
keepMounted={clientSecret !== null}
>
<View style={[StyleSheet.absoluteFillObject, styles.overlayContent]}>
<View style={[styles.embeddedWrapper, styles.embeddedWrapperFull]}>
{clientSecret && stripePromise ? (
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>
<EmbeddedCheckout
onComplete={() => {
closeEmbeddedCheckout()
fetchActiveSubscription()
}}
/>
<EmbeddedCheckoutProvider
key={embeddedCheckoutInstanceKey}
stripe={stripePromise}
options={embeddedCheckoutOptions}
>
<EmbeddedCheckout id="musicland-embedded-checkout" />
</EmbeddedCheckoutProvider>
) : null}
</View>
{embeddedCheckoutPhase === 'loading' ? (
<View style={styles.checkoutLoading}>
<ActivityLoader defaultMessage="Chargement du paiement sécurisé..." />
</View>
) : null}
{embeddedCheckoutPhase === 'failed' ? (
<View style={styles.checkoutFailure}>
<Text style={styles.checkoutFailureTitle}>Le paiement ne sest pas chargé.</Text>
<Text style={styles.checkoutFailureMessage}>
Vérifie ta connexion puis réessaie.
</Text>
<Button
type="primary"
text="Réessayer"
onPress={retryEmbeddedCheckout}
containerStyle={styles.retryButton}
/>
</View>
) : null}
<Button
type="primary"
isAbsoluteBottom
text="Fermer"
text="Fermer le paiement"
onPress={closeEmbeddedCheckout}
containerStyle={styles.closeButton}
textStyle={styles.closeButtonText}
@@ -488,11 +652,52 @@ const styles = StyleSheet.create({
overflow: 'hidden',
},
embeddedWrapperFull: {
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
right: 0,
bottom: 72,
left: 0,
width: 'auto',
height: 'auto',
maxWidth: '100%',
borderRadius: 0,
alignSelf: 'stretch',
overflow: 'auto',
zIndex: 1,
},
checkoutLoading: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
paddingBottom: 72,
pointerEvents: 'none',
backgroundColor: 'rgba(7, 17, 27, 0.88)',
zIndex: 2,
},
checkoutFailure: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
paddingHorizontal: 24,
paddingBottom: 72,
backgroundColor: 'rgba(7, 17, 27, 0.94)',
zIndex: 2,
},
checkoutFailureTitle: {
color: Palette.white,
fontSize: 20,
textAlign: 'center',
},
checkoutFailureMessage: {
color: Palette.white,
fontSize: 15,
opacity: 0.8,
textAlign: 'center',
},
retryButton: {
width: '100%',
maxWidth: 320,
},
closeButton: {
height: 56,
@@ -503,6 +708,7 @@ const styles = StyleSheet.create({
shadowRadius: 10,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
zIndex: 3,
},
closeButtonText: {
color: Palette.white,
+10 -8
View File
@@ -173,10 +173,13 @@ const PlaybackDownload = ({ route }) => {
const hasShownAfterPlaybackRef = useRef(false)
const projectForDownload = useMemo(() => {
if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) {
return selectedProject
if (routeProject && typeof routeProject === 'object' && routeProject.id) {
if (selectedProject?.id === routeProject.id) {
return selectedProject
}
return routeProject
}
return routeProject || selectedProject || null
return selectedProject?.id ? selectedProject : null
}, [routeProject, selectedProject])
const playbackDownloadStatus = projectForDownload?.playbackDownloadPurchase?.status || null
@@ -365,7 +368,7 @@ const PlaybackDownload = ({ route }) => {
)
const startPlaybackDownloadCheckout = useCallback(
async (intent, { force = false } = {}) => {
async (intent) => {
if (!projectForDownload?.id) {
setTooltip({
type: 'error',
@@ -374,7 +377,7 @@ const PlaybackDownload = ({ route }) => {
return
}
if (!force && (isCheckoutLaunching || downloadPaymentPending)) {
if (isCheckoutLaunching) {
return
}
@@ -396,7 +399,6 @@ const PlaybackDownload = ({ route }) => {
},
[
createPlaybackDownloadCheckout,
downloadPaymentPending,
isCheckoutLaunching,
projectForDownload,
setTooltip,
@@ -480,7 +482,7 @@ const PlaybackDownload = ({ route }) => {
<Text style={styles.sectionTitle}>Téléchargement</Text>
<Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
<BorderGradientButton
title="Option 1 · Téléchargement à des fins personnelles"
title="Télécharger à des fins personnelles"
onPress={() => handlePlaybackChoice('personal')}
disabled={isPublishing || isDownloading || isCheckoutLaunching}
loading={isDownloading && !isPublishing}
@@ -492,7 +494,7 @@ const PlaybackDownload = ({ route }) => {
title={
isPublishing
? 'Téléchargement et publication en cours...'
: 'Option 2 · Télécharger et publier sur MusicLand'
: 'Télécharger et publier sur MusicLand'
}
onPress={() => handlePlaybackChoice('publish')}
disabled={isPublishing || isDownloading || isCheckoutLaunching}
+11 -5
View File
@@ -74,9 +74,6 @@ const getStageLockState = (key, metadata) => {
}
return metadata.lyricsCount <= 0
case 'director':
if (metadata.hasPlaybackAsset) {
return true
}
return !metadata.hasCover
default:
return true
@@ -135,7 +132,7 @@ const getStageDescription = (key, metadata) => {
return 'Votre playback IA est prêt à être validé'
}
if (hasPlaybackAsset) {
return 'Modifier le playback généré'
return 'Télécharger ou publier votre playback'
}
return 'Commencer la création de votre playback'
default:
@@ -153,7 +150,7 @@ const getStageLockedDescription = (key, metadata) => {
}
return metadata.lyricsCount > 0 ? undefined : 'Créez vos paroles pour débloquer le studio'
case 'director':
return metadata.hasPlaybackAsset ? 'Impossible de modifier le playback' : undefined
return undefined
default:
return undefined
}
@@ -230,6 +227,15 @@ export const getStageAction = (key, project) => {
return { route: hasFinalCover ? Routes.ChooseCoverType : Routes.PouchReady }
}
case 'director':
if (project?.playbackUrl) {
return {
route: Routes.PlaybackDownload,
params: {
action: 'playback',
project,
},
}
}
return {
route: Routes.Playback,
params: project ? { project } : undefined,