feat: fix payment flow
This commit is contained in:
@@ -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
@@ -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 s’est 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,
|
||||
|
||||
@@ -173,10 +173,13 @@ const PlaybackDownload = ({ route }) => {
|
||||
const hasShownAfterPlaybackRef = useRef(false)
|
||||
|
||||
const projectForDownload = useMemo(() => {
|
||||
if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) {
|
||||
if (routeProject && typeof routeProject === 'object' && routeProject.id) {
|
||||
if (selectedProject?.id === routeProject.id) {
|
||||
return selectedProject
|
||||
}
|
||||
return routeProject || selectedProject || null
|
||||
return routeProject
|
||||
}
|
||||
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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user