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 = {}, contentContainerStyle = {},
hasPortal = true, hasPortal = true,
keepMounted = false,
}) => { }) => {
const { isNative } = useLayoutType() const { isNative } = useLayoutType()
@@ -27,15 +28,17 @@ const Overlay = ({
const shouldUseBlur = resolvedBlurIntensity > 0 const shouldUseBlur = resolvedBlurIntensity > 0
const BackgroundView = shouldUseBlur ? BlurView : View const BackgroundView = shouldUseBlur ? BlurView : View
const backgroundProps = shouldUseBlur ? { intensity: resolvedBlurIntensity, tint: 'dark' } : {} const backgroundProps = shouldUseBlur ? { intensity: resolvedBlurIntensity, tint: 'dark' } : {}
const shouldRender = isVisible || keepMounted
const isKeptHidden = keepMounted && !isVisible
return ( return (
<ContentContainerView> <ContentContainerView>
<AnimatePresence> <AnimatePresence>
{isVisible ? ( {shouldRender ? (
<Motion.View <Motion.View
key="overlay" key="overlay"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: isVisible ? 1 : 0 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
type: 'tween', type: 'tween',
@@ -47,6 +50,7 @@ const Overlay = ({
backgroundColor: Palette.ultraLightBlack, backgroundColor: Palette.ultraLightBlack,
flex: 1, flex: 1,
...Style.containerCenter, ...Style.containerCenter,
pointerEvents: isKeptHidden ? 'none' : 'auto',
}} }}
> >
<BackgroundView <BackgroundView
+221 -15
View File
@@ -1,10 +1,11 @@
import React from 'react' 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 { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js' import { loadStripe } from '@stripe/stripe-js'
import * as WebBrowser from 'expo-web-browser' import * as WebBrowser from 'expo-web-browser'
import Overlay from '../components/Overlay' import Overlay from '../components/Overlay'
import Button from '../components/Button' import Button from '../components/Button'
import ActivityLoader from '../components/ActivityLoader'
import { Palette } from '../styles' import { Palette } from '../styles'
import { mainBorderRadius } from '../styles/Style' import { mainBorderRadius } from '../styles/Style'
import { STRIPE_PUBLISHABLE_KEY_LIVE, STRIPE_PUBLISHABLE_KEY_TEST } from '../data/keys' 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' import { formatDate, toDate } from '../utils/dateFormatting'
const FUNCTIONS_REGION = 'europe-west1' const FUNCTIONS_REGION = 'europe-west1'
const EMBEDDED_CHECKOUT_BOOTSTRAP_HEIGHT = 620
const EMBEDDED_CHECKOUT_TIMEOUT_MS = 6000
const STRIPE_SUCCESS_URL = const STRIPE_SUCCESS_URL =
'https://dashboard.stripe.com/test/billing/starter-guide/checkout-success' 'https://dashboard.stripe.com/test/billing/starter-guide/checkout-success'
const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL
@@ -85,6 +88,11 @@ const StripeProvider = ({ children }) => {
const canUseEmbeddedCheckout = isWeb && Boolean(stripePromise) const canUseEmbeddedCheckout = isWeb && Boolean(stripePromise)
const [clientSecret, setClientSecret] = React.useState(null) 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({ const [subscriptions, setSubscriptions] = React.useState({
monthly: [], monthly: [],
annual: [], annual: [],
@@ -103,9 +111,32 @@ const StripeProvider = ({ children }) => {
null null
const closeEmbeddedCheckout = React.useCallback(() => { const closeEmbeddedCheckout = React.useCallback(() => {
setIsEmbeddedCheckoutVisible(false)
}, [])
const resetEmbeddedCheckout = React.useCallback(() => {
embeddedCheckoutSessionKeyRef.current = null
setIsEmbeddedCheckoutVisible(false)
setEmbeddedCheckoutAttempt(0)
setEmbeddedCheckoutPhase('idle')
setClientSecret(null) 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 = {}) => { const redirectToCheckout = React.useCallback(async (checkoutUrl = {}) => {
if (!checkoutUrl) { if (!checkoutUrl) {
throw new Error('Session Stripe introuvable.') throw new Error('Session Stripe introuvable.')
@@ -144,6 +175,20 @@ const StripeProvider = ({ children }) => {
} }
const wantsEmbeddedCheckout = useEmbeddedFlow && canUseEmbeddedCheckout 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() const safariWindow = wantsEmbeddedCheckout ? null : openSafariCheckoutWindow()
try { try {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(callableName) const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(callableName)
@@ -155,7 +200,12 @@ const StripeProvider = ({ children }) => {
if (!clientSecret) { if (!clientSecret) {
throw new Error('Session Stripe introuvable.') throw new Error('Session Stripe introuvable.')
} }
embeddedCheckoutSessionKeyRef.current = embeddedCheckoutSessionKey
setEmbeddedCheckoutAttempt(0)
setEmbeddedCheckoutPhase('loading')
setEmbeddedCheckoutInstanceKey((currentKey) => currentKey + 1)
setClientSecret(clientSecret) setClientSecret(clientSecret)
setIsEmbeddedCheckoutVisible(true)
return return
} }
@@ -175,7 +225,12 @@ const StripeProvider = ({ children }) => {
) )
} }
}, },
[canUseEmbeddedCheckout, redirectToCheckout, setClientSecret] [
canUseEmbeddedCheckout,
clientSecret,
embeddedCheckoutPhase,
redirectToCheckout,
]
) )
const createSubscriptionCheckout = React.useCallback( const createSubscriptionCheckout = React.useCallback(
@@ -334,6 +389,96 @@ const StripeProvider = ({ children }) => {
} }
}, [stripeCustomerId, localSubscriptionId]) }, [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 () => { const fetchStripeCatalog = React.useCallback(async () => {
setIsCatalogLoading(true) setIsCatalogLoading(true)
setCatalogError(null) setCatalogError(null)
@@ -410,7 +555,7 @@ const StripeProvider = ({ children }) => {
createSongDownloadCheckout, createSongDownloadCheckout,
createPlaybackDownloadCheckout, createPlaybackDownloadCheckout,
createCoinPackCheckout, createCoinPackCheckout,
openEmbeddedCheckout: setClientSecret, openEmbeddedCheckout,
closeEmbeddedCheckout, closeEmbeddedCheckout,
activeSubscription: activeSubscriptionInfo.subscription, activeSubscription: activeSubscriptionInfo.subscription,
activeSubscriptionInfo, activeSubscriptionInfo,
@@ -428,7 +573,7 @@ const StripeProvider = ({ children }) => {
createSongDownloadCheckout, createSongDownloadCheckout,
createPlaybackDownloadCheckout, createPlaybackDownloadCheckout,
createCoinPackCheckout, createCoinPackCheckout,
setClientSecret, openEmbeddedCheckout,
closeEmbeddedCheckout, closeEmbeddedCheckout,
activeSubscriptionInfo, activeSubscriptionInfo,
isActiveSubscriptionLoading, isActiveSubscriptionLoading,
@@ -441,27 +586,46 @@ const StripeProvider = ({ children }) => {
<StripeContext.Provider value={providerValue}> <StripeContext.Provider value={providerValue}>
{children} {children}
<Overlay <Overlay
isVisible={clientSecret !== null} isVisible={isEmbeddedCheckoutVisible}
setIsVisible={closeEmbeddedCheckout} setIsVisible={closeEmbeddedCheckout}
contentContainerStyle={styles.overlayContentFull} contentContainerStyle={styles.overlayContentFull}
keepMounted={clientSecret !== null}
> >
<View style={[StyleSheet.absoluteFillObject, styles.overlayContent]}> <View style={[StyleSheet.absoluteFillObject, styles.overlayContent]}>
<View style={[styles.embeddedWrapper, styles.embeddedWrapperFull]}> <View style={[styles.embeddedWrapper, styles.embeddedWrapperFull]}>
{clientSecret && stripePromise ? ( {clientSecret && stripePromise ? (
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}> <EmbeddedCheckoutProvider
<EmbeddedCheckout key={embeddedCheckoutInstanceKey}
onComplete={() => { stripe={stripePromise}
closeEmbeddedCheckout() options={embeddedCheckoutOptions}
fetchActiveSubscription() >
}} <EmbeddedCheckout id="musicland-embedded-checkout" />
/>
</EmbeddedCheckoutProvider> </EmbeddedCheckoutProvider>
) : null} ) : null}
</View> </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 <Button
type="primary" type="primary"
isAbsoluteBottom isAbsoluteBottom
text="Fermer" text="Fermer le paiement"
onPress={closeEmbeddedCheckout} onPress={closeEmbeddedCheckout}
containerStyle={styles.closeButton} containerStyle={styles.closeButton}
textStyle={styles.closeButtonText} textStyle={styles.closeButtonText}
@@ -488,11 +652,52 @@ const styles = StyleSheet.create({
overflow: 'hidden', overflow: 'hidden',
}, },
embeddedWrapperFull: { embeddedWrapperFull: {
width: '100%', position: 'absolute',
height: '100%', top: 0,
right: 0,
bottom: 72,
left: 0,
width: 'auto',
height: 'auto',
maxWidth: '100%', maxWidth: '100%',
borderRadius: 0, borderRadius: 0,
alignSelf: 'stretch', 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: { closeButton: {
height: 56, height: 56,
@@ -503,6 +708,7 @@ const styles = StyleSheet.create({
shadowRadius: 10, shadowRadius: 10,
shadowOffset: { width: 0, height: 6 }, shadowOffset: { width: 0, height: 6 },
elevation: 6, elevation: 6,
zIndex: 3,
}, },
closeButtonText: { closeButtonText: {
color: Palette.white, color: Palette.white,
+10 -8
View File
@@ -173,10 +173,13 @@ const PlaybackDownload = ({ route }) => {
const hasShownAfterPlaybackRef = useRef(false) const hasShownAfterPlaybackRef = useRef(false)
const projectForDownload = useMemo(() => { const projectForDownload = useMemo(() => {
if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) { if (routeProject && typeof routeProject === 'object' && routeProject.id) {
return selectedProject if (selectedProject?.id === routeProject.id) {
return selectedProject
}
return routeProject
} }
return routeProject || selectedProject || null return selectedProject?.id ? selectedProject : null
}, [routeProject, selectedProject]) }, [routeProject, selectedProject])
const playbackDownloadStatus = projectForDownload?.playbackDownloadPurchase?.status || null const playbackDownloadStatus = projectForDownload?.playbackDownloadPurchase?.status || null
@@ -365,7 +368,7 @@ const PlaybackDownload = ({ route }) => {
) )
const startPlaybackDownloadCheckout = useCallback( const startPlaybackDownloadCheckout = useCallback(
async (intent, { force = false } = {}) => { async (intent) => {
if (!projectForDownload?.id) { if (!projectForDownload?.id) {
setTooltip({ setTooltip({
type: 'error', type: 'error',
@@ -374,7 +377,7 @@ const PlaybackDownload = ({ route }) => {
return return
} }
if (!force && (isCheckoutLaunching || downloadPaymentPending)) { if (isCheckoutLaunching) {
return return
} }
@@ -396,7 +399,6 @@ const PlaybackDownload = ({ route }) => {
}, },
[ [
createPlaybackDownloadCheckout, createPlaybackDownloadCheckout,
downloadPaymentPending,
isCheckoutLaunching, isCheckoutLaunching,
projectForDownload, projectForDownload,
setTooltip, setTooltip,
@@ -480,7 +482,7 @@ const PlaybackDownload = ({ route }) => {
<Text style={styles.sectionTitle}>Téléchargement</Text> <Text style={styles.sectionTitle}>Téléchargement</Text>
<Text style={styles.sectionDescription}>{downloadAccessLabel}</Text> <Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
<BorderGradientButton <BorderGradientButton
title="Option 1 · Téléchargement à des fins personnelles" title="Télécharger à des fins personnelles"
onPress={() => handlePlaybackChoice('personal')} onPress={() => handlePlaybackChoice('personal')}
disabled={isPublishing || isDownloading || isCheckoutLaunching} disabled={isPublishing || isDownloading || isCheckoutLaunching}
loading={isDownloading && !isPublishing} loading={isDownloading && !isPublishing}
@@ -492,7 +494,7 @@ const PlaybackDownload = ({ route }) => {
title={ title={
isPublishing isPublishing
? 'Téléchargement et publication en cours...' ? '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')} onPress={() => handlePlaybackChoice('publish')}
disabled={isPublishing || isDownloading || isCheckoutLaunching} disabled={isPublishing || isDownloading || isCheckoutLaunching}
+11 -5
View File
@@ -74,9 +74,6 @@ const getStageLockState = (key, metadata) => {
} }
return metadata.lyricsCount <= 0 return metadata.lyricsCount <= 0
case 'director': case 'director':
if (metadata.hasPlaybackAsset) {
return true
}
return !metadata.hasCover return !metadata.hasCover
default: default:
return true return true
@@ -135,7 +132,7 @@ const getStageDescription = (key, metadata) => {
return 'Votre playback IA est prêt à être validé' return 'Votre playback IA est prêt à être validé'
} }
if (hasPlaybackAsset) { 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' return 'Commencer la création de votre playback'
default: default:
@@ -153,7 +150,7 @@ const getStageLockedDescription = (key, metadata) => {
} }
return metadata.lyricsCount > 0 ? undefined : 'Créez vos paroles pour débloquer le studio' return metadata.lyricsCount > 0 ? undefined : 'Créez vos paroles pour débloquer le studio'
case 'director': case 'director':
return metadata.hasPlaybackAsset ? 'Impossible de modifier le playback' : undefined return undefined
default: default:
return undefined return undefined
} }
@@ -230,6 +227,15 @@ export const getStageAction = (key, project) => {
return { route: hasFinalCover ? Routes.ChooseCoverType : Routes.PouchReady } return { route: hasFinalCover ? Routes.ChooseCoverType : Routes.PouchReady }
} }
case 'director': case 'director':
if (project?.playbackUrl) {
return {
route: Routes.PlaybackDownload,
params: {
action: 'playback',
project,
},
}
}
return { return {
route: Routes.Playback, route: Routes.Playback,
params: project ? { project } : undefined, params: project ? { project } : undefined,