feat(ui): refine club, packs, and download flows

This commit is contained in:
Jacques TIROT
2026-03-11 11:54:10 +01:00
parent af8a8bafa4
commit fd299e8543
12 changed files with 565 additions and 598 deletions
+4 -16
View File
@@ -1,15 +1,14 @@
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useMemo } from 'react'
import { Platform, Pressable, StyleSheet } from 'react-native' import { Platform, Pressable, StyleSheet } from 'react-native'
import CreditAmount from './CreditAmount' import CreditAmount from './CreditAmount'
import CoinPackModal from './modal/CoinPackModal'
import { useUser } from '../providers/UserDataProvider' import { useUser } from '../providers/UserDataProvider'
import { Palette } from '../styles' import { Palette } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts' import { FONT_FAMILY } from '../styles/Fonts'
import { openCoinPackModal } from '../utils/coinPackModal' import { navigate } from '../navigation/NavigationService'
import { Routes } from '../navigation/Routes'
const MobileCoinBadge = ({ style, textStyle, iconSize = 20, iconPosition = 'left' }) => { const MobileCoinBadge = ({ style, textStyle, iconSize = 20, iconPosition = 'left' }) => {
const { currentUID, currentUserData } = useUser() || {} const { currentUID, currentUserData } = useUser() || {}
const [isModalVisible, setModalVisible] = useState(false)
const coinBalance = useMemo(() => { const coinBalance = useMemo(() => {
const value = currentUserData?.coins const value = currentUserData?.coins
@@ -26,15 +25,7 @@ const MobileCoinBadge = ({ style, textStyle, iconSize = 20, iconPosition = 'left
}, [currentUserData?.coins]) }, [currentUserData?.coins])
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
if (Platform.OS === 'web') { navigate(Routes.Payments)
openCoinPackModal()
return
}
setModalVisible(true)
}, [])
const handleClose = useCallback(() => {
setModalVisible(false)
}, []) }, [])
if (!currentUID || Platform.OS === 'web') { if (!currentUID || Platform.OS === 'web') {
@@ -42,7 +33,6 @@ const MobileCoinBadge = ({ style, textStyle, iconSize = 20, iconPosition = 'left
} }
return ( return (
<>
<Pressable onPress={handlePress} accessibilityRole="button" style={[styles.container, style]}> <Pressable onPress={handlePress} accessibilityRole="button" style={[styles.container, style]}>
<CreditAmount <CreditAmount
value={coinBalance} value={coinBalance}
@@ -52,8 +42,6 @@ const MobileCoinBadge = ({ style, textStyle, iconSize = 20, iconPosition = 'left
iconPosition={iconPosition} iconPosition={iconPosition}
/> />
</Pressable> </Pressable>
<CoinPackModal visible={isModalVisible} onClose={handleClose} />
</>
) )
} }
+34 -35
View File
@@ -17,6 +17,11 @@ import { FONT_FAMILY } from '../../styles/Fonts'
import { isWeb } from '../../hooks/useLayoutType' import { isWeb } from '../../hooks/useLayoutType'
import CreditAmount from '../CreditAmount' import CreditAmount from '../CreditAmount'
import { useStripe } from '../../providers/StripeProvider' import { useStripe } from '../../providers/StripeProvider'
import {
COIN_PACK_DETAILS,
COIN_PACK_SECTION_TITLE,
getCoinPackKey,
} from '../../utils/coinPackDisplay'
const WEB_MODAL_MAX_WIDTH = 820 const WEB_MODAL_MAX_WIDTH = 820
const formatCurrency = (amount, currency = 'eur') => { const formatCurrency = (amount, currency = 'eur') => {
@@ -41,37 +46,10 @@ const formatCurrency = (amount, currency = 'eur') => {
return `${normalized.toFixed(2)} ${upperCurrency}` return `${normalized.toFixed(2)} ${upperCurrency}`
} }
const FALLBACK_BASE_PRICE_PER_COIN = 12 // ~0,12€ par jeton (valeur indicatif pour l'affichage) const FALLBACK_BASE_PRICE_PER_COIN = 12 // ~0,12€ par credit (valeur indicative pour l'affichage)
const FALLBACK_DISCOUNT_STEPS = [0, 12, 18, 26, 32] const FALLBACK_DISCOUNT_STEPS = [0, 12, 18, 26, 32]
const STATIC_DISCOUNTS = [0, 30, 50] const STATIC_DISCOUNTS = [0, 30, 50]
const COIN_PACK_DETAILS = {
starter: {
label: 'Pack starter',
creditsDisplay: '50 Crédits',
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
},
medium: {
label: 'Pack Medium',
creditsDisplay: '100 Crédits + 50 offerts',
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
},
premium: {
label: 'Pack Premium',
creditsDisplay: '150 Crédits + 100 offerts',
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
},
}
const getCoinPackKey = (pack) => {
const name = (pack?.name || '').toLowerCase()
if (name.includes('starter')) return 'starter'
if (name.includes('medium')) return 'medium'
if (name.includes('premium') || name.includes('prenium') || name.includes('creator'))
return 'premium'
return null
}
const computeCoinPackPricing = (packs = []) => { const computeCoinPackPricing = (packs = []) => {
if (!Array.isArray(packs) || !packs.length) { if (!Array.isArray(packs) || !packs.length) {
return {} return {}
@@ -191,6 +169,8 @@ function CoinPackCard({ pack, selected, pricingDetail, onSelect, staticDiscountP
const details = packKey ? COIN_PACK_DETAILS[packKey] : null const details = packKey ? COIN_PACK_DETAILS[packKey] : null
const displayName = details?.label || pack.name const displayName = details?.label || pack.name
const creditsText = details?.creditsDisplay const creditsText = details?.creditsDisplay
const displayPrice = details?.priceDisplay || formattedPrice
const isPopular = details?.popular === true
return ( return (
<Pressable <Pressable
@@ -235,8 +215,13 @@ function CoinPackCard({ pack, selected, pricingDetail, onSelect, staticDiscountP
) : null} ) : null}
</View> </View>
<View style={styles.priceBlock}> <View style={styles.priceBlock}>
{formattedPrice ? <Text style={styles.packPrice}>{formattedPrice}</Text> : null} {displayPrice ? <Text style={styles.packPrice}>{displayPrice}</Text> : null}
</View> </View>
{isPopular ? (
<View style={styles.popularBadge}>
<Text style={styles.popularBadgeText}>Le plus populaire !</Text>
</View>
) : null}
</BlurView> </BlurView>
</Pressable> </Pressable>
) )
@@ -324,7 +309,7 @@ const CoinPackModal = ({ visible, onClose }) => {
if (!coinPacks.length) { if (!coinPacks.length) {
return ( return (
<Text style={styles.emptyState}>Aucun pack de pièces n'est disponible pour le moment.</Text> <Text style={styles.emptyState}>Aucun pack de crédits n'est disponible pour le moment.</Text>
) )
} }
@@ -365,9 +350,9 @@ const CoinPackModal = ({ visible, onClose }) => {
> >
<View style={styles.container}> <View style={styles.container}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>Acheter des pièces</Text> <Text style={styles.title}>{COIN_PACK_SECTION_TITLE}</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Choisis un pack et finalise ton achat pour continuer. Crée tes chansons et tes vidéos.
</Text> </Text>
</View> </View>
@@ -378,8 +363,7 @@ const CoinPackModal = ({ visible, onClose }) => {
<View style={styles.content}>{renderContent()}</View> <View style={styles.content}>{renderContent()}</View>
<Text style={styles.indicativeNote}> <Text style={styles.indicativeNote}>
Tarifs indicatifs : les prix et quantités de jetons sont amenés à évoluer. Les remises Téléchargement gratuit de tes créations.
sont affichées vs le pack de base pour mieux valoriser les offres volumineuses.
</Text> </Text>
<View style={styles.actions}> <View style={styles.actions}>
@@ -394,7 +378,7 @@ const CoinPackModal = ({ visible, onClose }) => {
</View> </View>
<Text style={styles.disclaimer}> <Text style={styles.disclaimer}>
Les pièces sont crédies dès que le paiement Stripe est validé. Les crédits sont ajoutés dès que le paiement Stripe est validé.
</Text> </Text>
</View> </View>
</CreateLyricsHeader> </CreateLyricsHeader>
@@ -548,9 +532,24 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
}, },
priceBlock: { priceBlock: {
marginTop: 'auto',
gap: 2, gap: 2,
alignItems: 'center', alignItems: 'center',
}, },
popularBadge: {
alignSelf: 'center',
marginTop: 12,
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: Palette.primary,
},
popularBadgeText: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 11,
color: Palette.white,
textTransform: 'uppercase',
},
packPrice: { packPrice: {
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18, fontSize: 18,
+3 -3
View File
@@ -122,7 +122,7 @@ export default ({
const handleCoinPress = React.useCallback(() => { const handleCoinPress = React.useCallback(() => {
if (!showCoinBadge) return if (!showCoinBadge) return
setCoinModalVisible(true) navigate(Routes.Payments)
}, [showCoinBadge]) }, [showCoinBadge])
const handleCloseCoinModal = React.useCallback(() => { const handleCloseCoinModal = React.useCallback(() => {
@@ -144,10 +144,10 @@ export default ({
}, [showCoinBadge, isCoinModalVisible]) }, [showCoinBadge, isCoinModalVisible])
React.useEffect(() => { React.useEffect(() => {
const unsubscribe = subscribeCoinPackModal(() => {
if (!showCoinBadge) { if (!showCoinBadge) {
return return undefined
} }
const unsubscribe = subscribeCoinPackModal(() => {
setCoinModalVisible(true) setCoinModalVisible(true)
}) })
return unsubscribe return unsubscribe
+2 -18
View File
@@ -10,7 +10,6 @@ import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
import GradientButton from '../../components/GradientButton' import GradientButton from '../../components/GradientButton'
import MoreMenu from '../../components/MoreMenu' import MoreMenu from '../../components/MoreMenu'
import ProjectDropDown from '../../components/ProjectDropDown/ProjectDropDown' import ProjectDropDown from '../../components/ProjectDropDown/ProjectDropDown'
import CoinPackModal from '../../components/modal/CoinPackModal'
import { projectsRef, usersRef } from '../../config/firebase' import { projectsRef, usersRef } from '../../config/firebase'
import { isWeb } from '../../hooks/useLayoutType.js' import { isWeb } from '../../hooks/useLayoutType.js'
import Page from '../../layouts/Page' import Page from '../../layouts/Page'
@@ -23,7 +22,6 @@ import ShareBtn from '../../components/ShareBtn/ShareBtn'
import { Palette, gutters } from '../../styles' import { Palette, gutters } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts' import { FONT_FAMILY } from '../../styles/Fonts'
import { getCreationStageStates, getStageAction } from '../../utils/projectStages' import { getCreationStageStates, getStageAction } from '../../utils/projectStages'
import { openCoinPackModal } from '../../utils/coinPackModal'
import useNavigateToMusicDetails from '../../hooks/useNavigateToMusicDetails' import useNavigateToMusicDetails from '../../hooks/useNavigateToMusicDetails'
import usePlayer from '../../hooks/usePlayer' import usePlayer from '../../hooks/usePlayer'
import StageCard from './components/StageCard' import StageCard from './components/StageCard'
@@ -407,8 +405,6 @@ const Home = ({ navigation, route }) => {
[stageStatesByKey] [stageStatesByKey]
) )
const [isCoinModalVisible, setCoinModalVisible] = useState(false)
const handleStagePress = useCallback( const handleStagePress = useCallback(
(stageKey, isLocked) => { (stageKey, isLocked) => {
if (isLocked) { if (isLocked) {
@@ -434,19 +430,11 @@ const Home = ({ navigation, route }) => {
) )
const handleClubPress = useCallback(() => { const handleClubPress = useCallback(() => {
navigate(Routes.Payments) navigate(Routes.HitParade)
}, []) }, [])
const handleOpenCoinModal = useCallback(() => { const handleOpenCoinModal = useCallback(() => {
if (isWeb) { navigate(Routes.Payments)
openCoinPackModal()
return
}
setCoinModalVisible(true)
}, [])
const handleCloseCoinModal = useCallback(() => {
setCoinModalVisible(false)
}, []) }, [])
const stageCardContainerStyle = isWeb ? styles.cardsGrid : styles.cardsStack const stageCardContainerStyle = isWeb ? styles.cardsGrid : styles.cardsStack
@@ -652,10 +640,6 @@ const Home = ({ navigation, route }) => {
</View> </View>
</Page> </Page>
</View> </View>
{!isWeb ? (
<CoinPackModal visible={isCoinModalVisible} onClose={handleCloseCoinModal} />
) : null}
</> </>
) )
} }
+10 -36
View File
@@ -6,7 +6,6 @@ import * as FileSystem from 'expo-file-system'
import { shareAsync } from 'expo-sharing' import { shareAsync } from 'expo-sharing'
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo' import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
import BorderGradientButton from '../../components/BorderGradientButton' import BorderGradientButton from '../../components/BorderGradientButton'
import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
import AppCheckbox from '../../components/AppCheckbox' import AppCheckbox from '../../components/AppCheckbox'
import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../../config/firebase' import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../../config/firebase'
@@ -178,7 +177,6 @@ const PlaybackDownload = ({ route }) => {
const downloadLabel = canDownloadPlayback const downloadLabel = canDownloadPlayback
? 'Télécharger le playback' ? 'Télécharger le playback'
: 'Acheter le playback pour 1,99€' : 'Acheter le playback pour 1,99€'
const showSubscriptionCta = !hasActiveSubscription
const afterPlaybackUrl = useMemo(() => { const afterPlaybackUrl = useMemo(() => {
if (!videos) return null if (!videos) return null
@@ -434,6 +432,9 @@ const PlaybackDownload = ({ route }) => {
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<CreateLyricsHeader title={'Publier ton playback'} /> <CreateLyricsHeader title={'Publier ton playback'} />
<ClubAdvantagesCard
style={styles.clubCardSpacing}
topContent={
<View style={styles.coverRow}> <View style={styles.coverRow}>
{projectForDownload?.coverUrl ? ( {projectForDownload?.coverUrl ? (
<ExpoImage <ExpoImage
@@ -447,6 +448,7 @@ const PlaybackDownload = ({ route }) => {
</View> </View>
)} )}
<View style={styles.downloadColumn}>
<Pressable <Pressable
style={[ style={[
styles.downloadTile, styles.downloadTile,
@@ -464,25 +466,9 @@ const PlaybackDownload = ({ route }) => {
</Text> </Text>
) : null} ) : null}
</View> </View>
{showSubscriptionCta ? (
<View style={styles.subscriptionCta}>
<Text style={styles.subscriptionNote}>
Avec un abonnement, le téléchargement est gratuit.
</Text>
<GradientButton
title="Voir les abonnements"
onPress={() => navigate(Routes.Payments)}
containerStyle={styles.subscriptionButton}
/>
</View> </View>
) : ( }
<Text style={styles.subscriptionNote}> />
Avec ton abonnement, le téléchargement est gratuit.
</Text>
)}
<ClubAdvantagesCard style={styles.clubCardSpacing} />
<View style={styles.publishConsentContainer}> <View style={styles.publishConsentContainer}>
<AppCheckbox <AppCheckbox
@@ -546,6 +532,10 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
color: Palette.white, color: Palette.white,
}, },
downloadColumn: {
alignItems: 'center',
gap: 4,
},
downloadPendingText: { downloadPendingText: {
marginTop: 6, marginTop: 6,
fontFamily: FONT_FAMILY.InterRegular, fontFamily: FONT_FAMILY.InterRegular,
@@ -553,22 +543,6 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
opacity: 0.8, opacity: 0.8,
}, },
subscriptionCta: {
alignItems: 'center',
gap: 10,
marginTop: 6,
},
subscriptionNote: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13,
color: Palette.white,
opacity: 0.8,
textAlign: 'center',
},
subscriptionButton: {
width: '100%',
maxWidth: 280,
},
clubCardSpacing: { clubCardSpacing: {
marginTop: 10, marginTop: 10,
}, },
@@ -10,6 +10,11 @@ import GradientButton from '../../../components/GradientButton'
import CreditAmount from '../../../components/CreditAmount' import CreditAmount from '../../../components/CreditAmount'
import { useStripe } from '../../../providers/StripeProvider' import { useStripe } from '../../../providers/StripeProvider'
import { isWeb } from '../../../hooks/useLayoutType' import { isWeb } from '../../../hooks/useLayoutType'
import {
COIN_PACK_DETAILS,
COIN_PACK_SECTION_TITLE,
getCoinPackKey,
} from '../../../utils/coinPackDisplay'
const formatCurrency = (amount, currency = 'eur') => { const formatCurrency = (amount, currency = 'eur') => {
if (typeof amount !== 'number') { if (typeof amount !== 'number') {
@@ -33,34 +38,7 @@ const formatCurrency = (amount, currency = 'eur') => {
return `${normalized.toFixed(2)} ${upperCurrency}` return `${normalized.toFixed(2)} ${upperCurrency}`
} }
const COIN_PACK_DETAILS = { const ClubAdvantagesCard = ({ style, topContent = null }) => {
starter: {
label: 'Pack starter',
creditsDisplay: '50 Crédits',
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
},
medium: {
label: 'Pack Medium',
creditsDisplay: '100 Crédits + 50 offerts',
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
},
premium: {
label: 'Pack Premium',
creditsDisplay: '150 Crédits + 100 offerts',
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
},
}
const getCoinPackKey = (pack) => {
const name = (pack?.name || '').toLowerCase()
if (name.includes('starter')) return 'starter'
if (name.includes('medium')) return 'medium'
if (name.includes('premium') || name.includes('prenium') || name.includes('creator'))
return 'premium'
return null
}
const ClubAdvantagesCard = ({ style }) => {
const navigation = useNavigation() const navigation = useNavigation()
const { hasActiveSubscription } = useUserData() || {} const { hasActiveSubscription } = useUserData() || {}
const { coinPacks, createCoinPackCheckout } = useStripe() const { coinPacks, createCoinPackCheckout } = useStripe()
@@ -91,16 +69,15 @@ const ClubAdvantagesCard = ({ style }) => {
> >
<View style={styles.header}> <View style={styles.header}>
<View style={styles.titleBlock}> <View style={styles.titleBlock}>
<Text style={{fontSize: 30, fontWeight: 'bold', color: Palette.white, marginBottom: 12}}> {topContent ? <View style={styles.topContentContainer}>{topContent}</View> : null}
OU <View style={styles.topDivider} />
</Text>
<Text style={styles.title}> <Text style={styles.title}>
{'Les abonnements et les packs permettent le téléchargement de vos créations'} {'Les abonnements et les packs permettent le téléchargement de vos créations'}
</Text> </Text>
<Text style={styles.subtitle}>{'Avec un abonnement le téléchargement est gratuit'}</Text> <Text style={styles.subtitle}>{'Avec un abonnement le téléchargement est gratuit'}</Text>
{coinPacks && coinPacks.length > 0 ? ( {coinPacks && coinPacks.length > 0 ? (
<View style={styles.sectionContainer}> <View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>Recharger vos crédits</Text> <Text style={styles.sectionTitle}>{COIN_PACK_SECTION_TITLE}</Text>
<View style={styles.creditsGrid}> <View style={styles.creditsGrid}>
{coinPacks.map((pack) => { {coinPacks.map((pack) => {
const rawAmount = pack.unitAmount ?? pack.amount ?? pack.price ?? 0 const rawAmount = pack.unitAmount ?? pack.amount ?? pack.price ?? 0
@@ -110,6 +87,8 @@ const ClubAdvantagesCard = ({ style }) => {
const details = packKey ? COIN_PACK_DETAILS[packKey] : null const details = packKey ? COIN_PACK_DETAILS[packKey] : null
const displayName = details?.label || pack.name const displayName = details?.label || pack.name
const creditsText = details?.creditsDisplay const creditsText = details?.creditsDisplay
const displayPrice = details?.priceDisplay || price || `${rawAmount / 100}`
const isPopular = details?.popular === true
return ( return (
<View key={pack.productId} style={styles.creditCard}> <View key={pack.productId} style={styles.creditCard}>
@@ -128,12 +107,13 @@ const ClubAdvantagesCard = ({ style }) => {
<Text style={styles.packDisclaimer}>{details.disclaimer}</Text> <Text style={styles.packDisclaimer}>{details.disclaimer}</Text>
) : null} ) : null}
</View> </View>
{isPopular ? (
<View style={styles.popularBadge}>
<Text style={styles.popularBadgeText}>Le plus populaire !</Text>
</View>
) : null}
<GradientButton <GradientButton
title={ title={processingPriceId === pack.productId ? '...' : displayPrice}
processingPriceId === pack.productId
? '...'
: price || `${rawAmount / 100}`
}
onPress={() => handleBuyCredits(pack)} onPress={() => handleBuyCredits(pack)}
disabled={Boolean(processingPriceId)} disabled={Boolean(processingPriceId)}
style={styles.creditButton} style={styles.creditButton}
@@ -179,6 +159,17 @@ const styles = StyleSheet.create({
gap: gutters * 0.3, gap: gutters * 0.3,
alignItems: 'center', alignItems: 'center',
}, },
topContentContainer: {
width: '100%',
alignItems: 'center',
marginBottom: gutters * 0.4,
},
topDivider: {
width: '100%',
height: 1,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
marginBottom: gutters * 0.8,
},
title: { title: {
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18, fontSize: 18,
@@ -196,8 +187,6 @@ const styles = StyleSheet.create({
gap: 16, gap: 16,
width: '100%', width: '100%',
alignItems: 'center', alignItems: 'center',
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.08)',
paddingTop: gutters, paddingTop: gutters,
marginTop: gutters, marginTop: gutters,
paddingBottom: gutters, paddingBottom: gutters,
@@ -221,7 +210,7 @@ const styles = StyleSheet.create({
borderRadius: 16, borderRadius: 16,
padding: 16, padding: 16,
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'flex-start',
width: isWeb ? 220 : '100%', width: isWeb ? 220 : '100%',
minHeight: 160, minHeight: 160,
gap: 12, gap: 12,
@@ -257,6 +246,7 @@ const styles = StyleSheet.create({
marginTop: 4, marginTop: 4,
}, },
creditButton: { creditButton: {
marginTop: 'auto',
width: '100%', width: '100%',
height: 36, height: 36,
minHeight: 36, minHeight: 36,
@@ -264,6 +254,20 @@ const styles = StyleSheet.create({
creditButtonText: { creditButtonText: {
fontSize: 13, fontSize: 13,
}, },
popularBadge: {
alignSelf: 'center',
marginTop: 12,
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: Palette.primary,
},
popularBadgeText: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 11,
color: Palette.white,
textTransform: 'uppercase',
},
errorText: { errorText: {
color: Palette.red, color: Palette.red,
textAlign: 'center', textAlign: 'center',
+82 -189
View File
@@ -7,7 +7,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
import GradientButton from '../components/GradientButton' import GradientButton from '../components/GradientButton'
import CreditAmount from '../components/CreditAmount' import CreditAmount from '../components/CreditAmount'
import Page from '../layouts/Page' import Page from '../layouts/Page'
import { background, icons, planBadges, subBadges } from '../assets' import { background, icons, planBadges } from '../assets'
import { Palette, gutters } from '../styles' import { Palette, gutters } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts' import { FONT_FAMILY } from '../styles/Fonts'
import { isWeb } from '../hooks/useLayoutType' import { isWeb } from '../hooks/useLayoutType'
@@ -15,6 +15,18 @@ import { useStripe } from '../providers/StripeProvider'
import { useUserData } from '../providers/UserDataProvider' import { useUserData } from '../providers/UserDataProvider'
import { goBack, navigate } from '../navigation/NavigationService' import { goBack, navigate } from '../navigation/NavigationService'
import { Routes } from '../navigation/Routes' import { Routes } from '../navigation/Routes'
import {
COIN_PACK_DETAILS,
COIN_PACK_SECTION_TITLE,
getCoinPackKey,
} from '../utils/coinPackDisplay'
import {
getSubscriptionCardDisplay,
getSubscriptionPlanKey,
SUBSCRIPTION_PLAN_ORDER,
SUBSCRIPTION_PLAN_SYNONYMS,
SUBSCRIPTION_PRICE_ID_BY_PERIOD,
} from '../utils/subscriptionCardDisplay'
const formatCurrency = (amount, currency = 'eur') => { const formatCurrency = (amount, currency = 'eur') => {
if (typeof amount !== 'number') { if (typeof amount !== 'number') {
@@ -63,21 +75,22 @@ const getIntervalLabel = (recurring) => {
} }
function SubscriptionCard({ plan, selected, onSelect, isAnnual }) { function SubscriptionCard({ plan, selected, onSelect, isAnnual }) {
const planKey = getPlanKeyForBadge(plan) const planKey = getSubscriptionPlanKey(plan)
const planName =
(planKey && PLAN_DISPLAY_NAME_BY_KEY[planKey]) ||
plan?.product?.name ||
plan?.nickname ||
plan?.priceId ||
'Abonnement'
const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency) const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency)
const intervalLabel = getIntervalLabel(plan?.recurring) const intervalLabel = getIntervalLabel(plan?.recurring)
const coinsPerMonth = const coinsPerMonth =
typeof plan?.coinsPerMonth === 'number' && Number.isFinite(plan.coinsPerMonth) typeof plan?.coinsPerMonth === 'number' && Number.isFinite(plan.coinsPerMonth)
? Math.round(plan.coinsPerMonth) ? Math.round(plan.coinsPerMonth)
: null : null
const details = planKey ? PLAN_DETAILS[planKey] : null const cardDisplay = getSubscriptionCardDisplay({
const isPopular = details?.popular planKey,
isAnnual,
formattedPrice,
intervalLabel,
coinsPerMonth,
fallbackTitle: plan?.product?.name || plan?.nickname || plan?.priceId || 'Abonnement',
})
const isPopular = cardDisplay.popular
const planBadgeKey = planKey const planBadgeKey = planKey
const getPlanBadge = (plan) => { const getPlanBadge = (plan) => {
@@ -92,7 +105,6 @@ function SubscriptionCard({ plan, selected, onSelect, isAnnual }) {
return null return null
} }
} }
const planBadgeSource = planBadgeKey && subBadges[planBadgeKey] ? subBadges[planBadgeKey] : null
const handleSelect = React.useCallback(() => { const handleSelect = React.useCallback(() => {
if (typeof onSelect === 'function' && plan?.priceId) { if (typeof onSelect === 'function' && plan?.priceId) {
onSelect(plan.priceId) onSelect(plan.priceId)
@@ -124,61 +136,41 @@ function SubscriptionCard({ plan, selected, onSelect, isAnnual }) {
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<View style={styles.titleRow}> <View style={styles.titleRow}>
<Text style={styles.planName}>{planName}</Text> <Text style={styles.planName}>{cardDisplay.title}</Text>
{getPlanBadge(planKey)} {getPlanBadge(planKey)}
</View> </View>
{plan?.nickname ? <Text style={styles.cardSubtitle}>{plan.nickname}</Text> : null}
</View> </View>
{formattedPrice || coinsPerMonth !== null ? ( {cardDisplay.primaryPrice || cardDisplay.creditsLabel ? (
<View style={styles.priceBlock}> <View style={styles.priceBlock}>
{formattedPrice ? ( {cardDisplay.primaryPrice ? (
<View style={styles.priceRow}> <Text style={styles.priceValue}>{cardDisplay.primaryPrice}</Text>
<Text style={styles.priceValue}>{formattedPrice}</Text>
{intervalLabel ? <Text style={styles.period}>{intervalLabel}</Text> : null}
</View>
) : null} ) : null}
{cardDisplay.secondaryPrice ? (
{details?.creditsLabel ? ( <Text style={styles.secondaryPriceText}>{cardDisplay.secondaryPrice}</Text>
<Text style={styles.coinsPromoText}>{details.creditsLabel}</Text> ) : null}
) : coinsPerMonth !== null ? ( {cardDisplay.creditsLabel ? (
<View style={styles.coinsPerMonthRow}> <Text style={styles.coinsPromoText}>{cardDisplay.creditsLabel}</Text>
<CreditAmount
value={coinsPerMonth}
showPlus
textStyle={styles.coinsPerMonth}
iconSize={16}
accessibilityLabel={`+${coinsPerMonth} crédits par mois`}
/>
<Text style={styles.coinsPerMonthSuffix}>/ mois</Text>
</View>
) : null} ) : null}
</View> </View>
) : null} ) : null}
{Array.isArray(cardDisplay.benefitItems) && cardDisplay.benefitItems.length ? (
<View style={styles.cardBenefits}> <View style={styles.cardBenefits}>
<Text style={styles.benefitsTitle}>Privilège Membre :</Text> {cardDisplay.benefitItems.map((item, index) =>
{details?.privileges?.map((item, index) => ( item?.type === 'plus' ? (
<View key={index} style={styles.benefitBlock}> <Text key={`benefit-plus-${index}`} style={styles.benefitPlus}>
<Text style={[styles.benefitText, item.bold && styles.benefitTextBold]}> {item.text}
{item.bold ? `${item.text}` : item.text}
</Text> </Text>
{item.subItems ? ( ) : (
<View style={styles.benefitSubList}> <Text key={`benefit-text-${index}`} style={styles.downloadLabel}>
<Text style={styles.benefitSubText}>{item.subItems.join(' - ')}</Text> {item.text}
</View>
) : null}
</View>
)) || (
<>
<Text style={styles.cardBenefitsItem}>
Eligible au concours mensuel chanson/Vidéo
</Text> </Text>
<Text style={styles.cardBenefitsItem}> Crédits gratuits tous les mois</Text> )
</>
)} )}
</View> </View>
) : null}
{isPopular ? ( {isPopular ? (
<View style={styles.popularBadge}> <View style={styles.popularBadge}>
@@ -187,11 +179,6 @@ function SubscriptionCard({ plan, selected, onSelect, isAnnual }) {
) : null} ) : null}
</View> </View>
</BlurView> </BlurView>
{isAnnual ? (
<View style={styles.annualBadge}>
<Text style={styles.annualBadgeText}>2 mois offert</Text>
</View>
) : null}
</Pressable> </Pressable>
) )
} }
@@ -209,125 +196,6 @@ const PRICE_PRIORITY_BY_PERIOD = {
], ],
} }
const PACK_PRICE_ID_BY_PERIOD = {
monthly: {
starter: 'price_1SPgitCzf2o5bDRdbnhLFx6f',
pro: 'price_1SPgjCCzf2o5bDRdr08Xzp8u',
premium: 'price_1SPgjaCzf2o5bDRdd9Xo2u26',
},
annual: {
starter: 'price_1SPgkDCzf2o5bDRdNGLVNeQ3',
pro: 'price_1SPgkXCzf2o5bDRdejBVxEBY',
premium: 'price_1SPgkqCzf2o5bDRdIcUwTDrm',
},
}
const PRICE_ID_TO_PLAN_KEY = {}
Object.values(PACK_PRICE_ID_BY_PERIOD).forEach((mapping) => {
Object.entries(mapping).forEach(([planKey, priceId]) => {
if (!priceId) {
return
}
PRICE_ID_TO_PLAN_KEY[priceId] = planKey
})
})
const PLAN_FALLBACK_ORDER = ['starter', 'pro', 'premium']
const PLAN_SYNONYMS = {
starter: ['starter'],
pro: ['pro'],
premium: ['premium'],
}
const PLAN_DISPLAY_NAME_BY_KEY = {
starter: 'Play Backer Blue',
pro: 'Play Backer Silver',
premium: 'Play Backer Gold',
}
const COIN_PACK_DETAILS = {
starter: {
label: 'Pack starter',
creditsDisplay: '50 Crédits',
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
},
medium: {
label: 'Pack Medium',
creditsDisplay: '100 Crédits + 50 offerts',
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
},
premium: {
label: 'Pack Premium',
creditsDisplay: '150 Crédits + 100 offerts',
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
},
}
const getCoinPackKey = (pack) => {
const name = (pack?.name || '').toLowerCase()
if (name.includes('starter')) return 'starter'
if (name.includes('medium')) return 'medium'
if (name.includes('premium') || name.includes('prenium') || name.includes('creator'))
return 'premium'
return null
}
const PLAN_DETAILS = {
starter: {
creditsLabel: '50 Crédits / Mois',
popular: false,
privileges: [
{ text: 'Eligible au concours mensuel chanson/Vidéo', bold: true },
{ text: 'Catégorie Chanson :', subItems: ['Top 1: 500 €', 'Top 2: 250 €', 'Top 3: 125 €'] },
{ text: 'Catégorie Vidéo :', subItems: ['Top 1: 1000 €', 'Top 2: 500 €', 'Top 3: 250 €'] },
],
},
pro: {
creditsLabel: '100 crédits + 50 offerts / Mois',
popular: true,
privileges: [
{ text: 'Eligible au concours mensuel chanson/Vidéo', bold: true },
{ text: 'Catégorie Chanson :', subItems: ['Top 1: 500 €', 'Top 2: 250 €', 'Top 3: 125 €'] },
{ text: 'Catégorie Vidéo :', subItems: ['Top 1: 1000 €', 'Top 2: 500 €', 'Top 3: 250 €'] },
],
},
premium: {
creditsLabel: '150 Crédits + 100 offerts / Mois',
popular: false,
privileges: [
{ text: 'Eligible au concours mensuel chanson/Vidéo', bold: true },
{ text: 'Catégorie Chanson :', subItems: ['Top 1: 500 €', 'Top 2: 250 €', 'Top 3: 125 €'] },
{ text: 'Catégorie Vidéo :', subItems: ['Top 1: 1000 €', 'Top 2: 500 €', 'Top 3: 250 €'] },
],
},
}
const getPlanKeyForBadge = (plan) => {
if (!plan || typeof plan !== 'object') {
return null
}
const priceId = typeof plan?.priceId === 'string' ? plan.priceId : plan?.id || null
if (priceId && PRICE_ID_TO_PLAN_KEY[priceId]) {
return PRICE_ID_TO_PLAN_KEY[priceId]
}
const label = (plan?.product?.name || plan?.nickname || '').toString().toLowerCase()
if (!label) {
return null
}
const match = Object.entries(PLAN_SYNONYMS).find(([, variants]) =>
variants.some((variant) => label.includes(variant))
)
return match ? match[0] : null
}
const getPlanPriority = (plan, periodKey) => { const getPlanPriority = (plan, periodKey) => {
const priceId = typeof plan?.priceId === 'string' ? plan.priceId : plan?.id || null const priceId = typeof plan?.priceId === 'string' ? plan.priceId : plan?.id || null
const priceOrder = PRICE_PRIORITY_BY_PERIOD[periodKey] || [] const priceOrder = PRICE_PRIORITY_BY_PERIOD[periodKey] || []
@@ -341,13 +209,15 @@ const getPlanPriority = (plan, periodKey) => {
const label = (plan?.product?.name || plan?.nickname || '').toString().toLowerCase() const label = (plan?.product?.name || plan?.nickname || '').toString().toLowerCase()
const fallbackIndex = PLAN_FALLBACK_ORDER.findIndex((candidate) => { const fallbackIndex = SUBSCRIPTION_PLAN_ORDER.findIndex((candidate) => {
const variants = PLAN_SYNONYMS[candidate] || [candidate] const variants = SUBSCRIPTION_PLAN_SYNONYMS[candidate] || [candidate]
return variants.some((variant) => label.includes(variant)) return variants.some((variant) => label.includes(variant))
}) })
const baseOffset = priceOrder.length const baseOffset = priceOrder.length
return fallbackIndex === -1 ? baseOffset + PLAN_FALLBACK_ORDER.length : baseOffset + fallbackIndex return fallbackIndex === -1
? baseOffset + SUBSCRIPTION_PLAN_ORDER.length
: baseOffset + fallbackIndex
} }
const normalizePlans = (plans = [], periodKey) => { const normalizePlans = (plans = [], periodKey) => {
@@ -390,7 +260,7 @@ export default function Subscriptions() {
createCoinPackCheckout, createCoinPackCheckout,
} = useStripe() } = useStripe()
const { currentUserData } = useUserData() const { currentUserData } = useUserData()
const [selectedPeriodKey, setSelectedPeriodKey] = React.useState('annual') const [selectedPeriodKey, setSelectedPeriodKey] = React.useState('monthly')
const [selectedPriceId, setSelectedPriceId] = React.useState(null) const [selectedPriceId, setSelectedPriceId] = React.useState(null)
const [processingPriceId, setProcessingPriceId] = React.useState(null) const [processingPriceId, setProcessingPriceId] = React.useState(null)
const [errorMessage, setErrorMessage] = React.useState(null) const [errorMessage, setErrorMessage] = React.useState(null)
@@ -438,13 +308,13 @@ export default function Subscriptions() {
let nextPeriodKey = selectedPeriodKey let nextPeriodKey = selectedPeriodKey
// Only force switch if current selected period is empty and another is not // Only force switch if current selected period is empty and another is not
if ((normalizedPlansByPeriod[selectedPeriodKey]?.length || 0) === 0) { if ((normalizedPlansByPeriod[selectedPeriodKey]?.length || 0) === 0) {
nextPeriodKey = periodEntries[0]?.[0] || 'annual' nextPeriodKey = periodEntries[0]?.[0] || 'monthly'
} }
if (shouldApplyPack) { if (shouldApplyPack) {
const desired = initialSubscriptionPack.trim().toLowerCase() const desired = initialSubscriptionPack.trim().toLowerCase()
for (const [periodKey, plans] of periodEntries) { for (const [periodKey, plans] of periodEntries) {
const candidateId = PACK_PRICE_ID_BY_PERIOD?.[periodKey]?.[desired] const candidateId = SUBSCRIPTION_PRICE_ID_BY_PERIOD?.[periodKey]?.[desired]
if (candidateId) { if (candidateId) {
const exists = plans.some((plan) => plan?.priceId === candidateId) const exists = plans.some((plan) => plan?.priceId === candidateId)
if (exists) { if (exists) {
@@ -772,7 +642,7 @@ export default function Subscriptions() {
{coinPacks && coinPacks.length > 0 ? ( {coinPacks && coinPacks.length > 0 ? (
<View style={styles.creditsSection}> <View style={styles.creditsSection}>
<Text style={styles.sectionTitle}>Recharger vos crédits</Text> <Text style={styles.sectionTitle}>{COIN_PACK_SECTION_TITLE}</Text>
<View style={styles.creditsGrid}> <View style={styles.creditsGrid}>
{coinPacks.map((pack) => { {coinPacks.map((pack) => {
// Attempt to find amount in commonly used Stripe fields // Attempt to find amount in commonly used Stripe fields
@@ -785,6 +655,8 @@ export default function Subscriptions() {
const details = packKey ? COIN_PACK_DETAILS[packKey] : null const details = packKey ? COIN_PACK_DETAILS[packKey] : null
const displayName = details?.label || pack.name const displayName = details?.label || pack.name
const creditsText = details?.creditsDisplay const creditsText = details?.creditsDisplay
const displayPrice = details?.priceDisplay || price || `${rawAmount / 100}`
const isPopular = details?.popular === true
return ( return (
<View key={pack.productId} style={styles.creditCard}> <View key={pack.productId} style={styles.creditCard}>
@@ -803,12 +675,13 @@ export default function Subscriptions() {
<Text style={styles.packDisclaimer}>{details.disclaimer}</Text> <Text style={styles.packDisclaimer}>{details.disclaimer}</Text>
) : null} ) : null}
</View> </View>
{isPopular ? (
<View style={styles.popularBadge}>
<Text style={styles.popularBadgeText}>Le plus populaire !</Text>
</View>
) : null}
<GradientButton <GradientButton
title={ title={processingPriceId === pack.productId ? '...' : displayPrice}
processingPriceId === pack.productId
? '...'
: price || `${rawAmount / 100}`
}
onPress={() => handleBuyCredits(pack)} onPress={() => handleBuyCredits(pack)}
disabled={Boolean(processingPriceId)} disabled={Boolean(processingPriceId)}
style={styles.creditButton} style={styles.creditButton}
@@ -933,6 +806,19 @@ const styles = StyleSheet.create({
paddingTop: 12, paddingTop: 12,
gap: 6, gap: 6,
}, },
downloadLabel: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: Palette.white,
textAlign: 'center',
},
benefitPlus: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 22,
color: Palette.white,
textAlign: 'center',
lineHeight: 24,
},
cardBenefitsItem: { cardBenefitsItem: {
fontFamily: FONT_FAMILY.InterMedium, fontFamily: FONT_FAMILY.InterMedium,
fontSize: 12, fontSize: 12,
@@ -1008,7 +894,7 @@ const styles = StyleSheet.create({
borderRadius: 16, borderRadius: 16,
padding: 16, padding: 16,
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'flex-start',
width: isWeb ? 200 : '45%', width: isWeb ? 200 : '45%',
gap: 12, gap: 12,
borderWidth: 1, borderWidth: 1,
@@ -1043,6 +929,7 @@ const styles = StyleSheet.create({
fontSize: 20, fontSize: 20,
}, },
creditButton: { creditButton: {
marginTop: 'auto',
width: '100%', width: '100%',
height: 36, height: 36,
minHeight: 36, minHeight: 36,
@@ -1207,11 +1094,12 @@ const styles = StyleSheet.create({
paddingHorizontal: gutters, paddingHorizontal: gutters,
paddingVertical: isWeb ? gutters : Math.max(gutters * 0.2, 10), paddingVertical: isWeb ? gutters : Math.max(gutters * 0.2, 10),
gap: isWeb ? 18 : 10, gap: isWeb ? 18 : 10,
justifyContent: 'center', justifyContent: 'flex-start',
backgroundColor: 'rgba(48, 52, 56, 0.55)', backgroundColor: 'rgba(48, 52, 56, 0.55)',
borderRadius: 24, borderRadius: 24,
}, },
cardContent: { cardContent: {
flex: 1,
gap: isWeb ? 16 : 4, gap: isWeb ? 16 : 4,
}, },
cardHeader: { cardHeader: {
@@ -1244,6 +1132,11 @@ const styles = StyleSheet.create({
priceBlock: { priceBlock: {
gap: isWeb ? 4 : 1, gap: isWeb ? 4 : 1,
}, },
secondaryPriceText: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 16,
color: Palette.primary,
},
priceRow: { priceRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'baseline', alignItems: 'baseline',
@@ -1289,7 +1182,7 @@ const styles = StyleSheet.create({
}, },
popularBadge: { popularBadge: {
alignSelf: 'center', alignSelf: 'center',
marginTop: 12, marginTop: 'auto',
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 4, paddingVertical: 4,
borderRadius: 999, borderRadius: 999,
+22 -27
View File
@@ -112,27 +112,37 @@ const SongDownload = ({ route }) => {
const canDownloadDirectly = hasActiveSubscription || hasPaidDownload || hasPurchased const canDownloadDirectly = hasActiveSubscription || hasPaidDownload || hasPurchased
const downloadLabel = canDownloadDirectly const downloadLabel = canDownloadDirectly
? 'Télécharger mon morceau' ? 'Télécharger mon morceau'
: 'Acheter ce morceau pour 1,99€' : 'Télécharger ce morceau pour 1,99€'
const showSubscriptionCta = !hasActiveSubscription
const shouldShowDownloadPage = adventureChoice === 'stop' const shouldShowDownloadPage = adventureChoice === 'stop'
const reopenAdventureChoice = useCallback(() => {
reopenAdventureModalOnFocusRef.current = false
setShowIntro(null)
setAdventureChoice('pending')
setShowAdventureModal(true)
}, [])
const handleBackPress = useCallback(() => { const handleBackPress = useCallback(() => {
if (shouldShowDownloadPage) {
reopenAdventureChoice()
return true
}
if (backRoute) { if (backRoute) {
navigate(backRoute) navigate(backRoute)
return true return true
} }
goBack() goBack()
return true return true
}, [backRoute]) }, [backRoute, reopenAdventureChoice, shouldShowDownloadPage])
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
if (Platform.OS !== 'android' || !backRoute) { if (Platform.OS !== 'android' || (!backRoute && !shouldShowDownloadPage)) {
return undefined return undefined
} }
const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress) const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress)
return () => subscription.remove() return () => subscription.remove()
}, [backRoute, handleBackPress]) }, [backRoute, handleBackPress, shouldShowDownloadPage])
) )
useFocusEffect( useFocusEffect(
@@ -512,8 +522,8 @@ const SongDownload = ({ route }) => {
} }
if (promptPurchaseConfirm) { if (promptPurchaseConfirm) {
Alert.alert( Alert.alert(
'Acheter ce morceau', 'Télécharger ce morceau',
'Voulez-vous acheter ce morceau pour 1,99€ ?', 'Voulez-vous télécharger ce morceau pour 1,99€ ?',
[ [
{ text: 'Annuler', style: 'cancel' }, { text: 'Annuler', style: 'cancel' },
{ text: 'Acheter', onPress: () => startSongDownloadCheckout() }, { text: 'Acheter', onPress: () => startSongDownloadCheckout() },
@@ -569,6 +579,9 @@ const SongDownload = ({ route }) => {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<CreateLyricsHeader title="Ta pochette est validée" /> <CreateLyricsHeader title="Ta pochette est validée" />
<ClubAdvantagesCard
style={styles.clubCardSpacing}
topContent={
<View style={styles.coverRow}> <View style={styles.coverRow}>
{coverUrl ? ( {coverUrl ? (
<ExpoImage source={{ uri: coverUrl }} style={styles.coverImage} contentFit="cover" /> <ExpoImage source={{ uri: coverUrl }} style={styles.coverImage} contentFit="cover" />
@@ -597,10 +610,8 @@ const SongDownload = ({ route }) => {
) : null} ) : null}
</View> </View>
</View> </View>
}
/>
<ClubAdvantagesCard style={styles.clubCardSpacing} />
<GradientButton <GradientButton
title="Revenir à l'accueil" title="Revenir à l'accueil"
onPress={handleGoHome} onPress={handleGoHome}
@@ -695,22 +706,6 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
opacity: 0.8, opacity: 0.8,
}, },
subscriptionCta: {
alignItems: 'center',
gap: gutters * 0.6,
marginTop: gutters * 0.4,
},
subscriptionNote: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13,
color: Palette.white,
opacity: 0.8,
textAlign: 'center',
},
subscriptionButton: {
width: '100%',
maxWidth: 280,
},
adventureGatePlaceholder: { adventureGatePlaceholder: {
flex: 1, flex: 1,
}, },
@@ -20,11 +20,20 @@ import { useStripe } from '../../../providers/StripeProvider'
import CreditAmount from '../../../components/CreditAmount' import CreditAmount from '../../../components/CreditAmount'
import GradientButton from '../../../components/GradientButton' import GradientButton from '../../../components/GradientButton'
import AppCheckbox from '../../../components/AppCheckbox' import AppCheckbox from '../../../components/AppCheckbox'
import { subBadges, icons, planBadges } from '../../../assets' import { icons, planBadges } from '../../../assets'
import { isWeb } from '../../../hooks/useLayoutType' import { isWeb } from '../../../hooks/useLayoutType'
import { navigate } from '../../../navigation/NavigationService' import { navigate } from '../../../navigation/NavigationService'
import { Routes } from '../../../navigation' import { Routes } from '../../../navigation'
import { musiclandShareHeading } from '../../../data' import { musiclandShareHeading } from '../../../data'
import {
COIN_PACK_DETAILS,
COIN_PACK_SECTION_TITLE,
getCoinPackKey,
} from '../../../utils/coinPackDisplay'
import {
getSubscriptionCardDisplay,
getSubscriptionPlanKey,
} from '../../../utils/subscriptionCardDisplay'
// --- HELPER FUNCTIONS & CONSTANTS (Copied from Subscriptions.js) --- // --- HELPER FUNCTIONS & CONSTANTS (Copied from Subscriptions.js) ---
@@ -69,122 +78,25 @@ const getIntervalLabel = (recurring) => {
return `tous les ${count} ${count > 1 ? terms.plural : terms.singular}` return `tous les ${count} ${count > 1 ? terms.plural : terms.singular}`
} }
const PLAN_SYNONYMS = {
starter: ['starter'],
pro: ['pro'],
premium: ['premium'],
}
const PLAN_DISPLAY_NAME_BY_KEY = {
starter: 'Play Backer Blue',
pro: 'Play Backer Silver',
premium: 'Play Backer Gold',
}
const PACK_PRICE_ID_BY_PERIOD = {
monthly: {
starter: 'price_1SPgitCzf2o5bDRdbnhLFx6f',
pro: 'price_1SPgjCCzf2o5bDRdr08Xzp8u',
premium: 'price_1SPgjaCzf2o5bDRdd9Xo2u26',
},
annual: {
starter: 'price_1SPgkDCzf2o5bDRdNGLVNeQ3',
pro: 'price_1SPgkXCzf2o5bDRdejBVxEBY',
premium: 'price_1SPgkqCzf2o5bDRdIcUwTDrm',
},
}
const PRICE_ID_TO_PLAN_KEY = {}
Object.values(PACK_PRICE_ID_BY_PERIOD).forEach((mapping) => {
Object.entries(mapping).forEach(([planKey, priceId]) => {
if (!priceId) return
PRICE_ID_TO_PLAN_KEY[priceId] = planKey
})
})
const PLAN_DETAILS = {
starter: {
creditsLabel: '50 Crédits / Mois',
popular: false,
privileges: [
{ text: 'Eligible au concours mensuel chanson/Vidéo', bold: true },
],
},
pro: {
creditsLabel: '100 crédits + 50 offerts / Mois',
popular: true,
privileges: [
{ text: 'Eligible au concours mensuel chanson/Vidéo', bold: true },
],
},
premium: {
creditsLabel: '150 Crédits + 100 offerts / Mois',
popular: false,
privileges: [
{ text: 'Eligible au concours mensuel chanson/Vidéo', bold: true },
],
},
}
const COIN_PACK_DETAILS = {
starter: {
label: 'Pack starter',
creditsDisplay: '50 Crédits',
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
},
medium: {
label: 'Pack Medium',
creditsDisplay: '100 Crédits + 50 offerts',
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
},
premium: {
label: 'Pack Premium',
creditsDisplay: '150 Crédits + 100 offerts',
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
},
}
const getCoinPackKey = (pack) => {
const name = (pack?.name || '').toLowerCase()
if (name.includes('starter')) return 'starter'
if (name.includes('medium')) return 'medium'
if (name.includes('premium') || name.includes('prenium') || name.includes('creator'))
return 'premium'
return null
}
const getPlanKeyForBadge = (plan) => {
if (!plan || typeof plan !== 'object') return null
const priceId = typeof plan?.priceId === 'string' ? plan.priceId : plan?.id || null
if (priceId && PRICE_ID_TO_PLAN_KEY[priceId]) {
return PRICE_ID_TO_PLAN_KEY[priceId]
}
const label = (plan?.product?.name || plan?.nickname || '').toString().toLowerCase()
if (!label) return null
const match = Object.entries(PLAN_SYNONYMS).find(([, variants]) =>
variants.some((variant) => label.includes(variant))
)
return match ? match[0] : null
}
// --- SUBSCRIPTION CARD COMPONENT (Copied) --- // --- SUBSCRIPTION CARD COMPONENT (Copied) ---
function SubscriptionCard({ plan, selected, onSelect, isAnnual }) { function SubscriptionCard({ plan, selected, onSelect, isAnnual }) {
const planKey = getPlanKeyForBadge(plan) const planKey = getSubscriptionPlanKey(plan)
const planName =
(planKey && PLAN_DISPLAY_NAME_BY_KEY[planKey]) ||
plan?.product?.name ||
plan?.nickname ||
plan?.priceId ||
'Abonnement'
const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency) const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency)
const intervalLabel = getIntervalLabel(plan?.recurring) const intervalLabel = getIntervalLabel(plan?.recurring)
const coinsPerMonth = const coinsPerMonth =
typeof plan?.coinsPerMonth === 'number' && Number.isFinite(plan.coinsPerMonth) typeof plan?.coinsPerMonth === 'number' && Number.isFinite(plan.coinsPerMonth)
? Math.round(plan.coinsPerMonth) ? Math.round(plan.coinsPerMonth)
: null : null
const details = planKey ? PLAN_DETAILS[planKey] : null const cardDisplay = getSubscriptionCardDisplay({
const isPopular = details?.popular planKey,
isAnnual,
formattedPrice,
intervalLabel,
coinsPerMonth,
fallbackTitle: plan?.product?.name || plan?.nickname || plan?.priceId || 'Abonnement',
})
const isPopular = cardDisplay.popular
const planBadgeKey = planKey const planBadgeKey = planKey
const planBadgeSource = planBadgeKey && planBadges[planBadgeKey] ? planBadges[planBadgeKey] : null const planBadgeSource = planBadgeKey && planBadges[planBadgeKey] ? planBadges[planBadgeKey] : null
@@ -217,7 +129,7 @@ function SubscriptionCard({ plan, selected, onSelect, isAnnual }) {
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<View style={styles.titleRow}> <View style={styles.titleRow}>
<Text style={styles.planName}>{planName}</Text> <Text style={styles.planName}>{cardDisplay.title}</Text>
{planBadgeSource ? ( {planBadgeSource ? (
<ExpoImage <ExpoImage
source={planBadgeSource} source={planBadgeSource}
@@ -226,46 +138,37 @@ function SubscriptionCard({ plan, selected, onSelect, isAnnual }) {
/> />
) : null} ) : null}
</View> </View>
{plan?.nickname ? <Text style={styles.cardSubtitle}>{plan.nickname}</Text> : null}
</View> </View>
{formattedPrice || coinsPerMonth !== null ? ( {cardDisplay.primaryPrice || cardDisplay.creditsLabel ? (
<View style={styles.priceBlock}> <View style={styles.priceBlock}>
{formattedPrice ? ( {cardDisplay.primaryPrice ? (
<View style={styles.priceRow}> <Text style={styles.priceValue}>{cardDisplay.primaryPrice}</Text>
<Text style={styles.priceValue}>{formattedPrice}</Text>
{intervalLabel ? <Text style={styles.period}>{intervalLabel}</Text> : null}
</View>
) : null} ) : null}
{cardDisplay.secondaryPrice ? (
{details?.creditsLabel ? ( <Text style={styles.secondaryPriceText}>{cardDisplay.secondaryPrice}</Text>
<Text style={styles.coinsPromoText}>{details.creditsLabel}</Text> ) : null}
) : coinsPerMonth !== null ? ( {cardDisplay.creditsLabel ? (
<View style={styles.coinsPerMonthRow}> <Text style={styles.coinsPromoText}>{cardDisplay.creditsLabel}</Text>
<CreditAmount
value={coinsPerMonth}
showPlus
textStyle={styles.coinsPerMonth}
iconSize={16}
/>
<Text style={styles.coinsPerMonthSuffix}>/ mois</Text>
</View>
) : null} ) : null}
</View> </View>
) : null} ) : null}
{Array.isArray(cardDisplay.benefitItems) && cardDisplay.benefitItems.length ? (
<View style={styles.cardBenefits}> <View style={styles.cardBenefits}>
<Text style={styles.benefitsTitle}>Privilège Membre :</Text> {cardDisplay.benefitItems.map((item, index) =>
{details?.privileges?.map((item, index) => ( item?.type === 'plus' ? (
<View key={index} style={styles.benefitBlock}> <Text key={`benefit-plus-${index}`} style={styles.benefitPlus}>
<Text style={[styles.benefitText, item.bold && styles.benefitTextBold]}> {item.text}
{item.bold ? `${item.text}` : item.text}
</Text> </Text>
</View> ) : (
)) || ( <Text key={`benefit-text-${index}`} style={styles.downloadLabel}>
<Text style={styles.cardBenefitsItem}> Crédits gratuits tous les mois</Text> {item.text}
</Text>
)
)} )}
</View> </View>
) : null}
{isPopular ? ( {isPopular ? (
<View style={styles.popularBadge}> <View style={styles.popularBadge}>
@@ -274,11 +177,6 @@ function SubscriptionCard({ plan, selected, onSelect, isAnnual }) {
) : null} ) : null}
</View> </View>
</BlurView> </BlurView>
{isAnnual ? (
<View style={styles.annualBadge}>
<Text style={styles.annualBadgeText}>2 mois offert</Text>
</View>
) : null}
</Pressable> </Pressable>
) )
} }
@@ -301,7 +199,7 @@ const ShareAndCreditsModal = ({
isCatalogLoading, isCatalogLoading,
} = useStripe() } = useStripe()
const [selectedPeriodKey, setSelectedPeriodKey] = useState('annual') const [selectedPeriodKey, setSelectedPeriodKey] = useState('monthly')
const [selectedPriceId, setSelectedPriceId] = useState(null) const [selectedPriceId, setSelectedPriceId] = useState(null)
const [processingPriceId, setProcessingPriceId] = useState(null) const [processingPriceId, setProcessingPriceId] = useState(null)
const [errorMessage, setErrorMessage] = useState(null) const [errorMessage, setErrorMessage] = useState(null)
@@ -422,7 +320,7 @@ const ShareAndCreditsModal = ({
{/* CREDITS SECTION */} {/* CREDITS SECTION */}
{coinPacks && coinPacks.length > 0 ? ( {coinPacks && coinPacks.length > 0 ? (
<View style={styles.sectionContainer}> <View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>Recharger vos crédits</Text> <Text style={styles.sectionTitle}>{COIN_PACK_SECTION_TITLE}</Text>
<View style={styles.creditsGrid}> <View style={styles.creditsGrid}>
{coinPacks.map((pack) => { {coinPacks.map((pack) => {
const rawAmount = pack.unitAmount ?? pack.amount ?? pack.price ?? 0 const rawAmount = pack.unitAmount ?? pack.amount ?? pack.price ?? 0
@@ -432,6 +330,8 @@ const ShareAndCreditsModal = ({
const details = packKey ? COIN_PACK_DETAILS[packKey] : null const details = packKey ? COIN_PACK_DETAILS[packKey] : null
const displayName = details?.label || pack.name const displayName = details?.label || pack.name
const creditsText = details?.creditsDisplay const creditsText = details?.creditsDisplay
const displayPrice = details?.priceDisplay || price || `${rawAmount / 100}`
const isPopular = details?.popular === true
return ( return (
<View key={pack.productId} style={styles.creditCard}> <View key={pack.productId} style={styles.creditCard}>
@@ -450,12 +350,13 @@ const ShareAndCreditsModal = ({
<Text style={styles.packDisclaimer}>{details.disclaimer}</Text> <Text style={styles.packDisclaimer}>{details.disclaimer}</Text>
) : null} ) : null}
</View> </View>
{isPopular ? (
<View style={styles.popularBadge}>
<Text style={styles.popularBadgeText}>Le plus populaire !</Text>
</View>
) : null}
<GradientButton <GradientButton
title={ title={processingPriceId === pack.productId ? '...' : displayPrice}
processingPriceId === pack.productId
? '...'
: price || `${rawAmount / 100}`
}
onPress={() => handleBuyCredits(pack)} onPress={() => handleBuyCredits(pack)}
disabled={Boolean(processingPriceId)} disabled={Boolean(processingPriceId)}
style={styles.creditButton} style={styles.creditButton}
@@ -636,7 +537,7 @@ const styles = StyleSheet.create({
borderRadius: 16, borderRadius: 16,
padding: 16, padding: 16,
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'flex-start',
width: isWeb ? 220 : '100%', width: isWeb ? 220 : '100%',
minHeight: 160, minHeight: 160,
gap: 12, gap: 12,
@@ -672,6 +573,7 @@ const styles = StyleSheet.create({
marginTop: 4, marginTop: 4,
}, },
creditButton: { creditButton: {
marginTop: 'auto',
width: '100%', width: '100%',
height: 36, height: 36,
minHeight: 36, minHeight: 36,
@@ -758,6 +660,10 @@ const styles = StyleSheet.create({
padding: 16, padding: 16,
gap: 16, gap: 16,
}, },
cardContent: {
flex: 1,
gap: 16,
},
badge: { badge: {
alignSelf: 'flex-start', alignSelf: 'flex-start',
backgroundColor: 'rgba(112, 35, 247, 0.25)', backgroundColor: 'rgba(112, 35, 247, 0.25)',
@@ -795,6 +701,11 @@ const styles = StyleSheet.create({
priceBlock: { priceBlock: {
marginBottom: 8, marginBottom: 8,
}, },
secondaryPriceText: {
color: Palette.primary,
fontSize: 15,
fontFamily: FONT_FAMILY.InterBold,
},
priceRow: { priceRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'baseline', alignItems: 'baseline',
@@ -830,6 +741,19 @@ const styles = StyleSheet.create({
cardBenefits: { cardBenefits: {
gap: 6, gap: 6,
}, },
downloadLabel: {
color: Palette.white,
fontSize: 13,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: 'center',
},
benefitPlus: {
color: Palette.white,
fontSize: 22,
fontFamily: FONT_FAMILY.InterBold,
textAlign: 'center',
lineHeight: 24,
},
benefitsTitle: { benefitsTitle: {
color: Palette.white, color: Palette.white,
fontSize: 13, fontSize: 13,
@@ -856,7 +780,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 4, paddingVertical: 4,
borderRadius: 12, borderRadius: 12,
marginTop: 12, marginTop: 'auto',
}, },
popularBadgeText: { popularBadgeText: {
color: Palette.white, color: Palette.white,
+63
View File
@@ -0,0 +1,63 @@
export const COIN_PACK_SECTION_TITLE = 'Achat de crédits'
export const COIN_PACK_DETAILS = {
starter: {
label: 'Amateur',
creditsDisplay: '30 crédits',
priceDisplay: '4,99€',
disclaimer: 'Téléchargement gratuit de tes créations',
},
pro: {
label: 'Pro',
creditsDisplay: '100 crédits',
priceDisplay: '9,99€',
popular: true,
disclaimer: 'Téléchargement gratuit de tes créations',
},
premium: {
label: 'Engagé',
creditsDisplay: '150 crédits',
priceDisplay: '14,99€',
disclaimer: 'Téléchargement gratuit de tes créations',
},
}
export const getCoinPackKey = (pack) => {
const directKey = (pack?.coinPackKey || '').toString().trim().toLowerCase()
const name = (pack?.name || '').toString().trim().toLowerCase()
if (directKey === 'starter') {
return 'starter'
}
if (directKey === 'pro' || directKey === 'medium') {
return 'pro'
}
if (directKey === 'premium' || directKey === 'creator') {
return 'premium'
}
if (!name) {
return null
}
if (name.includes('starter') || name.includes('amateur')) {
return 'starter'
}
if (name.includes('medium') || name.includes('pro')) {
return 'pro'
}
if (
name.includes('premium') ||
name.includes('prenium') ||
name.includes('creator') ||
name.includes('engage')
) {
return 'premium'
}
return null
}
+10 -6
View File
@@ -1,21 +1,25 @@
const listeners = new Set() const listeners = new Map()
let nextListenerId = 0
export const subscribeCoinPackModal = (listener) => { export const subscribeCoinPackModal = (listener) => {
if (typeof listener !== 'function') { if (typeof listener !== 'function') {
return () => {} return () => {}
} }
listeners.add(listener) const listenerId = ++nextListenerId
listeners.set(listenerId, listener)
return () => { return () => {
listeners.delete(listener) listeners.delete(listenerId)
} }
} }
export const openCoinPackModal = () => { export const openCoinPackModal = () => {
listeners.forEach((listener) => { const activeListener = Array.from(listeners.values()).pop()
if (!activeListener) {
return
}
try { try {
listener() activeListener()
} catch (error) { } catch (error) {
console.error('[coinPackModal] open listener error', error) console.error('[coinPackModal] open listener error', error)
} }
})
} }
+139
View File
@@ -0,0 +1,139 @@
export const SUBSCRIPTION_PRICE_ID_BY_PERIOD = {
monthly: {
starter: 'price_1SPgitCzf2o5bDRdbnhLFx6f',
pro: 'price_1SPgjCCzf2o5bDRdr08Xzp8u',
premium: 'price_1SPgjaCzf2o5bDRdd9Xo2u26',
},
annual: {
starter: 'price_1SPgkDCzf2o5bDRdNGLVNeQ3',
pro: 'price_1SPgkXCzf2o5bDRdejBVxEBY',
premium: 'price_1SPgkqCzf2o5bDRdIcUwTDrm',
},
}
export const SUBSCRIPTION_PRICE_ID_TO_PLAN_KEY = Object.values(
SUBSCRIPTION_PRICE_ID_BY_PERIOD
).reduce((acc, mapping) => {
Object.entries(mapping).forEach(([planKey, priceId]) => {
if (priceId) {
acc[priceId] = planKey
}
})
return acc
}, {})
export const SUBSCRIPTION_PLAN_SYNONYMS = {
starter: ['starter', 'blue'],
pro: ['pro', 'silver'],
premium: ['premium', 'gold'],
}
export const SUBSCRIPTION_PLAN_ORDER = ['starter', 'pro', 'premium']
export const SUBSCRIPTION_PLAN_DISPLAY_NAME_BY_KEY = {
starter: 'Pack Play Backer Blue',
pro: 'Pack Play Backer Silver',
premium: 'Pack Play Backer Gold',
}
export const SUBSCRIPTION_PLAN_DETAILS = {
starter: {
monthlyPriceDisplay: '4,99 € / Mois',
annualPriceDisplay: '49,99 € / An (2 mois offerts)',
monthlyCreditsLabel: '50 Crédits / Mois',
annualCreditsLabel: 'Annuel: 600 crédits',
playlistLabel: 'Accès à la création de Playlist personnalisées',
downloadLabel: 'Téléchargement gratuit de tes créations',
fullAccessLabel: 'Accès au téléchargement de toutes les créations de Musicland',
popular: false,
},
pro: {
monthlyPriceDisplay: '9,99 € / Mois',
annualPriceDisplay: '99,99 € / An (2 mois offerts)',
monthlyCreditsLabel: '110 crédits/Mois',
annualCreditsLabel: 'Annuel: 1320 crédits',
playlistLabel: 'Accès à la création de Playlist personnalisées',
downloadLabel: 'Téléchargement gratuit de tes créations',
fullAccessLabel: 'Accès au téléchargement de toutes les créations de Musicland',
popular: true,
},
premium: {
monthlyPriceDisplay: '14,99 € / Mois',
annualPriceDisplay: '149,99 € / An (2 mois offerts)',
monthlyCreditsLabel: '160 Crédits/mois',
annualCreditsLabel: 'Annuel: 1920 Crédits',
playlistLabel: 'Accès à la création de Playlist personnalisées',
downloadLabel: 'Téléchargement gratuit de tes créations',
fullAccessLabel: 'Accès au téléchargement de toutes les créations de Musicland',
popular: false,
},
}
const formatFallbackPrimaryPrice = (formattedPrice, intervalLabel) => {
if (!formattedPrice) {
return null
}
if (!intervalLabel) {
return formattedPrice
}
return `${formattedPrice} ${intervalLabel}`
}
export const getSubscriptionPlanKey = (plan) => {
if (!plan || typeof plan !== 'object') {
return null
}
const priceId = typeof plan?.priceId === 'string' ? plan.priceId : plan?.id || null
if (priceId && SUBSCRIPTION_PRICE_ID_TO_PLAN_KEY[priceId]) {
return SUBSCRIPTION_PRICE_ID_TO_PLAN_KEY[priceId]
}
const label = (plan?.product?.name || plan?.nickname || '').toString().toLowerCase()
if (!label) {
return null
}
const match = Object.entries(SUBSCRIPTION_PLAN_SYNONYMS).find(([, variants]) =>
variants.some((variant) => label.includes(variant))
)
return match ? match[0] : null
}
export const getSubscriptionCardDisplay = ({
planKey,
isAnnual = false,
formattedPrice = null,
intervalLabel = null,
coinsPerMonth = null,
fallbackTitle = 'Abonnement',
}) => {
const details = planKey ? SUBSCRIPTION_PLAN_DETAILS[planKey] : null
return {
title:
(planKey && SUBSCRIPTION_PLAN_DISPLAY_NAME_BY_KEY[planKey]) || fallbackTitle,
primaryPrice:
(details && (isAnnual ? details.annualPriceDisplay : details.monthlyPriceDisplay)) ||
formatFallbackPrimaryPrice(formattedPrice, intervalLabel),
secondaryPrice: details && !isAnnual ? details.annualPriceDisplay : null,
creditsLabel:
(details && (isAnnual ? details.annualCreditsLabel : details.monthlyCreditsLabel)) ||
(typeof coinsPerMonth === 'number' ? `${Math.round(coinsPerMonth)} crédits/mois` : null),
downloadLabel: details?.downloadLabel || null,
benefitItems: isAnnual
? [
details?.playlistLabel ? { type: 'text', text: details.playlistLabel } : null,
details?.downloadLabel ? { type: 'text', text: details.downloadLabel } : null,
{ type: 'plus', text: '+' },
details?.fullAccessLabel ? { type: 'text', text: details.fullAccessLabel } : null,
].filter(Boolean)
: details?.downloadLabel
? [{ type: 'text', text: details.downloadLabel }]
: [],
popular: details?.popular === true,
}
}