feat: download options

This commit is contained in:
2026-09-03 09:30:31 +02:00
parent f4549b694f
commit 99431f9ae4
20 changed files with 837 additions and 1512 deletions
+2
View File
@@ -284,6 +284,8 @@ exports.validatePlaybackDraft = onCall({ region: REGION, timeoutSeconds: 540 },
playbackJobId: null,
playbackCallbackId: null,
playbackError: null,
playbackPublishedOnMusicLand: false,
playbackPublishedOnMusicLandAt: null,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
+5 -2
View File
@@ -36,9 +36,12 @@ exports.snapshotMonthlyTopSongs = onSchedule(
const { scheduleTime } = event
const context = buildMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
const topProjectsSnap = await refList.projects.orderBy('views', 'desc').limit(3).get()
const topProjectsSnap = await refList.projects.orderBy('views', 'desc').limit(100).get()
const topProjects = topProjectsSnap.docs.map((doc, index) => {
const topProjects = topProjectsSnap.docs
.filter((doc) => doc.data()?.songPublishedOnMusicLand !== false)
.slice(0, 3)
.map((doc, index) => {
const data = doc.data() || {}
return {
rank: index + 1,
+5 -1
View File
@@ -47,10 +47,14 @@ const usePlaylistMusicSearch = () => {
}
}, [musics])
const visibleMusics = (hydratedMusics ?? musics)?.filter(
(project) => project?.songPublishedOnMusicLand !== false
)
return {
search,
setSearch,
musics: hydratedMusics ?? musics,
musics: visibleMusics,
loading,
hasSearch: normalizedSearch.length > 0,
}
+9 -2
View File
@@ -96,14 +96,21 @@ const useSearch = () => {
}
}, [playbacks])
const visibleMusics = (hydratedMusics ?? musics)?.filter(
(project) => project?.songPublishedOnMusicLand !== false
)
const visiblePlaybacks = (hydratedPlaybacks ?? playbacks)?.filter(
(project) => project?.playbackPublishedOnMusicLand !== false
)
return {
search,
setSearch,
selected,
setSelected,
users,
musics: hydratedMusics ?? musics,
playbacks: hydratedPlaybacks ?? playbacks,
musics: visibleMusics,
playbacks: visiblePlaybacks,
loading: userLoading || musicLoading || playbackLoading,
}
}
+5
View File
@@ -21,6 +21,7 @@ import Playback from '../screens/Playback/Playback'
import PlaybackAi from '../screens/Playback/PlaybackAi'
import PlaybackAiStatus from '../screens/Playback/PlaybackAiStatus'
import PlaybackGuide from '../screens/Playback/PlaybackGuide'
import PlaybackImageRightsConsent from '../screens/Playback/PlaybackImageRightsConsent'
import PlaybackOnboarding from '../screens/Playback/PlaybackOnboarding'
import RecordPlayback from '../screens/Playback/RecordPlayback'
import RecordedPlayback from '../screens/Playback/RecordedPlayback'
@@ -232,6 +233,10 @@ const baseScreens = [
name: Routes.Playback,
component: Playback,
},
{
name: Routes.PlaybackImageRightsConsent,
component: PlaybackImageRightsConsent,
},
{
name: Routes.PlaybackGuide,
component: PlaybackGuide,
+1
View File
@@ -57,6 +57,7 @@ export const Routes = {
PlaybackOnboarding: 'PlaybackOnboarding',
Playback: 'Playback',
PlaybackImageRightsConsent: 'PlaybackImageRightsConsent',
PlaybackGuide: 'PlaybackGuide',
PlaybackAi: 'PlaybackAi',
PlaybackAiStatus: 'PlaybackAiStatus',
+1
View File
@@ -35,6 +35,7 @@ const HIDDEN_ROUTE_NAMES = new Set([
Routes.PlaybackExample,
Routes.PlaybackOnboarding,
Routes.Playback,
Routes.PlaybackImageRightsConsent,
Routes.PlaybackGuide,
Routes.PlaybackAi,
Routes.PlaybackAiStatus,
+18 -3
View File
@@ -72,7 +72,12 @@ const HitParade = () => {
}, [selectedLanguage])
const { data: allSongs } = useDataFromRef({
ref: allSongsRef,
format: (docs) => docs.filter((item) => item?.hasPlayback !== true),
format: (docs) =>
docs.filter(
(item) =>
item?.songPublishedOnMusicLand !== false &&
(item?.hasPlayback !== true || item?.playbackPublishedOnMusicLand === false)
),
simpleRef: false,
listener: true,
condition: true,
@@ -81,7 +86,12 @@ const HitParade = () => {
const { data: topMonthSongs } = useDataFromRef({
ref: projectsRef.where('monthViews', '>', 0).orderBy('monthViews', 'desc').limit(20),
format: (docs) => docs.filter((item) => item?.hasPlayback !== true),
format: (docs) =>
docs.filter(
(item) =>
item?.songPublishedOnMusicLand !== false &&
(item?.hasPlayback !== true || item?.playbackPublishedOnMusicLand === false)
),
simpleRef: false,
listener: false,
condition: selectedLanguage === 'all',
@@ -90,7 +100,12 @@ const HitParade = () => {
const { data: allTimeSongs } = useDataFromRef({
ref: projectsRef.where('views', '>', 0).orderBy('views', 'desc').limit(20),
format: (docs) => docs.filter((item) => item?.hasPlayback !== true),
format: (docs) =>
docs.filter(
(item) =>
item?.songPublishedOnMusicLand !== false &&
(item?.hasPlayback !== true || item?.playbackPublishedOnMusicLand === false)
),
simpleRef: false,
listener: false,
condition: selectedLanguage === 'all',
@@ -112,7 +112,10 @@ const SearchResultsList = ({
return musics
}
return musics.filter((project) => project?.hasPlayback !== true)
return musics.filter(
(project) =>
project?.hasPlayback !== true || project?.playbackPublishedOnMusicLand === false
)
}, [musics, selected])
const shouldShowMusics =
+16 -4
View File
@@ -46,7 +46,11 @@ const Playback = ({ route, navigation }) => {
const onPressRecord = useCallback(() => {
if (isManualBlocked) return
navigate(Routes.RecordPlayback, { project, returnToAdventureModal })
navigate(Routes.PlaybackImageRightsConsent, {
project,
flow: 'record',
returnToAdventureModal,
})
}, [isManualBlocked, project, returnToAdventureModal])
const onPressPlaybackAi = useCallback(() => {
@@ -54,8 +58,12 @@ const Playback = ({ route, navigation }) => {
navigate(Routes.PlaybackAiStatus, { project })
return
}
navigate(Routes.PlaybackAi, { project })
}, [project])
navigate(Routes.PlaybackImageRightsConsent, {
project,
flow: 'ai',
returnToAdventureModal,
})
}, [project, returnToAdventureModal])
return (
<Page
@@ -86,7 +94,11 @@ const Playback = ({ route, navigation }) => {
onPress={onPressRecord}
disabled={!project || isManualBlocked}
/>
<BorderGradientButton title={aiButtonLabel} onPress={onPressPlaybackAi} disabled={!project} />
<BorderGradientButton
title={aiButtonLabel}
onPress={onPressPlaybackAi}
disabled={!project}
/>
{isManualBlocked ? (
<Text style={styles.blockedText}>
Le playback manuel est bloqué tant que le playback IA est en cours ou en attente de
@@ -0,0 +1,150 @@
import React, { useMemo, useState } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import { background } from '../../assets'
import AppCheckbox from '../../components/AppCheckbox'
import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader'
import { projectsRef, serverTimestamp } from '../../config/firebase'
import Page from '../../layouts/Page'
import { Routes } from '../../navigation'
import { goBack, navigate } from '../../navigation/NavigationService'
import { useUserData } from '../../providers/UserDataProvider'
import { gutters, Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
const PlaybackImageRightsConsent = ({ route }) => {
const { project: routeProject, flow, returnToAdventureModal = false } = route.params || {}
const { selectedProject } = useUserData() || {}
const { setTooltip } = useMinuit()
const [hasAcceptedImageRights, setHasAcceptedImageRights] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const project = useMemo(() => {
if (routeProject?.id && selectedProject?.id === routeProject.id) {
return selectedProject
}
return routeProject || null
}, [routeProject, selectedProject])
const handleContinue = async () => {
if (!hasAcceptedImageRights || isSaving) return
if (!project?.id || (flow !== 'record' && flow !== 'ai')) {
setTooltip({
type: 'error',
text: "Impossible de poursuivre vers l'espace Playback.",
})
return
}
try {
setIsSaving(true)
await projectsRef.doc(project.id).set(
{
imageRightsAccepted: true,
imageRightsAcceptedAt: serverTimestamp(),
},
{ merge: true }
)
const params = { project, returnToAdventureModal }
navigate(flow === 'record' ? Routes.RecordPlayback : Routes.PlaybackAi, params)
} catch (error) {
setTooltip({
type: 'error',
text: error?.message || "Impossible d'enregistrer la confirmation.",
})
} finally {
setIsSaving(false)
}
}
return (
<Page backgroundImg={background.playbackBG2} backgroundColor="#07111B" headerType="NONE">
<MusicLandHeader progress={28} onPressBack={goBack} />
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<CreateLyricsHeader
title="Droit à l'image"
subTitle="Cette confirmation est nécessaire pour continuer."
/>
<View style={styles.disclaimerCard}>
<Text style={styles.disclaimerTitle}>Avant de créer ton playback</Text>
<Text style={styles.disclaimerText}>
La photo ou la vidéo utilisée doit représenter une personne qui a donné son accord.
N'utilise pas l'image d'un tiers sans son autorisation.
</Text>
<Text style={styles.disclaimerText}>
Si une autre personne apparaît dans le contenu, tu dois disposer de son autorisation
pour l'enregistrement, le traitement et, si tu le choisis plus tard, la publication sur
MusicLand.
</Text>
</View>
<View style={styles.consentContainer}>
<AppCheckbox
selected={hasAcceptedImageRights}
onPress={() => setHasAcceptedImageRights((current) => !current)}
label="Je confirme disposer des droits et autorisations nécessaires sur toutes les personnes représentées."
/>
</View>
<GradientButton
title="Continuer vers mon playback"
onPress={handleContinue}
disabled={!hasAcceptedImageRights || isSaving}
loading={isSaving}
loadingText="Enregistrement..."
containerStyle={styles.continueButton}
/>
</ScrollView>
</Page>
)
}
export default PlaybackImageRightsConsent
const styles = StyleSheet.create({
container: {
flexGrow: 1,
width: '100%',
maxWidth: 620,
alignSelf: 'center',
paddingHorizontal: gutters,
paddingTop: gutters,
paddingBottom: gutters * 2,
gap: gutters,
},
disclaimerCard: {
padding: gutters,
borderRadius: 18,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.16)',
backgroundColor: 'rgba(5, 12, 24, 0.82)',
gap: 12,
},
disclaimerTitle: {
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
},
disclaimerText: {
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 15,
lineHeight: 22,
opacity: 0.86,
},
consentContainer: {
padding: gutters,
borderRadius: 16,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
continueButton: {
width: '100%',
maxWidth: 360,
alignSelf: 'center',
},
})
+1
View File
@@ -23,6 +23,7 @@ const Playbacks = () => {
const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts')
const { data: rawPlaybacks = [], loadMore } = useDataFromRef({
ref: projectsRef.where('playbackUrl', '!=', null),
format: (docs) => docs.filter((item) => item?.playbackPublishedOnMusicLand !== false),
simpleRef: false,
listener: false,
usePagination: true,
+1
View File
@@ -38,6 +38,7 @@ const Playbacks = () => {
loading,
} = useDataFromRef({
ref: projectsRef.where('playbackUrl', '!=', null),
format: (docs) => docs.filter((item) => item?.playbackPublishedOnMusicLand !== false),
simpleRef: false,
listener: false,
usePagination: true,
@@ -40,12 +40,10 @@ const chunkArray = (items = [], size = 10) => {
const PlaybackCharts = ({ onPlaybackPress }) => {
const isWeb = Platform.OS === 'web'
const [selectedCategory, setSelectedCategory] = useState('Playbacks')
const {
data: allPlaybacks,
loading: allPlaybacksLoading,
} = useDataFromRef({
const { data: allPlaybacks, loading: allPlaybacksLoading } = useDataFromRef({
ref: projectsRef.where('playbackUrl', '!=', null),
format: (docs) => docs.filter((item) => !!item?.playbackUrl),
format: (docs) =>
docs.filter((item) => !!item?.playbackUrl && item?.playbackPublishedOnMusicLand !== false),
simpleRef: false,
listener: true,
condition: true,
@@ -53,7 +51,8 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
const { data: topMonthPlaybacks, loading: topMonthLoading } = useDataFromRef({
ref: projectsRef.where('monthViews', '>', 0).orderBy('monthViews', 'desc').limit(20),
format: (docs) => docs.filter((item) => !!item?.playbackUrl),
format: (docs) =>
docs.filter((item) => !!item?.playbackUrl && item?.playbackPublishedOnMusicLand !== false),
simpleRef: false,
listener: false,
condition: true,
@@ -61,7 +60,8 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
const { data: allTimePlaybacks, loading: allTimeLoading } = useDataFromRef({
ref: projectsRef.where('views', '>', 0).orderBy('views', 'desc').limit(20),
format: (docs) => docs.filter((item) => !!item?.playbackUrl),
format: (docs) =>
docs.filter((item) => !!item?.playbackUrl && item?.playbackPublishedOnMusicLand !== false),
simpleRef: false,
listener: false,
condition: true,
+176 -158
View File
@@ -1,14 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { MaterialCommunityIcons } from '@expo/vector-icons'
import { Platform, ScrollView, StyleSheet, Text, View } from 'react-native'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import * as FileSystem from 'expo-file-system'
import { shareAsync } from 'expo-sharing'
import { background } from '../../assets'
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
import BorderGradientButton from '../../components/BorderGradientButton'
import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader'
import AppCheckbox from '../../components/AppCheckbox'
import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../../config/firebase'
import { uploadFileToFirebase } from '../../helpers/uploadToFirebase'
import Page from '../../layouts/Page'
@@ -20,15 +19,12 @@ import { Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import { size } from '../../styles/Style'
import { getBlobForUrl, releaseBlobUrl } from '../../utils/blobUrlCache'
import ClubAdvantagesCard from '../Profile/components/ClubAdvantagesCard'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
import { Image as ExpoImage } from 'expo-image'
import SubscriptionConfirmModal from '../../components/SubscriptionConfirmModal'
import { isWeb } from '../../hooks/useLayoutType'
import { clampSyncOffsetMs, toSyncOffsetSeconds } from '../../utils/playbackSync'
const triggerDownload = async (url, title) => {
if (!url) return
if (!url) return false
const baseName = (title || 'Playback').toString().trim() || 'Playback'
const sanitized = baseName.replace(/[\\/:*?"<>|]/g, '-')
const extension = guessExtension(url) || 'mp4'
@@ -53,6 +49,7 @@ const triggerDownload = async (url, title) => {
document.body.removeChild(anchor)
// Revoke with delay to ensure browser captures it
setTimeout(() => URL.revokeObjectURL(blobUrl), 150)
return true
} catch (error) {
console.log('[PlaybackDownload] web download fallback', {
message: error?.message,
@@ -67,6 +64,7 @@ const triggerDownload = async (url, title) => {
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
return true
} catch (fallbackError) {
console.log('[PlaybackDownload] anchor fallback failed', {
message: fallbackError?.message,
@@ -74,7 +72,8 @@ const triggerDownload = async (url, title) => {
// Ultimate fallback: direct window open
try {
window.open(url, '_blank', 'noopener,noreferrer')
} catch { }
return true
} catch {}
}
}
} else {
@@ -87,11 +86,13 @@ const triggerDownload = async (url, title) => {
if (shareAsync) {
await shareAsync(localUri)
}
return true
}
} catch (e) {
console.error(e)
}
}
return false
}
const guessExtension = (inputUri = '') => {
@@ -146,7 +147,12 @@ const PlaybackDownload = ({ route }) => {
const { currentUID, selectedProject } = useUserData()
const { hasActiveSubscription, hasPurchased, videos } = useUser() || {}
const { createPlaybackDownloadCheckout } = useStripe()
const { action: routeAction, uri, project: routeProject, syncOffsetMs: routeSyncOffsetMs = 0 } = route.params || {}
const {
action: routeAction,
uri,
project: routeProject,
syncOffsetMs: routeSyncOffsetMs = 0,
} = route.params || {}
const action = routeAction || 'playback'
const syncOffsetMs = clampSyncOffsetMs(routeSyncOffsetMs)
console.log('[PlaybackDownload] route params', {
@@ -158,15 +164,13 @@ const PlaybackDownload = ({ route }) => {
})
const { setIsLoading, setTooltip } = useMinuit()
const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [pendingPlaybackUrl, setPendingPlaybackUrl] = useState(routeProject?.playbackUrl || null)
const [isPublishing, setIsPublishing] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false)
const [downloadPaymentPending, setDownloadPaymentPending] = useState(false)
const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true)
const [pendingDownloadIntent, setPendingDownloadIntent] = useState(null)
const hasShownAfterPlaybackRef = useRef(false)
const publishSuccessMessage = action === 'playback' ? 'Playback publié !' : 'Chanson publiée !'
const projectForDownload = useMemo(() => {
if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) {
@@ -178,9 +182,9 @@ const PlaybackDownload = ({ route }) => {
const playbackDownloadStatus = projectForDownload?.playbackDownloadPurchase?.status || null
const hasPaidPlaybackDownload = playbackDownloadStatus === 'paid'
const canDownloadPlayback = hasActiveSubscription || hasPaidPlaybackDownload || hasPurchased
const downloadLabel = canDownloadPlayback
? 'Télécharger le playback'
: 'Acheter le playback pour 1,99'
const downloadAccessLabel = canDownloadPlayback
? 'Téléchargement inclus avec ton accès actuel.'
: 'Le téléchargement de ce playback coûte 1,99 €.'
const afterPlaybackUrl = useMemo(() => {
if (!videos) return null
@@ -302,36 +306,66 @@ const PlaybackDownload = ({ route }) => {
releaseBlobUrl(uri || null)
setIsLoading(false)
}
}, [action, currentUID, pendingPlaybackUrl, projectForDownload, resolveAudioUrl, setIsLoading, syncOffsetMs, uri])
}, [
action,
currentUID,
pendingPlaybackUrl,
projectForDownload,
resolveAudioUrl,
setIsLoading,
syncOffsetMs,
uri,
])
const executePlaybackChoice = useCallback(
async (intent) => {
if (intent !== 'personal' && intent !== 'publish') return
if (isPublishing || isDownloading || !projectForDownload?.id) return
const handleDownloadUri = useCallback(async () => {
if (isPublishing || isDownloading) return
try {
setIsDownloading(true)
const resultURI = await processMerge()
setIsPublishing(intent === 'publish')
const playbackUrl = await processMerge()
const didDownload = await triggerDownload(playbackUrl, projectForDownload.title)
if (!didDownload) {
throw new Error("Le téléchargement du playback n'a pas abouti.")
}
await projectsRef.doc(projectForDownload.id).set(
{
playbackUrl,
playbackPublishedOnMusicLand: intent === 'publish',
playbackPublishedOnMusicLandAt: intent === 'publish' ? serverTimestamp() : null,
updatedAt: serverTimestamp(),
},
{ merge: true }
)
setTooltip({
type: 'success',
text: 'Playback prêt à télécharger',
text:
intent === 'publish'
? 'Playback téléchargé et publié sur MusicLand !'
: 'Playback téléchargé à des fins personnelles.',
})
await triggerDownload(resultURI, projectForDownload?.title)
if (intent === 'publish') {
navigate(Routes.Home)
}
} catch (error) {
setTooltip({
type: 'error',
text: String(error?.message || 'Erreur lors de la publication du playback'),
text: String(error?.message || "Impossible de terminer l'opération."),
})
} finally {
setIsDownloading(false)
setIsPublishing(false)
}
}, [
isDownloading,
isPublishing,
processMerge,
projectForDownload,
setTooltip,
])
},
[isDownloading, isPublishing, processMerge, projectForDownload, setTooltip]
)
const startPlaybackDownloadCheckout = useCallback(
async ({ force = false } = {}) => {
async (intent, { force = false } = {}) => {
if (!projectForDownload?.id) {
setTooltip({
type: 'error',
@@ -344,15 +378,17 @@ const PlaybackDownload = ({ route }) => {
return
}
setPendingDownloadIntent(intent)
setIsCheckoutLaunching(true)
setDownloadPaymentPending(true)
try {
await createPlaybackDownloadCheckout(projectForDownload.id)
} catch (error) {
setDownloadPaymentPending(false)
setPendingDownloadIntent(null)
setTooltip({
type: 'error',
text: error?.message || "Une erreur est survenue lors du paiement.",
text: error?.message || 'Une erreur est survenue lors du paiement.',
})
} finally {
setIsCheckoutLaunching(false)
@@ -367,60 +403,45 @@ const PlaybackDownload = ({ route }) => {
]
)
const handleDownloadPress = async () => {
if (!canDownloadPlayback) return startPlaybackDownloadCheckout()
if (isDownloading || isPublishing) return
try {
setIsDownloading(true)
const url = await processMerge()
await triggerDownload(url, projectForDownload?.title)
} catch (err) {
setTooltip({ type: 'error', text: err.message })
} finally {
setIsDownloading(false)
}
}
useEffect(() => {
if (!downloadPaymentPending || !hasPaidPlaybackDownload || isDownloading) {
const handlePlaybackChoice = useCallback(
(intent) => {
if (isDownloading || isPublishing || isCheckoutLaunching) return
if (canDownloadPlayback) {
executePlaybackChoice(intent)
return
}
setDownloadPaymentPending(false)
handleDownloadUri()
}, [downloadPaymentPending, handleDownloadUri, hasPaidPlaybackDownload, isDownloading])
const handlePublish = async () => {
if (!hasAcceptedPublication) return
if (isPublishing) return
try {
setIsPublishing(true)
const url = await processMerge()
await projectsRef.doc(projectForDownload.id).set(
{
playbackUrl: url,
updatedAt: serverTimestamp(),
startPlaybackDownloadCheckout(intent)
},
{ merge: true }
[
canDownloadPlayback,
executePlaybackChoice,
isCheckoutLaunching,
isDownloading,
isPublishing,
startPlaybackDownloadCheckout,
]
)
setTooltip({
type: 'success',
text: 'Playback publié sur MusicLand !',
})
navigate(Routes.Home)
} catch (err) {
setTooltip({
type: 'error',
text: String(err?.message || 'Erreur de publication'),
})
} finally {
setIsPublishing(false)
}
useEffect(() => {
if (
!downloadPaymentPending ||
!hasPaidPlaybackDownload ||
!pendingDownloadIntent ||
isDownloading
) {
return
}
const intent = pendingDownloadIntent
setDownloadPaymentPending(false)
setPendingDownloadIntent(null)
executePlaybackChoice(intent)
}, [
downloadPaymentPending,
executePlaybackChoice,
hasPaidPlaybackDownload,
isDownloading,
pendingDownloadIntent,
])
return (
<>
<FullscreenIntroVideo
@@ -429,23 +450,21 @@ const PlaybackDownload = ({ route }) => {
onClose={() => setIsAfterPlaybackVideoVisible(false)}
/>
<Page
backgroundImg={
action === 'playback' ? background.playbackBG2 : background.productionBG2
}
backgroundImg={action === 'playback' ? background.playbackBG2 : background.productionBG2}
backgroundColor="#07111B"
headerType="NONE"
>
<MusicLandHeader onPressBack={goBack} progress={50} />
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<CreateLyricsHeader title={'Publier ton playback'} />
<CreateLyricsHeader
title="Ton playback est prêt"
subTitle="Choisis l'utilisation de ta vidéo."
/>
<ClubAdvantagesCard
style={styles.clubCardSpacing}
topContent={
<View style={styles.coverRow}>
<View style={styles.summaryCard}>
{projectForDownload?.coverUrl ? (
<ExpoImage
source={{ uri: projectForDownload?.coverUrl }}
source={{ uri: projectForDownload.coverUrl }}
style={styles.coverImage}
contentFit="cover"
/>
@@ -454,50 +473,36 @@ const PlaybackDownload = ({ route }) => {
<Text style={styles.placeholderText}>Aucune pochette</Text>
</View>
)}
<Text style={styles.playbackTitle}>{projectForDownload?.title || 'Mon playback'}</Text>
</View>
<View style={styles.downloadColumn}>
<Pressable
style={[
styles.downloadTile,
(isPublishing || isDownloading || isCheckoutLaunching) && { opacity: 0.6 },
]}
onPress={handleDownloadPress}
<View style={styles.choiceSection}>
<Text style={styles.sectionTitle}>Téléchargement</Text>
<Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
<BorderGradientButton
title="Option 1 · Téléchargement à des fins personnelles"
onPress={() => handlePlaybackChoice('personal')}
disabled={isPublishing || isDownloading || isCheckoutLaunching}
>
<MaterialCommunityIcons name="download" size={22} color={Palette.white} />
<Text style={styles.downloadText}>{downloadLabel}</Text>
</Pressable>
loading={isDownloading && !isPublishing}
loadingText="Téléchargement en cours..."
height={58}
titleStyle={styles.choiceButtonText}
/>
<GradientButton
title={
isPublishing
? 'Téléchargement et publication en cours...'
: 'Option 2 · Télécharger et publier sur MusicLand'
}
onPress={() => handlePlaybackChoice('publish')}
disabled={isPublishing || isDownloading || isCheckoutLaunching}
height={58}
textStyle={styles.choiceButtonText}
/>
{!canDownloadPlayback && downloadPaymentPending ? (
<Text style={styles.downloadPendingText}>
Paiement en attente de confirmation...
</Text>
<Text style={styles.downloadPendingText}>Paiement en attente de confirmation...</Text>
) : null}
</View>
</View>
}
/>
<View style={styles.publishConsentContainer}>
<AppCheckbox
selected={hasAcceptedPublication}
onPress={() => setHasAcceptedPublication((prevState) => !prevState)}
label="J'accepte la diffusion de mon playback sur Musicland."
/>
<Text style={styles.publishConsentDescription}>
Cette confirmation est requise avant de lancer la publication.
</Text>
</View>
<BorderGradientButton
title={'Publier'}
onPress={() => {
handlePublish()
}}
disabled={isPublishing || !hasAcceptedPublication}
loading={isPublishing}
loadingText="Publication en cours..."
containerStyle={styles.continueButton}
/>
</ScrollView>
</Page>
</>
@@ -509,15 +514,22 @@ export default PlaybackDownload
const styles = StyleSheet.create({
container: {
flexGrow: 1,
width: '100%',
maxWidth: 620,
alignSelf: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
gap: 14,
},
coverRow: {
flexDirection: isWeb ? 'row' : 'column',
summaryCard: {
alignItems: 'center',
justifyContent: isWeb ? 'center' : 'center',
justifyContent: 'center',
gap: 12,
padding: 16,
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.14)',
backgroundColor: 'rgba(5, 12, 24, 0.76)',
},
coverImage: {
...size({ size: 140 }),
@@ -525,23 +537,45 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.18)',
},
downloadTile: {
flexDirection: 'row',
coverPlaceholder: {
alignItems: 'center',
gap: 10,
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 14,
backgroundColor: '#8C4BFF',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.06)',
},
downloadText: {
placeholderText: {
fontFamily: FONT_FAMILY.InterMedium,
color: Palette.grayMid,
},
playbackTitle: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 15,
fontSize: 18,
color: Palette.white,
textAlign: 'center',
},
choiceSection: {
gap: 10,
padding: 16,
borderRadius: 20,
backgroundColor: 'rgba(37, 36, 56, 0.94)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
},
sectionTitle: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
color: Palette.white,
},
downloadColumn: {
alignItems: 'center',
gap: 4,
sectionDescription: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
lineHeight: 20,
color: Palette.white,
opacity: 0.8,
marginBottom: 4,
},
choiceButtonText: {
textAlign: 'center',
fontSize: 14,
},
downloadPendingText: {
marginTop: 6,
@@ -550,20 +584,4 @@ const styles = StyleSheet.create({
color: Palette.white,
opacity: 0.8,
},
clubCardSpacing: {
marginTop: 10,
},
publishConsentContainer: {
gap: 6,
marginTop: 2,
},
publishConsentDescription: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13,
color: Palette.white,
opacity: 0.8,
},
continueButton: {
marginTop: 10,
},
})
+7 -2
View File
@@ -304,10 +304,15 @@ const Profile = () => {
refreshArray: [targetUserId],
})
const displayedProjects = isSelf ? selfProjects : otherUserProjects
const displayedProjects = isSelf
? selfProjects
: otherUserProjects.filter((project) => project?.songPublishedOnMusicLand !== false)
const displayedPlaybacksSource = isSelf ? selfPlaybacks : otherUserProjects
const displayedPlaybacks = Array.isArray(displayedPlaybacksSource)
? displayedPlaybacksSource.filter((project) => project?.playbackUrl)
? displayedPlaybacksSource.filter(
(project) =>
project?.playbackUrl && (isSelf || project?.playbackPublishedOnMusicLand !== false)
)
: []
const showHeader = isWeb
const showBackButton = !params?.noBack && !isWeb
+4
View File
@@ -124,6 +124,10 @@ const SongReady = () => {
playbackUrl: null,
playbackStatus: null,
playbackGenerating: null,
songPublishedOnMusicLand: false,
songPublishedOnMusicLandAt: null,
playbackPublishedOnMusicLand: false,
playbackPublishedOnMusicLandAt: null,
})
// Incrémenter le compteur de chansons générées
+199 -216
View File
@@ -1,22 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Image as ExpoImage } from 'expo-image'
import {
BackHandler,
Linking,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
Alert,
} from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import { Linking, Platform, ScrollView, StyleSheet, Text, View, Alert } from 'react-native'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import * as FileSystem from 'expo-file-system'
import * as Sharing from 'expo-sharing'
import BorderGradientButton from '../../components/BorderGradientButton'
import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader'
import { projectsRef, serverTimestamp } from '../../config/firebase'
import Page from '../../layouts/Page'
import { Routes } from '../../navigation/Routes'
import { goBack, navigate, push } from '../../navigation/NavigationService'
@@ -27,15 +18,11 @@ import { FONT_FAMILY } from '../../styles/Fonts'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
import { background } from '../../assets'
import { getStageAction } from '../../utils/projectStages'
import ClubAdvantagesCard from '../Profile/components/ClubAdvantagesCard'
import useGlobalLoading from '../../hooks/useGlobalLoading'
import { MaterialCommunityIcons } from '@expo/vector-icons'
import { isWeb } from '../../hooks/useLayoutType'
import { getArtistDisplayName } from '../../utils/artistName'
import { toDate } from '../../utils/dateFormatting'
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
import AdventureChoiceModal from './components/AdventureChoiceModal'
import ShareAndCreditsModal from './components/ShareAndCreditsModal'
import { musiclandShareHeading } from '../../data'
const SongDownload = ({ route }) => {
@@ -54,16 +41,12 @@ const SongDownload = ({ route }) => {
const [isDownloading, setIsDownloading] = useState(false)
const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false)
const [downloadPaymentPending, setDownloadPaymentPending] = useState(false)
const [pendingDownloadIntent, setPendingDownloadIntent] = useState(null)
const [isPublishing, setIsPublishing] = useState(false)
const { videos } = useUser()
const part1Url = isWeb ? videos?.benaiPart1 : videos?.benaiPart1
const [showIntro, setShowIntro] = useState(null)
const [showAdventureModal, setShowAdventureModal] = useState(false)
const [showShareModal, setShowShareModal] = useState(false)
const [adventureChoice, setAdventureChoice] = useState(() =>
skipAdventureGate ? 'stop' : 'pending'
)
const adventureGateTriggeredRef = useRef(false)
const reopenAdventureModalOnFocusRef = useRef(false)
const introTriggeredRef = useRef(false)
const projectForStage = useMemo(() => {
if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) {
@@ -110,84 +93,29 @@ const SongDownload = ({ route }) => {
const downloadPurchaseStatus = projectForStage?.downloadPurchase?.status || null
const hasPaidDownload = downloadPurchaseStatus === 'paid'
const canDownloadDirectly = hasActiveSubscription || hasPaidDownload || hasPurchased
const downloadLabel = canDownloadDirectly
? 'Télécharger mon morceau'
: 'Télécharger ce morceau pour 1,99'
const shouldShowDownloadPage = adventureChoice === 'stop'
const reopenAdventureChoice = useCallback(() => {
reopenAdventureModalOnFocusRef.current = false
setShowIntro(null)
setAdventureChoice('pending')
setShowAdventureModal(true)
}, [])
const downloadAccessLabel = canDownloadDirectly
? 'Téléchargement inclus avec ton accès actuel.'
: 'Le téléchargement de ce morceau coûte 1,99 €.'
const handleBackPress = useCallback(() => {
if (shouldShowDownloadPage) {
reopenAdventureChoice()
return true
}
if (backRoute) {
navigate(backRoute)
return true
}
goBack()
return true
}, [backRoute, reopenAdventureChoice, shouldShowDownloadPage])
useFocusEffect(
useCallback(() => {
if (Platform.OS !== 'android' || (!backRoute && !shouldShowDownloadPage)) {
return undefined
}
const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress)
return () => subscription.remove()
}, [backRoute, handleBackPress, shouldShowDownloadPage])
)
useFocusEffect(
useCallback(() => {
if (!reopenAdventureModalOnFocusRef.current) {
return undefined
}
setShowIntro(null)
setShowAdventureModal(true)
return undefined
}, [])
)
}, [backRoute])
useEffect(() => {
if (adventureGateTriggeredRef.current) {
return
}
if (adventureChoice !== 'pending') {
return
}
if (skipAdventureGate || introTriggeredRef.current) return
if (typeof videos === 'undefined') {
return
}
adventureGateTriggeredRef.current = true
introTriggeredRef.current = true
if (part1Url) {
setShowIntro(part1Url)
} else {
setShowAdventureModal(true)
}
}, [adventureChoice, part1Url, videos])
useEffect(() => {
if (adventureChoice !== 'pending') {
return
}
if (!adventureGateTriggeredRef.current) {
return
}
if (showIntro) {
return
}
if (!showAdventureModal) {
setShowAdventureModal(true)
}
}, [adventureChoice, showAdventureModal, showIntro])
}, [part1Url, skipAdventureGate, videos])
const continueFlow = useCallback(async () => {
if (!selectedOption) {
@@ -225,25 +153,13 @@ const SongDownload = ({ route }) => {
} finally {
await setLoading(false)
}
}, [
coverOptions,
coverUrl,
projectForStage,
push,
selectedOption,
setLoading,
updateProjectData,
])
}, [coverOptions, coverUrl, projectForStage, push, selectedOption, setLoading, updateProjectData])
const handleCloseIntro = useCallback(() => {
setShowIntro(null)
setShowAdventureModal(true)
}, [])
const handleContinueAdventure = useCallback(() => {
reopenAdventureModalOnFocusRef.current = true
setShowAdventureModal(false)
setAdventureChoice('continue')
continueFlow()
}, [continueFlow])
@@ -255,7 +171,7 @@ const SongDownload = ({ route }) => {
selectedOption?.generatedUrl ||
null
if (!downloadUrl || isDownloading) {
return
return false
}
await setLoading(true, { message: 'Préparation du téléchargement...' })
@@ -390,12 +306,22 @@ const SongDownload = ({ route }) => {
}
if (isWeb) {
try {
await triggerWebDownload(downloadUrl, projectForStage?.title)
return true
} catch (error) {
setTooltip({
type: 'error',
text: error?.message || 'Impossible de télécharger le morceau.',
})
return false
} finally {
await setLoading(false)
return
}
}
setIsDownloading(true)
let didDownload = false
try {
const baseName =
String(trackTitle || 'musicland-track')
@@ -427,6 +353,7 @@ const SongDownload = ({ route }) => {
await FileSystem.writeAsStringAsync(destUri, base64, {
encoding: FileSystem.EncodingType.Base64,
})
didDownload = true
} catch (error) {
console.log('[SongDownload] SAF write error', error?.message)
}
@@ -437,12 +364,14 @@ const SongDownload = ({ route }) => {
mimeType: 'audio/mpeg',
dialogTitle: musiclandShareHeading,
})
didDownload = true
} catch (error) {
console.log('[SongDownload] share error', error?.message)
}
} else {
try {
await Linking.openURL(downloadResult.uri)
didDownload = true
} catch (error) {
console.log('[SongDownload] open local file error', error?.message)
}
@@ -454,10 +383,60 @@ const SongDownload = ({ route }) => {
setIsDownloading(false)
await setLoading(false)
}
}, [coverUrl, isDownloading, projectForStage, selectedOption, trackTitle])
return didDownload
}, [coverUrl, isDownloading, projectForStage, selectedOption, setLoading, setTooltip, trackTitle])
const executeDownloadChoice = useCallback(
async (intent) => {
if (intent !== 'personal' && intent !== 'publish') return
try {
setIsPublishing(intent === 'publish')
const didDownload = await handleDownload()
if (!didDownload) {
setTooltip({
type: 'error',
text: "Le téléchargement du morceau n'a pas abouti.",
})
return
}
if (intent === 'personal') {
setTooltip({
type: 'success',
text: 'Morceau téléchargé à des fins personnelles.',
})
return
}
if (!projectId) {
throw new Error('Projet introuvable pour la publication.')
}
await projectsRef.doc(projectId).set(
{
songPublishedOnMusicLand: true,
songPublishedOnMusicLandAt: serverTimestamp(),
},
{ merge: true }
)
setTooltip({
type: 'success',
text: 'Morceau téléchargé et publié sur MusicLand !',
})
} catch (error) {
setTooltip({
type: 'error',
text: error?.message || "Impossible de terminer l'opération.",
})
} finally {
setIsPublishing(false)
}
},
[handleDownload, projectId, setTooltip]
)
const startSongDownloadCheckout = useCallback(
async ({ force = false } = {}) => {
async (intent, { force = false } = {}) => {
if (!projectId) {
if (setTooltip) {
setTooltip({
@@ -474,39 +453,36 @@ const SongDownload = ({ route }) => {
return
}
setPendingDownloadIntent(intent)
setIsCheckoutLaunching(true)
setDownloadPaymentPending(true)
try {
await createSongDownloadCheckout(projectId)
} catch (error) {
setDownloadPaymentPending(false)
setPendingDownloadIntent(null)
if (setTooltip) {
setTooltip({
type: 'error',
text: error?.message || "Une erreur est survenue lors du paiement.",
text: error?.message || 'Une erreur est survenue lors du paiement.',
})
} else {
Alert.alert('Paiement', error?.message || "Une erreur est survenue lors du paiement.")
Alert.alert('Paiement', error?.message || 'Une erreur est survenue lors du paiement.')
}
} finally {
setIsCheckoutLaunching(false)
}
},
[
createSongDownloadCheckout,
downloadPaymentPending,
isCheckoutLaunching,
projectId,
setTooltip,
]
[createSongDownloadCheckout, downloadPaymentPending, isCheckoutLaunching, projectId, setTooltip]
)
const handleDownloadPress = useCallback(() => {
if (isDownloading || isCheckoutLaunching) {
const handleDownloadChoice = useCallback(
(intent) => {
if (isDownloading || isCheckoutLaunching || isPublishing) {
return
}
if (canDownloadDirectly) {
handleDownload()
executeDownloadChoice(intent)
return
}
if (downloadPaymentPending) {
@@ -515,7 +491,10 @@ const SongDownload = ({ route }) => {
"Le paiement n'est pas encore confirmé. Si tu as déjà payé, patiente quelques instants.",
[
{ text: 'Attendre', style: 'cancel' },
{ text: 'Relancer le paiement', onPress: () => startSongDownloadCheckout({ force: true }) },
{
text: 'Relancer le paiement',
onPress: () => startSongDownloadCheckout(intent, { force: true }),
},
]
)
return
@@ -526,50 +505,45 @@ const SongDownload = ({ route }) => {
'Voulez-vous télécharger ce morceau pour 1,99€ ?',
[
{ text: 'Annuler', style: 'cancel' },
{ text: 'Acheter', onPress: () => startSongDownloadCheckout() },
{ text: 'Acheter', onPress: () => startSongDownloadCheckout(intent) },
],
{ cancelable: true }
)
return
}
startSongDownloadCheckout()
}, [
startSongDownloadCheckout(intent)
},
[
canDownloadDirectly,
downloadPaymentPending,
handleDownload,
executeDownloadChoice,
isCheckoutLaunching,
isDownloading,
isPublishing,
promptPurchaseConfirm,
startSongDownloadCheckout,
])
]
)
useEffect(() => {
if (!downloadPaymentPending || !hasPaidDownload || isDownloading) {
if (!downloadPaymentPending || !hasPaidDownload || !pendingDownloadIntent || isDownloading) {
return
}
const intent = pendingDownloadIntent
setDownloadPaymentPending(false)
handleDownload()
}, [downloadPaymentPending, handleDownload, hasPaidDownload, isDownloading])
const handleStopAdventure = useCallback(() => {
reopenAdventureModalOnFocusRef.current = false
setShowAdventureModal(false)
setAdventureChoice('stop')
}, [])
const handleGoHome = useCallback(() => {
navigate(Routes.BottomTab, {
screen: Routes.HomeStack,
params: {
screen: Routes.Home,
},
})
}, [])
setPendingDownloadIntent(null)
executeDownloadChoice(intent)
}, [
downloadPaymentPending,
executeDownloadChoice,
hasPaidDownload,
isDownloading,
pendingDownloadIntent,
])
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={handleBackPress} progress={84} />
{shouldShowDownloadPage ? (
<ScrollView
contentContainerStyle={[
styles.container,
@@ -578,11 +552,12 @@ const SongDownload = ({ route }) => {
]}
showsVerticalScrollIndicator={false}
>
<CreateLyricsHeader title="Ta pochette est validée" />
<ClubAdvantagesCard
style={styles.clubCardSpacing}
topContent={
<View style={styles.coverRow}>
<CreateLyricsHeader
title="Ton morceau est prêt"
subTitle="Choisis ce que tu souhaites faire maintenant."
/>
<View style={styles.summaryCard}>
{coverUrl ? (
<ExpoImage source={{ uri: coverUrl }} style={styles.coverImage} contentFit="cover" />
) : (
@@ -590,54 +565,52 @@ const SongDownload = ({ route }) => {
<Text style={styles.placeholderText}>Aucune pochette</Text>
</View>
)}
<Text style={styles.trackTitle}>{trackTitle}</Text>
</View>
<View style={styles.downloadColumn}>
<Pressable
style={[
styles.downloadTile,
(isDownloading || isCheckoutLaunching) && { opacity: 0.6 },
]}
onPress={handleDownloadPress}
disabled={isDownloading || isCheckoutLaunching}
>
<MaterialCommunityIcons name="download" size={22} color={Palette.white} />
<Text style={styles.downloadText}>{downloadLabel}</Text>
</Pressable>
{!canDownloadDirectly && downloadPaymentPending ? (
<Text style={styles.downloadPendingText}>
Paiement en attente de confirmation...
</Text>
) : null}
</View>
</View>
}
<View style={styles.choiceSection}>
<Text style={styles.sectionTitle}>Téléchargement</Text>
<Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
<BorderGradientButton
title="Option 1 · Téléchargement à des fins personnelles"
onPress={() => handleDownloadChoice('personal')}
disabled={isDownloading || isCheckoutLaunching || isPublishing}
loading={isDownloading && !isPublishing}
loadingText="Téléchargement en cours..."
height={58}
titleStyle={styles.choiceButtonText}
/>
<GradientButton
title="Revenir à l'accueil"
onPress={handleGoHome}
containerStyle={styles.returnHomeButton}
title={
isPublishing
? 'Téléchargement et publication en cours...'
: 'Option 2 · Télécharger et publier sur MusicLand'
}
onPress={() => handleDownloadChoice('publish')}
disabled={isDownloading || isCheckoutLaunching || isPublishing}
height={58}
textStyle={styles.choiceButtonText}
/>
{!canDownloadDirectly && downloadPaymentPending ? (
<Text style={styles.downloadPendingText}>Paiement en attente de confirmation...</Text>
) : null}
</View>
<View style={styles.continueSection}>
<Text style={styles.sectionTitle}>Continuer l'aventure</Text>
<Text style={styles.sectionDescription}>
Retrouve Théo dans l'espace Playback pour donner vie à ton morceau en vidéo.
</Text>
<BorderGradientButton
title="Continuer avec Théo vers l'espace Playback"
onPress={handleContinueAdventure}
disabled={isDownloading || isCheckoutLaunching || isPublishing}
height={58}
titleStyle={styles.choiceButtonText}
/>
</View>
</ScrollView>
) : (
<View style={styles.adventureGatePlaceholder} />
)}
<FullscreenIntroVideo url={showIntro} visible={!!showIntro} onClose={handleCloseIntro} />
<AdventureChoiceModal
isVisible={showAdventureModal}
setIsVisible={setShowAdventureModal}
onContinue={handleContinueAdventure}
onStop={handleStopAdventure}
/>
<ShareAndCreditsModal
isVisible={showShareModal}
setIsVisible={setShowShareModal}
onNavigateToCredits={() => {
setShowShareModal(false)
navigate(Routes.DownloadPrices, { action: 'song' })
}}
project={projectForStage}
selectedOption={selectedOption}
/>
</Page>
)
}
@@ -659,11 +632,15 @@ const styles = StyleSheet.create({
scrollContent: {
paddingBottom: gutters * 2.6,
},
coverRow: {
flexDirection: isWeb ? 'row' : 'column',
summaryCard: {
alignItems: 'center',
justifyContent: 'center',
gap: gutters * 0.8,
padding: gutters,
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.14)',
backgroundColor: 'rgba(5, 12, 24, 0.76)',
},
coverImage: {
width: 150,
@@ -680,24 +657,42 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterMedium,
color: Palette.grayMid,
},
downloadTile: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: gutters * 0.9,
paddingHorizontal: gutters * 1.2,
borderRadius: 14,
backgroundColor: '#8C4BFF',
borderWidth: 0,
},
downloadColumn: {
alignItems: 'center',
gap: 4,
},
downloadText: {
trackTitle: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 15,
fontSize: 18,
color: Palette.white,
textAlign: 'center',
},
choiceSection: {
gap: gutters * 0.65,
padding: gutters,
borderRadius: 20,
backgroundColor: 'rgba(37, 36, 56, 0.94)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
},
continueSection: {
gap: gutters * 0.65,
padding: gutters,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.07)',
},
sectionTitle: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
color: Palette.white,
},
sectionDescription: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
lineHeight: 20,
color: Palette.white,
opacity: 0.8,
marginBottom: 4,
},
choiceButtonText: {
textAlign: 'center',
fontSize: 14,
},
downloadPendingText: {
marginTop: 6,
@@ -706,18 +701,6 @@ const styles = StyleSheet.create({
color: Palette.white,
opacity: 0.8,
},
adventureGatePlaceholder: {
flex: 1,
},
clubCardSpacing: {
marginTop: gutters * 0.5,
},
returnHomeButton: {
width: '100%',
maxWidth: 320,
alignSelf: 'center',
marginTop: gutters * 0.6,
},
})
export default SongDownload
@@ -1,64 +0,0 @@
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { gutters, Palette } from '../../../styles'
import { FONT_FAMILY } from '../../../styles/Fonts'
import BorderGradientButton from '../../../components/BorderGradientButton'
import GradientButton from '../../../components/GradientButton'
import Overlay from '../../../components/Overlay'
const AdventureChoiceModal = ({ isVisible, setIsVisible, onContinue, onStop }) => {
return (
<Overlay isVisible={isVisible} setIsVisible={setIsVisible} blurIntensity={15}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Quelle est la suite ?</Text>
<View style={styles.modalActions}>
<GradientButton
title="Je continue l'aventure Musicland vers l'espace vidéo"
onPress={onContinue}
containerStyle={styles.modalAction}
textStyle={styles.modalActionText}
/>
<BorderGradientButton
title="J'arrête l'aventure et je télécharge ma chanson"
onPress={onStop}
containerStyle={styles.modalAction}
titleStyle={styles.modalActionText}
/>
</View>
</View>
</Overlay>
)
}
export default AdventureChoiceModal
const styles = StyleSheet.create({
modalCard: {
width: '90%',
maxWidth: 420,
alignSelf: 'center',
padding: gutters * 1.4,
borderRadius: 20,
backgroundColor: 'rgba(37, 36, 56, 0.96)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
gap: gutters * 0.8,
},
modalTitle: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
color: Palette.white,
textAlign: 'center',
marginBottom: 8,
},
modalActions: {
gap: gutters * 0.6,
marginTop: gutters * 0.6,
},
modalAction: {
width: '100%',
},
modalActionText: {
textAlign: 'center',
},
})
@@ -1,826 +0,0 @@
import React, { useMemo, useState, useCallback } from 'react'
import {
StyleSheet,
Text,
View,
Pressable,
Linking,
Alert,
ScrollView,
ActivityIndicator,
} from 'react-native'
import { MaterialCommunityIcons } from '@expo/vector-icons'
import * as Sharing from 'expo-sharing'
import { BlurView } from 'expo-blur'
import { Image as ExpoImage } from 'expo-image'
import { gutters, Palette } from '../../../styles'
import { FONT_FAMILY } from '../../../styles/Fonts'
import Overlay from '../../../components/Overlay'
import { useStripe } from '../../../providers/StripeProvider'
import CreditAmount from '../../../components/CreditAmount'
import GradientButton from '../../../components/GradientButton'
import AppCheckbox from '../../../components/AppCheckbox'
import { icons, planBadges } from '../../../assets'
import { isWeb } from '../../../hooks/useLayoutType'
import { navigate } from '../../../navigation/NavigationService'
import { Routes } from '../../../navigation'
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) ---
const formatCurrency = (amount, currency = 'eur') => {
if (typeof amount !== 'number') {
return null
}
const normalized = amount / 100
const upperCurrency =
typeof currency === 'string' && currency.trim() ? currency.trim().toUpperCase() : 'EUR'
if (typeof Intl !== 'undefined' && Intl.NumberFormat) {
try {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: upperCurrency,
minimumFractionDigits: 2,
}).format(normalized)
} catch (_error) {
// Ignore
}
}
return `${normalized.toFixed(2)} ${upperCurrency}`
}
const getIntervalLabel = (recurring) => {
if (!recurring || typeof recurring !== 'object') {
return null
}
const interval = recurring.interval || 'month'
const count = recurring.interval_count || 1
const vocabulary = {
day: { singular: 'jour', plural: 'jours' },
week: { singular: 'semaine', plural: 'semaines' },
month: { singular: 'mois', plural: 'mois' },
year: { singular: 'an', plural: 'ans' },
}
const terms = vocabulary[interval] || vocabulary.month
if (count <= 1) {
return `par ${terms.singular}`
}
return `tous les ${count} ${count > 1 ? terms.plural : terms.singular}`
}
// --- SUBSCRIPTION CARD COMPONENT (Copied) ---
function SubscriptionCard({ plan, selected, onSelect, isAnnual }) {
const planKey = getSubscriptionPlanKey(plan)
const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency)
const intervalLabel = getIntervalLabel(plan?.recurring)
const coinsPerMonth =
typeof plan?.coinsPerMonth === 'number' && Number.isFinite(plan.coinsPerMonth)
? Math.round(plan.coinsPerMonth)
: null
const cardDisplay = getSubscriptionCardDisplay({
planKey,
isAnnual,
formattedPrice,
intervalLabel,
coinsPerMonth,
fallbackTitle: plan?.product?.name || plan?.nickname || plan?.priceId || 'Abonnement',
})
const isPopular = cardDisplay.popular
const planBadgeKey = planKey
const planBadgeSource = planBadgeKey && planBadges[planBadgeKey] ? planBadges[planBadgeKey] : null
const handleSelect = React.useCallback(() => {
if (typeof onSelect === 'function' && plan?.priceId) {
onSelect(plan.priceId)
}
}, [onSelect, plan?.priceId])
return (
<Pressable
onPress={handleSelect}
style={({ pressed }) => [
styles.cardWrapper,
selected && styles.cardWrapperSelected,
pressed && styles.cardWrapperPressed,
plan?.active === false && styles.cardWrapperInactive,
isPopular && styles.cardWrapperPopular,
]}
disabled={plan?.active === false}
>
<BlurView intensity={18} tint="dark" style={styles.cardBlur}>
<View style={styles.cardContent}>
{plan?.badge ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>{plan.badge}</Text>
</View>
) : null}
<View style={styles.cardHeader}>
<View style={styles.titleRow}>
<Text style={styles.planName}>{cardDisplay.title}</Text>
{planBadgeSource ? (
<ExpoImage
source={planBadgeSource}
style={styles.planBadgeImage}
contentFit="contain"
/>
) : null}
</View>
</View>
{cardDisplay.primaryPrice || cardDisplay.creditsLabel ? (
<View style={styles.priceBlock}>
{cardDisplay.primaryPrice ? (
<Text style={styles.priceValue}>{cardDisplay.primaryPrice}</Text>
) : null}
{cardDisplay.secondaryPrice ? (
<Text style={styles.secondaryPriceText}>{cardDisplay.secondaryPrice}</Text>
) : null}
{cardDisplay.creditsLabel ? (
<Text style={styles.coinsPromoText}>{cardDisplay.creditsLabel}</Text>
) : null}
</View>
) : null}
{Array.isArray(cardDisplay.benefitItems) && cardDisplay.benefitItems.length ? (
<View style={styles.cardBenefits}>
{cardDisplay.benefitItems.map((item, index) =>
item?.type === 'plus' ? (
<Text key={`benefit-plus-${index}`} style={styles.benefitPlus}>
{item.text}
</Text>
) : (
<Text key={`benefit-text-${index}`} style={styles.downloadLabel}>
{item.text}
</Text>
)
)}
</View>
) : null}
{isPopular ? (
<View style={styles.popularBadge}>
<Text style={styles.popularBadgeText}>Le plus populaire !</Text>
</View>
) : null}
</View>
</BlurView>
</Pressable>
)
}
// --- MAIN WRAPPER COMPONENT ---
const ShareAndCreditsModal = ({
isVisible,
setIsVisible,
project,
selectedOption,
}) => {
const trackTitle = project?.title || 'Musicland Track'
const {
subscriptions,
coinPacks,
createSubscriptionCheckout,
createCoinPackCheckout,
isCatalogLoading,
} = useStripe()
const [selectedPeriodKey, setSelectedPeriodKey] = useState('monthly')
const [selectedPriceId, setSelectedPriceId] = useState(null)
const [processingPriceId, setProcessingPriceId] = useState(null)
const [errorMessage, setErrorMessage] = useState(null)
const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true)
const normalizedPlansByPeriod = useMemo(() => {
// Basic normalization/filter logic similar to Subscriptions.js
const normalize = (plans) => {
if (!Array.isArray(plans)) return []
return plans.filter((p) => p && p.priceId)
}
return {
monthly: normalize(subscriptions?.monthly),
annual: normalize(subscriptions?.annual),
}
}, [subscriptions])
// Set default selection
React.useEffect(() => {
const plans = normalizedPlansByPeriod[selectedPeriodKey] || []
if (plans.length > 0 && !selectedPriceId) {
// Optional: Auto-select popular/first plan
// But for now let's leave it null to force user choice or pick first
}
}, [normalizedPlansByPeriod, selectedPeriodKey, selectedPriceId])
const handleShare = async (platform) => {
const shareUrl =
project?.songUrl ||
project?.playbackUrl ||
selectedOption?.finalUrl ||
'https://musicland.ai'
const shareText = `Check out my new song "${trackTitle}" created with MusicLand! ${shareUrl}`
if (platform === 'whatsapp') {
Linking.openURL(`whatsapp://send?text=${encodeURIComponent(shareText)}`).catch(() => {
Alert.alert('Erreur', 'WhatsApp is not installed')
})
} else if (platform === 'facebook') {
Linking.openURL(
`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`
).catch(() => {
Alert.alert('Erreur', 'Unable to open Facebook')
})
} else if (platform === 'instagram' || platform === 'tiktok') {
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(shareUrl, { dialogTitle: musiclandShareHeading })
} else {
Linking.openURL(shareUrl)
}
}
}
const handleBuyCredits = async (pack) => {
const targetId = pack?.productId
if (!targetId) return
setProcessingPriceId(targetId)
setErrorMessage(null)
try {
await createCoinPackCheckout(targetId)
} catch (error) {
setErrorMessage(error?.message || 'Erreur lors de l\'achat de crédits.')
} finally {
setProcessingPriceId(null)
}
}
const handleSubscribe = async () => {
if (!selectedPriceId) return
setProcessingPriceId(selectedPriceId)
try {
await createSubscriptionCheckout(selectedPriceId)
} catch (error) {
setErrorMessage(error?.message || 'Erreur lors de l\'abonnement.')
} finally {
setProcessingPriceId(null)
}
}
const handleGoHome = useCallback(() => {
setIsVisible(false)
navigate(Routes.Home)
}, [setIsVisible])
const currentPlans = normalizedPlansByPeriod[selectedPeriodKey] || []
return (
<Overlay isVisible={isVisible} setIsVisible={setIsVisible} blurIntensity={20}>
<View style={styles.modalCard}>
<View style={styles.closeButtonContainer}>
<Pressable onPress={() => setIsVisible(false)} style={styles.closeButton}>
<MaterialCommunityIcons name="close" size={24} color={Palette.white} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* SHARE SECTION */}
<Text style={styles.modalTitle}>Partage ton succès !</Text>
<View style={styles.shareSection}>
<Text style={styles.shareSubtitle}>Partage mon morceau</Text>
<View style={styles.shareButtons}>
<Pressable style={styles.shareBtn} onPress={() => handleShare('whatsapp')}>
<MaterialCommunityIcons name="whatsapp" size={32} color="#25D366" />
</Pressable>
<Pressable style={styles.shareBtn} onPress={() => handleShare('facebook')}>
<MaterialCommunityIcons name="facebook" size={32} color="#1877F2" />
</Pressable>
<Pressable style={styles.shareBtn} onPress={() => handleShare('instagram')}>
<MaterialCommunityIcons name="instagram" size={32} color="#C13584" />
</Pressable>
<Pressable style={styles.shareBtn} onPress={() => handleShare('tiktok')}>
<MaterialCommunityIcons name="music-note" size={32} color="#000000" />
</Pressable>
</View>
</View>
{/* CREDITS SECTION */}
{coinPacks && coinPacks.length > 0 ? (
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>{COIN_PACK_SECTION_TITLE}</Text>
<View style={styles.creditsGrid}>
{coinPacks.map((pack) => {
const rawAmount = pack.unitAmount ?? pack.amount ?? pack.price ?? 0
const price = formatCurrency(rawAmount, pack.currency)
const coins = Number(pack.metadata?.coins || 0)
const packKey = getCoinPackKey(pack)
const details = packKey ? COIN_PACK_DETAILS[packKey] : null
const displayName = details?.label || pack.name
const creditsText = details?.creditsDisplay
const displayPrice = details?.priceDisplay || price || `${rawAmount / 100}`
const isPopular = details?.popular === true
return (
<View key={pack.productId} style={styles.creditCard}>
<View style={styles.creditInfoContainer}>
<Text style={styles.creditName}>{displayName}</Text>
{creditsText ? (
<Text style={styles.creditAmountCustom}>{creditsText}</Text>
) : (
<CreditAmount
value={coins}
iconSize={24}
textStyle={styles.creditAmountText}
/>
)}
{details?.disclaimer ? (
<Text style={styles.packDisclaimer}>{details.disclaimer}</Text>
) : null}
</View>
{isPopular ? (
<View style={styles.popularBadge}>
<Text style={styles.popularBadgeText}>Le plus populaire !</Text>
</View>
) : null}
<GradientButton
title={processingPriceId === pack.productId ? '...' : displayPrice}
onPress={() => handleBuyCredits(pack)}
disabled={Boolean(processingPriceId)}
style={styles.creditButton}
textStyle={styles.creditButtonText}
gradientStyle={{ paddingVertical: 8, paddingHorizontal: 16 }}
/>
</View>
)
})}
</View>
</View>
) : null}
{/* SUBSCRIPTIONS SECTION */}
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>Rejoignez le club MusicLand</Text>
{/* Period Toggle */}
<View style={styles.segmentedControl}>
{['monthly', 'annual'].map((key) => {
const isActive = selectedPeriodKey === key
const label = key === 'monthly' ? 'Mensuel' : 'Annuel'
const showAnnualPromo = key === 'annual'
return (
<Pressable
key={key}
onPress={() => {
setSelectedPeriodKey(key)
setSelectedPriceId(null)
}}
style={[styles.segmentButton, isActive && styles.segmentButtonActive]}
>
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive]}>
{label}
</Text>
{showAnnualPromo && (
<View style={styles.segmentBadge}>
<Text style={styles.segmentBadgeText}>-16%</Text>
</View>
)}
</Pressable>
)
})}
</View>
{/* Plans Grid */}
<View style={styles.plansGrid}>
{isCatalogLoading && !currentPlans.length ? (
<ActivityIndicator color={Palette.white} />
) : (
currentPlans.map((plan) => (
<View key={plan.priceId} style={{ width: isWeb ? '30%' : '100%' }}>
<SubscriptionCard
plan={plan}
selected={selectedPriceId === plan.priceId}
onSelect={setSelectedPriceId}
isAnnual={selectedPeriodKey === 'annual'}
/>
</View>
))
)}
</View>
<View style={{ marginTop: 20, width: '100%', alignItems: 'center' }}>
<GradientButton
title={processingPriceId && !coinPacks.find(p => p.productId === processingPriceId) ? 'Traitement...' : 'Choisir cet abonnement'}
onPress={handleSubscribe}
disabled={!selectedPriceId || Boolean(processingPriceId)}
style={{ width: isWeb ? 320 : '100%' }}
/>
</View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
{/* BROADCAST SECTION */}
<View style={styles.broadcastSection}>
<View style={styles.consentContainer}>
<AppCheckbox
selected={hasAcceptedPublication}
onPress={() => setHasAcceptedPublication((prev) => !prev)}
label="J'accepte de diffuser mon contenu sur la plateforme de streaming de MusicLand"
/>
</View>
<GradientButton
title="Diffuser"
onPress={handleGoHome}
disabled={!hasAcceptedPublication}
containerStyle={styles.broadcastButton}
/>
</View>
</ScrollView>
</View>
</Overlay>
)
}
export default ShareAndCreditsModal
const styles = StyleSheet.create({
modalCard: {
width: isWeb ? '90%' : '95%',
maxWidth: 1000,
height: isWeb ? '90%' : '85%',
alignSelf: 'center',
borderRadius: 24,
backgroundColor: 'rgba(23, 22, 33, 0.98)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
overflow: 'hidden',
padding: 0,
},
scrollContent: {
padding: gutters,
gap: gutters * 1.5,
paddingBottom: gutters * 2,
},
closeButtonContainer: {
position: 'absolute',
top: 12,
right: 12,
zIndex: 100,
},
closeButton: {
padding: 8,
backgroundColor: 'rgba(255,255,255,0.1)',
borderRadius: 20,
},
modalTitle: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 24,
color: Palette.white,
textAlign: 'center',
marginTop: 10,
},
shareSection: {
gap: 12,
alignItems: 'center',
},
shareSubtitle: {
fontSize: 16,
fontFamily: FONT_FAMILY.InterSemiBold,
color: Palette.white,
opacity: 0.9,
},
shareButtons: {
flexDirection: 'row',
gap: 20,
justifyContent: 'center',
},
shareBtn: {
padding: 8,
borderRadius: 50,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
sectionContainer: {
gap: 16,
width: '100%',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.08)',
paddingTop: gutters,
},
sectionTitle: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 20,
color: Palette.white,
textAlign: 'center',
},
creditsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 16,
justifyContent: 'center',
width: '100%',
},
creditCard: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 16,
alignItems: 'center',
justifyContent: 'flex-start',
width: isWeb ? 220 : '100%',
minHeight: 160,
gap: 12,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
creditInfoContainer: {
alignItems: 'center',
gap: 4,
},
creditName: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 14,
color: Palette.white,
textAlign: 'center',
marginBottom: 4,
},
creditAmountCustom: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 16,
color: Palette.white,
textAlign: 'center',
},
creditAmountText: {
fontSize: 20,
},
packDisclaimer: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 10,
color: 'rgba(255, 255, 255, 0.6)',
fontStyle: 'italic',
textAlign: 'center',
marginTop: 4,
},
creditButton: {
marginTop: 'auto',
width: '100%',
height: 36,
minHeight: 36,
},
creditButtonText: {
fontSize: 13,
},
segmentedControl: {
flexDirection: 'row',
alignSelf: 'center',
justifyContent: 'center',
padding: 4,
borderRadius: 999,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
segmentButton: {
paddingVertical: 8,
paddingHorizontal: 18,
borderRadius: 999,
position: 'relative',
},
segmentButtonActive: {
backgroundColor: 'rgba(255, 255, 255, 0.18)',
},
segmentLabel: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: 'rgba(255, 255, 255, 0.7)',
},
segmentLabelActive: {
color: Palette.white,
},
segmentBadge: {
position: 'absolute',
top: -6,
right: -8,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: Palette.red,
},
segmentBadgeText: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 11,
color: Palette.white,
},
plansGrid: {
width: '100%',
flexDirection: isWeb ? 'row' : 'column',
flexWrap: 'wrap',
justifyContent: 'center',
gap: 16,
},
// Subscription Card Styles
cardWrapper: {
width: '100%',
minHeight: 280,
borderRadius: 24,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
backgroundColor: 'rgba(12, 14, 18, 0.45)',
},
cardWrapperSelected: {
borderColor: Palette.primary,
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.35,
shadowRadius: 20,
elevation: 8,
},
cardWrapperPopular: {
borderColor: Palette.primary,
backgroundColor: 'rgba(112, 35, 247, 0.1)',
},
cardWrapperPressed: {
transform: [{ scale: 0.98 }],
},
cardWrapperInactive: {
opacity: 0.6,
},
cardBlur: {
flex: 1,
padding: 16,
gap: 16,
},
cardContent: {
flex: 1,
gap: 16,
},
badge: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(112, 35, 247, 0.25)',
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 4,
marginBottom: 8,
},
badgeText: {
color: Palette.primary,
fontSize: 11,
fontFamily: FONT_FAMILY.InterSemiBold,
},
cardHeader: {
gap: 4,
},
titleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
planName: {
color: Palette.white,
fontSize: 18,
fontFamily: FONT_FAMILY.InterBold,
},
planBadgeImage: {
width: 40,
height: 40,
},
cardSubtitle: {
color: 'rgba(255,255,255,0.7)',
fontSize: 13,
},
priceBlock: {
marginBottom: 8,
},
secondaryPriceText: {
color: Palette.primary,
fontSize: 15,
fontFamily: FONT_FAMILY.InterBold,
},
priceRow: {
flexDirection: 'row',
alignItems: 'baseline',
gap: 6,
},
priceValue: {
color: Palette.white,
fontSize: 20,
fontFamily: FONT_FAMILY.InterBold,
},
period: {
color: 'rgba(255,255,255,0.6)',
fontSize: 13,
},
coinsPromoText: {
color: Palette.primary,
fontSize: 14,
fontFamily: FONT_FAMILY.InterBold,
},
coinsPerMonthRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
coinsPerMonth: {
fontSize: 14,
color: Palette.primary,
},
coinsPerMonthSuffix: {
fontSize: 13,
color: Palette.primary,
},
cardBenefits: {
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: {
color: Palette.white,
fontSize: 13,
fontFamily: FONT_FAMILY.InterSemiBold,
},
benefitBlock: {
flexDirection: 'row',
},
benefitText: {
color: 'rgba(255,255,255,0.8)',
fontSize: 12,
},
benefitTextBold: {
fontFamily: FONT_FAMILY.InterSemiBold,
color: Palette.white,
},
cardBenefitsItem: {
color: 'rgba(255,255,255,0.8)',
fontSize: 12,
},
popularBadge: {
alignSelf: 'center',
backgroundColor: Palette.primary,
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
marginTop: 'auto',
},
popularBadgeText: {
color: Palette.white,
fontSize: 10,
fontFamily: FONT_FAMILY.InterBold,
textTransform: 'uppercase',
},
annualBadge: {
position: 'absolute',
top: 10,
right: 10,
backgroundColor: Palette.red + '80',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
annualBadgeText: {
color: Palette.white + '80',
fontSize: 11,
fontFamily: FONT_FAMILY.InterBold,
},
errorText: {
color: Palette.red,
textAlign: 'center',
marginTop: 10,
},
broadcastSection: {
marginTop: gutters,
paddingTop: gutters,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.08)',
gap: gutters,
width: '100%',
alignItems: 'center',
},
consentContainer: {
width: '100%',
paddingHorizontal: gutters,
},
broadcastButton: {
width: isWeb ? 320 : '100%',
},
})