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
+146 -144
View File
@@ -284,6 +284,8 @@ exports.validatePlaybackDraft = onCall({ region: REGION, timeoutSeconds: 540 },
playbackJobId: null, playbackJobId: null,
playbackCallbackId: null, playbackCallbackId: null,
playbackError: null, playbackError: null,
playbackPublishedOnMusicLand: false,
playbackPublishedOnMusicLandAt: null,
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true }
@@ -302,180 +304,180 @@ exports.playbackWebhook = onRequest(
secrets: [HEYGEN_API_KEY, HEYGEN_WEBHOOK_TOKEN], secrets: [HEYGEN_API_KEY, HEYGEN_WEBHOOK_TOKEN],
}, },
async (req, res) => { async (req, res) => {
if (req.method !== 'POST') { if (req.method !== 'POST') {
res.status(405).json({ ok: false, message: 'Method not allowed' }) res.status(405).json({ ok: false, message: 'Method not allowed' })
return
}
try {
const expectedToken = getSecretValue(HEYGEN_WEBHOOK_TOKEN, 'HEYGEN_WEBHOOK_TOKEN')
const token = trimString(req?.query?.token)
if (!token || token !== expectedToken) {
res.status(401).json({ ok: false, message: 'Unauthorized webhook token' })
return return
} }
const { eventType, callbackId, videoId } = extractWebhookPayload(req.body || {}) try {
const expectedToken = getSecretValue(HEYGEN_WEBHOOK_TOKEN, 'HEYGEN_WEBHOOK_TOKEN')
const token = trimString(req?.query?.token)
if (!callbackId && !videoId) { if (!token || token !== expectedToken) {
logger.warn('[HeyGen] webhook ignored: missing identifiers', { res.status(401).json({ ok: false, message: 'Unauthorized webhook token' })
eventType, return
bodyKeys: Object.keys(req.body || {}), }
})
res.status(400).json({ ok: false, message: 'Missing callback_id or video_id' })
return
}
const projectDoc = await findProjectForWebhook({ callbackId, videoId }) const { eventType, callbackId, videoId } = extractWebhookPayload(req.body || {})
if (!projectDoc) {
logger.info('[HeyGen] webhook ignored: project not found', {
callbackId,
videoId,
eventType,
})
res.status(202).json({ ok: true, ignored: true })
return
}
const project = projectDoc.data() || {} if (!callbackId && !videoId) {
const currentCallbackId = trimString(project?.playbackCallbackId) logger.warn('[HeyGen] webhook ignored: missing identifiers', {
const currentVideoId = trimString(project?.playbackJobId) eventType,
bodyKeys: Object.keys(req.body || {}),
})
res.status(400).json({ ok: false, message: 'Missing callback_id or video_id' })
return
}
if (callbackId && currentCallbackId && callbackId !== currentCallbackId) { const projectDoc = await findProjectForWebhook({ callbackId, videoId })
logger.info('[HeyGen] webhook ignored: stale callback_id', { if (!projectDoc) {
projectId: projectDoc.id, logger.info('[HeyGen] webhook ignored: project not found', {
callbackId, callbackId,
currentCallbackId, videoId,
}) eventType,
res.status(202).json({ ok: true, ignored: true })
return
}
if (videoId && currentVideoId && videoId !== currentVideoId) {
logger.info('[HeyGen] webhook ignored: stale video_id', {
projectId: projectDoc.id,
videoId,
currentVideoId,
})
res.status(202).json({ ok: true, ignored: true })
return
}
const canonicalVideoId = currentVideoId || videoId
if (!canonicalVideoId) {
logger.warn('[HeyGen] webhook ignored: no canonical video id', {
projectId: projectDoc.id,
callbackId,
})
res.status(202).json({ ok: true, ignored: true })
return
}
const canonicalVideo = await fetchCanonicalVideo(canonicalVideoId)
const canonicalStatus = normalizeStatus(canonicalVideo?.status)
if (canonicalStatus === 'completed') {
const canonicalVideoUrl = trimString(canonicalVideo?.video_url)
if (!canonicalVideoUrl) {
logger.warn('[HeyGen] webhook ignored: completed without video_url', {
projectId: projectDoc.id,
canonicalVideoId,
}) })
res.status(202).json({ ok: true, ignored: true }) res.status(202).json({ ok: true, ignored: true })
return return
} }
const notificationRef = db const project = projectDoc.data() || {}
.collection('notifications') const currentCallbackId = trimString(project?.playbackCallbackId)
.doc( const currentVideoId = trimString(project?.playbackJobId)
if (callbackId && currentCallbackId && callbackId !== currentCallbackId) {
logger.info('[HeyGen] webhook ignored: stale callback_id', {
projectId: projectDoc.id,
callbackId,
currentCallbackId,
})
res.status(202).json({ ok: true, ignored: true })
return
}
if (videoId && currentVideoId && videoId !== currentVideoId) {
logger.info('[HeyGen] webhook ignored: stale video_id', {
projectId: projectDoc.id,
videoId,
currentVideoId,
})
res.status(202).json({ ok: true, ignored: true })
return
}
const canonicalVideoId = currentVideoId || videoId
if (!canonicalVideoId) {
logger.warn('[HeyGen] webhook ignored: no canonical video id', {
projectId: projectDoc.id,
callbackId,
})
res.status(202).json({ ok: true, ignored: true })
return
}
const canonicalVideo = await fetchCanonicalVideo(canonicalVideoId)
const canonicalStatus = normalizeStatus(canonicalVideo?.status)
if (canonicalStatus === 'completed') {
const canonicalVideoUrl = trimString(canonicalVideo?.video_url)
if (!canonicalVideoUrl) {
logger.warn('[HeyGen] webhook ignored: completed without video_url', {
projectId: projectDoc.id,
canonicalVideoId,
})
res.status(202).json({ ok: true, ignored: true })
return
}
const notificationRef = db
.collection('notifications')
.doc(
buildPlaybackReadyNotificationId({ buildPlaybackReadyNotificationId({
projectId: projectDoc.id, projectId: projectDoc.id,
videoId: canonicalVideoId, videoId: canonicalVideoId,
}) })
) )
await db.runTransaction(async (transaction) => { await db.runTransaction(async (transaction) => {
const [latestProjectSnap, notificationSnap] = await Promise.all([ const [latestProjectSnap, notificationSnap] = await Promise.all([
transaction.get(projectDoc.ref), transaction.get(projectDoc.ref),
transaction.get(notificationRef), transaction.get(notificationRef),
]) ])
const latestProject = latestProjectSnap.data() || {} const latestProject = latestProjectSnap.data() || {}
const userId = trimString(latestProject?.userId) const userId = trimString(latestProject?.userId)
const projectTitle = trimString(latestProject?.title) || 'ton projet' const projectTitle = trimString(latestProject?.title) || 'ton projet'
transaction.set( transaction.set(
projectDoc.ref, projectDoc.ref,
{
playbackProvider: 'heygen',
playbackStatus: PLAYBACK_STATUS.DRAFT_READY,
playbackGenerating: false,
playbackDraftUrl: canonicalVideoUrl,
playbackJobId: canonicalVideoId,
playbackCompletedAt: FieldValue.serverTimestamp(),
playbackError: null,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
if (userId && !notificationSnap.exists) {
transaction.set(notificationRef, {
sender: 'SYSTEM',
receiver: userId,
receiverCollection: 'users',
title: 'Ton playback IA est prêt !',
message: `La vidéo IA de "${projectTitle}" est terminée. Tu peux maintenant la découvrir et la valider.`,
time: FieldValue.serverTimestamp(),
read: false,
readAt: null,
mailOnly: false,
data: {
type: 'PLAYBACK_GENERATION_SUCCESS',
projectId: projectDoc.id,
projectTitle,
},
})
}
})
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.DRAFT_READY })
return
}
if (canonicalStatus === 'failed') {
const failureMessage =
trimString(canonicalVideo?.failure_message) || 'La génération HeyGen a échoué'
await projectDoc.ref.set(
{ {
playbackProvider: 'heygen', playbackProvider: 'heygen',
playbackStatus: PLAYBACK_STATUS.DRAFT_READY, playbackStatus: PLAYBACK_STATUS.FAILED,
playbackGenerating: false, playbackGenerating: false,
playbackDraftUrl: canonicalVideoUrl, playbackError: failureMessage,
playbackJobId: canonicalVideoId, playbackJobId: canonicalVideoId,
playbackCompletedAt: FieldValue.serverTimestamp(),
playbackError: null,
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true }
) )
if (userId && !notificationSnap.exists) { res.status(200).json({ ok: true, status: PLAYBACK_STATUS.FAILED })
transaction.set(notificationRef, { return
sender: 'SYSTEM', }
receiver: userId,
receiverCollection: 'users', logger.info('[HeyGen] webhook acknowledged without terminal status', {
title: 'Ton playback IA est prêt !', projectId: projectDoc.id,
message: `La vidéo IA de "${projectTitle}" est terminée. Tu peux maintenant la découvrir et la valider.`, canonicalVideoId,
time: FieldValue.serverTimestamp(), canonicalStatus,
read: false, eventType,
readAt: null,
mailOnly: false,
data: {
type: 'PLAYBACK_GENERATION_SUCCESS',
projectId: projectDoc.id,
projectTitle,
},
})
}
}) })
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.DRAFT_READY }) res.status(202).json({ ok: true, status: canonicalStatus || 'processing' })
return } catch (error) {
logger.error('[HeyGen] webhook failed', {
message: error?.message || String(error || ''),
})
res.status(500).json({ ok: false, message: error?.message || 'Webhook failure' })
} }
if (canonicalStatus === 'failed') {
const failureMessage =
trimString(canonicalVideo?.failure_message) || 'La génération HeyGen a échoué'
await projectDoc.ref.set(
{
playbackProvider: 'heygen',
playbackStatus: PLAYBACK_STATUS.FAILED,
playbackGenerating: false,
playbackError: failureMessage,
playbackJobId: canonicalVideoId,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.FAILED })
return
}
logger.info('[HeyGen] webhook acknowledged without terminal status', {
projectId: projectDoc.id,
canonicalVideoId,
canonicalStatus,
eventType,
})
res.status(202).json({ ok: true, status: canonicalStatus || 'processing' })
} catch (error) {
logger.error('[HeyGen] webhook failed', {
message: error?.message || String(error || ''),
})
res.status(500).json({ ok: false, message: error?.message || 'Webhook failure' })
}
} }
) )
+17 -14
View File
@@ -36,21 +36,24 @@ exports.snapshotMonthlyTopSongs = onSchedule(
const { scheduleTime } = event const { scheduleTime } = event
const context = buildMonthContext(scheduleTime ? new Date(scheduleTime) : new Date()) 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
const data = doc.data() || {} .filter((doc) => doc.data()?.songPublishedOnMusicLand !== false)
return { .slice(0, 3)
rank: index + 1, .map((doc, index) => {
projectId: doc.id, const data = doc.data() || {}
title: data.title || null, return {
userId: data.userId || null, rank: index + 1,
userName: data.userName || null, projectId: doc.id,
coverUrl: data.coverUrl || null, title: data.title || null,
songUrl: data.songUrl || null, userId: data.userId || null,
views: data.views || 0, userName: data.userName || null,
} coverUrl: data.coverUrl || null,
}) songUrl: data.songUrl || null,
views: data.views || 0,
}
})
const docRef = firestore.collection('monthlyTopSongs').doc(context.monthKey) const docRef = firestore.collection('monthlyTopSongs').doc(context.monthKey)
+5 -1
View File
@@ -47,10 +47,14 @@ const usePlaylistMusicSearch = () => {
} }
}, [musics]) }, [musics])
const visibleMusics = (hydratedMusics ?? musics)?.filter(
(project) => project?.songPublishedOnMusicLand !== false
)
return { return {
search, search,
setSearch, setSearch,
musics: hydratedMusics ?? musics, musics: visibleMusics,
loading, loading,
hasSearch: normalizedSearch.length > 0, hasSearch: normalizedSearch.length > 0,
} }
+9 -2
View File
@@ -96,14 +96,21 @@ const useSearch = () => {
} }
}, [playbacks]) }, [playbacks])
const visibleMusics = (hydratedMusics ?? musics)?.filter(
(project) => project?.songPublishedOnMusicLand !== false
)
const visiblePlaybacks = (hydratedPlaybacks ?? playbacks)?.filter(
(project) => project?.playbackPublishedOnMusicLand !== false
)
return { return {
search, search,
setSearch, setSearch,
selected, selected,
setSelected, setSelected,
users, users,
musics: hydratedMusics ?? musics, musics: visibleMusics,
playbacks: hydratedPlaybacks ?? playbacks, playbacks: visiblePlaybacks,
loading: userLoading || musicLoading || playbackLoading, loading: userLoading || musicLoading || playbackLoading,
} }
} }
+5
View File
@@ -21,6 +21,7 @@ import Playback from '../screens/Playback/Playback'
import PlaybackAi from '../screens/Playback/PlaybackAi' import PlaybackAi from '../screens/Playback/PlaybackAi'
import PlaybackAiStatus from '../screens/Playback/PlaybackAiStatus' import PlaybackAiStatus from '../screens/Playback/PlaybackAiStatus'
import PlaybackGuide from '../screens/Playback/PlaybackGuide' import PlaybackGuide from '../screens/Playback/PlaybackGuide'
import PlaybackImageRightsConsent from '../screens/Playback/PlaybackImageRightsConsent'
import PlaybackOnboarding from '../screens/Playback/PlaybackOnboarding' import PlaybackOnboarding from '../screens/Playback/PlaybackOnboarding'
import RecordPlayback from '../screens/Playback/RecordPlayback' import RecordPlayback from '../screens/Playback/RecordPlayback'
import RecordedPlayback from '../screens/Playback/RecordedPlayback' import RecordedPlayback from '../screens/Playback/RecordedPlayback'
@@ -232,6 +233,10 @@ const baseScreens = [
name: Routes.Playback, name: Routes.Playback,
component: Playback, component: Playback,
}, },
{
name: Routes.PlaybackImageRightsConsent,
component: PlaybackImageRightsConsent,
},
{ {
name: Routes.PlaybackGuide, name: Routes.PlaybackGuide,
component: PlaybackGuide, component: PlaybackGuide,
+1
View File
@@ -57,6 +57,7 @@ export const Routes = {
PlaybackOnboarding: 'PlaybackOnboarding', PlaybackOnboarding: 'PlaybackOnboarding',
Playback: 'Playback', Playback: 'Playback',
PlaybackImageRightsConsent: 'PlaybackImageRightsConsent',
PlaybackGuide: 'PlaybackGuide', PlaybackGuide: 'PlaybackGuide',
PlaybackAi: 'PlaybackAi', PlaybackAi: 'PlaybackAi',
PlaybackAiStatus: 'PlaybackAiStatus', PlaybackAiStatus: 'PlaybackAiStatus',
+1
View File
@@ -35,6 +35,7 @@ const HIDDEN_ROUTE_NAMES = new Set([
Routes.PlaybackExample, Routes.PlaybackExample,
Routes.PlaybackOnboarding, Routes.PlaybackOnboarding,
Routes.Playback, Routes.Playback,
Routes.PlaybackImageRightsConsent,
Routes.PlaybackGuide, Routes.PlaybackGuide,
Routes.PlaybackAi, Routes.PlaybackAi,
Routes.PlaybackAiStatus, Routes.PlaybackAiStatus,
+18 -3
View File
@@ -72,7 +72,12 @@ const HitParade = () => {
}, [selectedLanguage]) }, [selectedLanguage])
const { data: allSongs } = useDataFromRef({ const { data: allSongs } = useDataFromRef({
ref: allSongsRef, 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, simpleRef: false,
listener: true, listener: true,
condition: true, condition: true,
@@ -81,7 +86,12 @@ const HitParade = () => {
const { data: topMonthSongs } = useDataFromRef({ const { data: topMonthSongs } = useDataFromRef({
ref: projectsRef.where('monthViews', '>', 0).orderBy('monthViews', 'desc').limit(20), 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, simpleRef: false,
listener: false, listener: false,
condition: selectedLanguage === 'all', condition: selectedLanguage === 'all',
@@ -90,7 +100,12 @@ const HitParade = () => {
const { data: allTimeSongs } = useDataFromRef({ const { data: allTimeSongs } = useDataFromRef({
ref: projectsRef.where('views', '>', 0).orderBy('views', 'desc').limit(20), 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, simpleRef: false,
listener: false, listener: false,
condition: selectedLanguage === 'all', condition: selectedLanguage === 'all',
@@ -112,7 +112,10 @@ const SearchResultsList = ({
return musics return musics
} }
return musics.filter((project) => project?.hasPlayback !== true) return musics.filter(
(project) =>
project?.hasPlayback !== true || project?.playbackPublishedOnMusicLand === false
)
}, [musics, selected]) }, [musics, selected])
const shouldShowMusics = const shouldShowMusics =
+16 -4
View File
@@ -46,7 +46,11 @@ const Playback = ({ route, navigation }) => {
const onPressRecord = useCallback(() => { const onPressRecord = useCallback(() => {
if (isManualBlocked) return if (isManualBlocked) return
navigate(Routes.RecordPlayback, { project, returnToAdventureModal }) navigate(Routes.PlaybackImageRightsConsent, {
project,
flow: 'record',
returnToAdventureModal,
})
}, [isManualBlocked, project, returnToAdventureModal]) }, [isManualBlocked, project, returnToAdventureModal])
const onPressPlaybackAi = useCallback(() => { const onPressPlaybackAi = useCallback(() => {
@@ -54,8 +58,12 @@ const Playback = ({ route, navigation }) => {
navigate(Routes.PlaybackAiStatus, { project }) navigate(Routes.PlaybackAiStatus, { project })
return return
} }
navigate(Routes.PlaybackAi, { project }) navigate(Routes.PlaybackImageRightsConsent, {
}, [project]) project,
flow: 'ai',
returnToAdventureModal,
})
}, [project, returnToAdventureModal])
return ( return (
<Page <Page
@@ -86,7 +94,11 @@ const Playback = ({ route, navigation }) => {
onPress={onPressRecord} onPress={onPressRecord}
disabled={!project || isManualBlocked} disabled={!project || isManualBlocked}
/> />
<BorderGradientButton title={aiButtonLabel} onPress={onPressPlaybackAi} disabled={!project} /> <BorderGradientButton
title={aiButtonLabel}
onPress={onPressPlaybackAi}
disabled={!project}
/>
{isManualBlocked ? ( {isManualBlocked ? (
<Text style={styles.blockedText}> <Text style={styles.blockedText}>
Le playback manuel est bloqué tant que le playback IA est en cours ou en attente de 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 [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts')
const { data: rawPlaybacks = [], loadMore } = useDataFromRef({ const { data: rawPlaybacks = [], loadMore } = useDataFromRef({
ref: projectsRef.where('playbackUrl', '!=', null), ref: projectsRef.where('playbackUrl', '!=', null),
format: (docs) => docs.filter((item) => item?.playbackPublishedOnMusicLand !== false),
simpleRef: false, simpleRef: false,
listener: false, listener: false,
usePagination: true, usePagination: true,
+1
View File
@@ -38,6 +38,7 @@ const Playbacks = () => {
loading, loading,
} = useDataFromRef({ } = useDataFromRef({
ref: projectsRef.where('playbackUrl', '!=', null), ref: projectsRef.where('playbackUrl', '!=', null),
format: (docs) => docs.filter((item) => item?.playbackPublishedOnMusicLand !== false),
simpleRef: false, simpleRef: false,
listener: false, listener: false,
usePagination: true, usePagination: true,
@@ -40,12 +40,10 @@ const chunkArray = (items = [], size = 10) => {
const PlaybackCharts = ({ onPlaybackPress }) => { const PlaybackCharts = ({ onPlaybackPress }) => {
const isWeb = Platform.OS === 'web' const isWeb = Platform.OS === 'web'
const [selectedCategory, setSelectedCategory] = useState('Playbacks') const [selectedCategory, setSelectedCategory] = useState('Playbacks')
const { const { data: allPlaybacks, loading: allPlaybacksLoading } = useDataFromRef({
data: allPlaybacks,
loading: allPlaybacksLoading,
} = useDataFromRef({
ref: projectsRef.where('playbackUrl', '!=', null), ref: projectsRef.where('playbackUrl', '!=', null),
format: (docs) => docs.filter((item) => !!item?.playbackUrl), format: (docs) =>
docs.filter((item) => !!item?.playbackUrl && item?.playbackPublishedOnMusicLand !== false),
simpleRef: false, simpleRef: false,
listener: true, listener: true,
condition: true, condition: true,
@@ -53,7 +51,8 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
const { data: topMonthPlaybacks, loading: topMonthLoading } = useDataFromRef({ const { data: topMonthPlaybacks, loading: topMonthLoading } = useDataFromRef({
ref: projectsRef.where('monthViews', '>', 0).orderBy('monthViews', 'desc').limit(20), 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, simpleRef: false,
listener: false, listener: false,
condition: true, condition: true,
@@ -61,7 +60,8 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
const { data: allTimePlaybacks, loading: allTimeLoading } = useDataFromRef({ const { data: allTimePlaybacks, loading: allTimeLoading } = useDataFromRef({
ref: projectsRef.where('views', '>', 0).orderBy('views', 'desc').limit(20), 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, simpleRef: false,
listener: false, listener: false,
condition: true, condition: true,
+199 -181
View File
@@ -1,14 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' import { Platform, ScrollView, StyleSheet, Text, View } from 'react-native'
import { MaterialCommunityIcons } from '@expo/vector-icons'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import * as FileSystem from 'expo-file-system' import * as FileSystem from 'expo-file-system'
import { shareAsync } from 'expo-sharing' import { shareAsync } from 'expo-sharing'
import { background } from '../../assets' import { background } from '../../assets'
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 firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../../config/firebase' import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../../config/firebase'
import { uploadFileToFirebase } from '../../helpers/uploadToFirebase' import { uploadFileToFirebase } from '../../helpers/uploadToFirebase'
import Page from '../../layouts/Page' import Page from '../../layouts/Page'
@@ -20,15 +19,12 @@ import { Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts' import { FONT_FAMILY } from '../../styles/Fonts'
import { size } from '../../styles/Style' import { size } from '../../styles/Style'
import { getBlobForUrl, releaseBlobUrl } from '../../utils/blobUrlCache' import { getBlobForUrl, releaseBlobUrl } from '../../utils/blobUrlCache'
import ClubAdvantagesCard from '../Profile/components/ClubAdvantagesCard'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
import { Image as ExpoImage } from 'expo-image' import { Image as ExpoImage } from 'expo-image'
import SubscriptionConfirmModal from '../../components/SubscriptionConfirmModal'
import { isWeb } from '../../hooks/useLayoutType'
import { clampSyncOffsetMs, toSyncOffsetSeconds } from '../../utils/playbackSync' import { clampSyncOffsetMs, toSyncOffsetSeconds } from '../../utils/playbackSync'
const triggerDownload = async (url, title) => { const triggerDownload = async (url, title) => {
if (!url) return if (!url) return false
const baseName = (title || 'Playback').toString().trim() || 'Playback' const baseName = (title || 'Playback').toString().trim() || 'Playback'
const sanitized = baseName.replace(/[\\/:*?"<>|]/g, '-') const sanitized = baseName.replace(/[\\/:*?"<>|]/g, '-')
const extension = guessExtension(url) || 'mp4' const extension = guessExtension(url) || 'mp4'
@@ -53,6 +49,7 @@ const triggerDownload = async (url, title) => {
document.body.removeChild(anchor) document.body.removeChild(anchor)
// Revoke with delay to ensure browser captures it // Revoke with delay to ensure browser captures it
setTimeout(() => URL.revokeObjectURL(blobUrl), 150) setTimeout(() => URL.revokeObjectURL(blobUrl), 150)
return true
} catch (error) { } catch (error) {
console.log('[PlaybackDownload] web download fallback', { console.log('[PlaybackDownload] web download fallback', {
message: error?.message, message: error?.message,
@@ -67,6 +64,7 @@ const triggerDownload = async (url, title) => {
document.body.appendChild(anchor) document.body.appendChild(anchor)
anchor.click() anchor.click()
document.body.removeChild(anchor) document.body.removeChild(anchor)
return true
} catch (fallbackError) { } catch (fallbackError) {
console.log('[PlaybackDownload] anchor fallback failed', { console.log('[PlaybackDownload] anchor fallback failed', {
message: fallbackError?.message, message: fallbackError?.message,
@@ -74,7 +72,8 @@ const triggerDownload = async (url, title) => {
// Ultimate fallback: direct window open // Ultimate fallback: direct window open
try { try {
window.open(url, '_blank', 'noopener,noreferrer') window.open(url, '_blank', 'noopener,noreferrer')
} catch { } return true
} catch {}
} }
} }
} else { } else {
@@ -87,11 +86,13 @@ const triggerDownload = async (url, title) => {
if (shareAsync) { if (shareAsync) {
await shareAsync(localUri) await shareAsync(localUri)
} }
return true
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
} }
return false
} }
const guessExtension = (inputUri = '') => { const guessExtension = (inputUri = '') => {
@@ -146,7 +147,12 @@ const PlaybackDownload = ({ route }) => {
const { currentUID, selectedProject } = useUserData() const { currentUID, selectedProject } = useUserData()
const { hasActiveSubscription, hasPurchased, videos } = useUser() || {} const { hasActiveSubscription, hasPurchased, videos } = useUser() || {}
const { createPlaybackDownloadCheckout } = useStripe() 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 action = routeAction || 'playback'
const syncOffsetMs = clampSyncOffsetMs(routeSyncOffsetMs) const syncOffsetMs = clampSyncOffsetMs(routeSyncOffsetMs)
console.log('[PlaybackDownload] route params', { console.log('[PlaybackDownload] route params', {
@@ -158,15 +164,13 @@ const PlaybackDownload = ({ route }) => {
}) })
const { setIsLoading, setTooltip } = useMinuit() const { setIsLoading, setTooltip } = useMinuit()
const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false) const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [pendingPlaybackUrl, setPendingPlaybackUrl] = useState(routeProject?.playbackUrl || null) const [pendingPlaybackUrl, setPendingPlaybackUrl] = useState(routeProject?.playbackUrl || null)
const [isPublishing, setIsPublishing] = useState(false) const [isPublishing, setIsPublishing] = useState(false)
const [isDownloading, setIsDownloading] = useState(false) const [isDownloading, setIsDownloading] = useState(false)
const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false) const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false)
const [downloadPaymentPending, setDownloadPaymentPending] = useState(false) const [downloadPaymentPending, setDownloadPaymentPending] = useState(false)
const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true) const [pendingDownloadIntent, setPendingDownloadIntent] = useState(null)
const hasShownAfterPlaybackRef = useRef(false) const hasShownAfterPlaybackRef = useRef(false)
const publishSuccessMessage = action === 'playback' ? 'Playback publié !' : 'Chanson publiée !'
const projectForDownload = useMemo(() => { const projectForDownload = useMemo(() => {
if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) { if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) {
@@ -178,9 +182,9 @@ const PlaybackDownload = ({ route }) => {
const playbackDownloadStatus = projectForDownload?.playbackDownloadPurchase?.status || null const playbackDownloadStatus = projectForDownload?.playbackDownloadPurchase?.status || null
const hasPaidPlaybackDownload = playbackDownloadStatus === 'paid' const hasPaidPlaybackDownload = playbackDownloadStatus === 'paid'
const canDownloadPlayback = hasActiveSubscription || hasPaidPlaybackDownload || hasPurchased const canDownloadPlayback = hasActiveSubscription || hasPaidPlaybackDownload || hasPurchased
const downloadLabel = canDownloadPlayback const downloadAccessLabel = canDownloadPlayback
? 'Télécharger le playback' ? 'Téléchargement inclus avec ton accès actuel.'
: 'Acheter le playback pour 1,99' : 'Le téléchargement de ce playback coûte 1,99 €.'
const afterPlaybackUrl = useMemo(() => { const afterPlaybackUrl = useMemo(() => {
if (!videos) return null if (!videos) return null
@@ -302,36 +306,66 @@ const PlaybackDownload = ({ route }) => {
releaseBlobUrl(uri || null) releaseBlobUrl(uri || null)
setIsLoading(false) setIsLoading(false)
} }
}, [action, currentUID, pendingPlaybackUrl, projectForDownload, resolveAudioUrl, setIsLoading, syncOffsetMs, uri])
const handleDownloadUri = useCallback(async () => {
if (isPublishing || isDownloading) return
try {
setIsDownloading(true)
const resultURI = await processMerge()
setTooltip({
type: 'success',
text: 'Playback prêt à télécharger',
})
await triggerDownload(resultURI, projectForDownload?.title)
} catch (error) {
setTooltip({
type: 'error',
text: String(error?.message || 'Erreur lors de la publication du playback'),
})
} finally {
setIsDownloading(false)
}
}, [ }, [
isDownloading, action,
isPublishing, currentUID,
processMerge, pendingPlaybackUrl,
projectForDownload, projectForDownload,
setTooltip, resolveAudioUrl,
setIsLoading,
syncOffsetMs,
uri,
]) ])
const executePlaybackChoice = useCallback(
async (intent) => {
if (intent !== 'personal' && intent !== 'publish') return
if (isPublishing || isDownloading || !projectForDownload?.id) return
try {
setIsDownloading(true)
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:
intent === 'publish'
? 'Playback téléchargé et publié sur MusicLand !'
: 'Playback téléchargé à des fins personnelles.',
})
if (intent === 'publish') {
navigate(Routes.Home)
}
} catch (error) {
setTooltip({
type: 'error',
text: String(error?.message || "Impossible de terminer l'opération."),
})
} finally {
setIsDownloading(false)
setIsPublishing(false)
}
},
[isDownloading, isPublishing, processMerge, projectForDownload, setTooltip]
)
const startPlaybackDownloadCheckout = useCallback( const startPlaybackDownloadCheckout = useCallback(
async ({ force = false } = {}) => { async (intent, { force = false } = {}) => {
if (!projectForDownload?.id) { if (!projectForDownload?.id) {
setTooltip({ setTooltip({
type: 'error', type: 'error',
@@ -344,15 +378,17 @@ const PlaybackDownload = ({ route }) => {
return return
} }
setPendingDownloadIntent(intent)
setIsCheckoutLaunching(true) setIsCheckoutLaunching(true)
setDownloadPaymentPending(true) setDownloadPaymentPending(true)
try { try {
await createPlaybackDownloadCheckout(projectForDownload.id) await createPlaybackDownloadCheckout(projectForDownload.id)
} catch (error) { } catch (error) {
setDownloadPaymentPending(false) setDownloadPaymentPending(false)
setPendingDownloadIntent(null)
setTooltip({ setTooltip({
type: 'error', type: 'error',
text: error?.message || "Une erreur est survenue lors du paiement.", text: error?.message || 'Une erreur est survenue lors du paiement.',
}) })
} finally { } finally {
setIsCheckoutLaunching(false) setIsCheckoutLaunching(false)
@@ -367,60 +403,45 @@ const PlaybackDownload = ({ route }) => {
] ]
) )
const handleDownloadPress = async () => { const handlePlaybackChoice = useCallback(
if (!canDownloadPlayback) return startPlaybackDownloadCheckout() (intent) => {
if (isDownloading || isPublishing || isCheckoutLaunching) return
if (isDownloading || isPublishing) return if (canDownloadPlayback) {
executePlaybackChoice(intent)
try { return
setIsDownloading(true) }
const url = await processMerge() startPlaybackDownloadCheckout(intent)
await triggerDownload(url, projectForDownload?.title) },
} catch (err) { [
setTooltip({ type: 'error', text: err.message }) canDownloadPlayback,
} finally { executePlaybackChoice,
setIsDownloading(false) isCheckoutLaunching,
} isDownloading,
} isPublishing,
startPlaybackDownloadCheckout,
]
)
useEffect(() => { useEffect(() => {
if (!downloadPaymentPending || !hasPaidPlaybackDownload || isDownloading) { if (
!downloadPaymentPending ||
!hasPaidPlaybackDownload ||
!pendingDownloadIntent ||
isDownloading
) {
return return
} }
const intent = pendingDownloadIntent
setDownloadPaymentPending(false) setDownloadPaymentPending(false)
handleDownloadUri() setPendingDownloadIntent(null)
}, [downloadPaymentPending, handleDownloadUri, hasPaidPlaybackDownload, isDownloading]) executePlaybackChoice(intent)
}, [
const handlePublish = async () => { downloadPaymentPending,
if (!hasAcceptedPublication) return executePlaybackChoice,
hasPaidPlaybackDownload,
if (isPublishing) return isDownloading,
pendingDownloadIntent,
try { ])
setIsPublishing(true)
const url = await processMerge()
await projectsRef.doc(projectForDownload.id).set(
{
playbackUrl: url,
updatedAt: serverTimestamp(),
},
{ merge: true }
)
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)
}
}
return ( return (
<> <>
<FullscreenIntroVideo <FullscreenIntroVideo
@@ -429,75 +450,59 @@ const PlaybackDownload = ({ route }) => {
onClose={() => setIsAfterPlaybackVideoVisible(false)} onClose={() => setIsAfterPlaybackVideoVisible(false)}
/> />
<Page <Page
backgroundImg={ backgroundImg={action === 'playback' ? background.playbackBG2 : background.productionBG2}
action === 'playback' ? background.playbackBG2 : background.productionBG2
}
backgroundColor="#07111B" backgroundColor="#07111B"
headerType="NONE" headerType="NONE"
> >
<MusicLandHeader onPressBack={goBack} progress={50} /> <MusicLandHeader onPressBack={goBack} progress={50} />
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<CreateLyricsHeader title={'Publier ton playback'} /> <CreateLyricsHeader
title="Ton playback est prêt"
<ClubAdvantagesCard subTitle="Choisis l'utilisation de ta vidéo."
style={styles.clubCardSpacing}
topContent={
<View style={styles.coverRow}>
{projectForDownload?.coverUrl ? (
<ExpoImage
source={{ uri: projectForDownload?.coverUrl }}
style={styles.coverImage}
contentFit="cover"
/>
) : (
<View style={[styles.coverImage, styles.coverPlaceholder]}>
<Text style={styles.placeholderText}>Aucune pochette</Text>
</View>
)}
<View style={styles.downloadColumn}>
<Pressable
style={[
styles.downloadTile,
(isPublishing || isDownloading || isCheckoutLaunching) && { opacity: 0.6 },
]}
onPress={handleDownloadPress}
disabled={isPublishing || isDownloading || isCheckoutLaunching}
>
<MaterialCommunityIcons name="download" size={22} color={Palette.white} />
<Text style={styles.downloadText}>{downloadLabel}</Text>
</Pressable>
{!canDownloadPlayback && downloadPaymentPending ? (
<Text style={styles.downloadPendingText}>
Paiement en attente de confirmation...
</Text>
) : null}
</View>
</View>
}
/> />
<View style={styles.publishConsentContainer}> <View style={styles.summaryCard}>
<AppCheckbox {projectForDownload?.coverUrl ? (
selected={hasAcceptedPublication} <ExpoImage
onPress={() => setHasAcceptedPublication((prevState) => !prevState)} source={{ uri: projectForDownload.coverUrl }}
label="J'accepte la diffusion de mon playback sur Musicland." style={styles.coverImage}
/> contentFit="cover"
<Text style={styles.publishConsentDescription}> />
Cette confirmation est requise avant de lancer la publication. ) : (
</Text> <View style={[styles.coverImage, styles.coverPlaceholder]}>
<Text style={styles.placeholderText}>Aucune pochette</Text>
</View>
)}
<Text style={styles.playbackTitle}>{projectForDownload?.title || 'Mon playback'}</Text>
</View> </View>
<BorderGradientButton <View style={styles.choiceSection}>
title={'Publier'} <Text style={styles.sectionTitle}>Téléchargement</Text>
onPress={() => { <Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
handlePublish() <BorderGradientButton
}} title="Option 1 · Téléchargement à des fins personnelles"
disabled={isPublishing || !hasAcceptedPublication} onPress={() => handlePlaybackChoice('personal')}
loading={isPublishing} disabled={isPublishing || isDownloading || isCheckoutLaunching}
loadingText="Publication en cours..." loading={isDownloading && !isPublishing}
containerStyle={styles.continueButton} 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>
) : null}
</View>
</ScrollView> </ScrollView>
</Page> </Page>
</> </>
@@ -509,15 +514,22 @@ export default PlaybackDownload
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flexGrow: 1, flexGrow: 1,
width: '100%',
maxWidth: 620,
alignSelf: 'center',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
gap: 14, gap: 14,
}, },
coverRow: { summaryCard: {
flexDirection: isWeb ? 'row' : 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: isWeb ? 'center' : 'center', justifyContent: 'center',
gap: 12, gap: 12,
padding: 16,
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.14)',
backgroundColor: 'rgba(5, 12, 24, 0.76)',
}, },
coverImage: { coverImage: {
...size({ size: 140 }), ...size({ size: 140 }),
@@ -525,23 +537,45 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.18)', borderColor: 'rgba(255, 255, 255, 0.18)',
}, },
downloadTile: { coverPlaceholder: {
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, justifyContent: 'center',
paddingVertical: 10, backgroundColor: 'rgba(255, 255, 255, 0.06)',
paddingHorizontal: 14,
borderRadius: 14,
backgroundColor: '#8C4BFF',
}, },
downloadText: { placeholderText: {
fontFamily: FONT_FAMILY.InterMedium,
color: Palette.grayMid,
},
playbackTitle: {
fontFamily: FONT_FAMILY.InterSemiBold, 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, color: Palette.white,
}, },
downloadColumn: { sectionDescription: {
alignItems: 'center', fontFamily: FONT_FAMILY.InterRegular,
gap: 4, fontSize: 14,
lineHeight: 20,
color: Palette.white,
opacity: 0.8,
marginBottom: 4,
},
choiceButtonText: {
textAlign: 'center',
fontSize: 14,
}, },
downloadPendingText: { downloadPendingText: {
marginTop: 6, marginTop: 6,
@@ -550,20 +584,4 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
opacity: 0.8, 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], refreshArray: [targetUserId],
}) })
const displayedProjects = isSelf ? selfProjects : otherUserProjects const displayedProjects = isSelf
? selfProjects
: otherUserProjects.filter((project) => project?.songPublishedOnMusicLand !== false)
const displayedPlaybacksSource = isSelf ? selfPlaybacks : otherUserProjects const displayedPlaybacksSource = isSelf ? selfPlaybacks : otherUserProjects
const displayedPlaybacks = Array.isArray(displayedPlaybacksSource) const displayedPlaybacks = Array.isArray(displayedPlaybacksSource)
? displayedPlaybacksSource.filter((project) => project?.playbackUrl) ? displayedPlaybacksSource.filter(
(project) =>
project?.playbackUrl && (isSelf || project?.playbackPublishedOnMusicLand !== false)
)
: [] : []
const showHeader = isWeb const showHeader = isWeb
const showBackButton = !params?.noBack && !isWeb const showBackButton = !params?.noBack && !isWeb
+4
View File
@@ -124,6 +124,10 @@ const SongReady = () => {
playbackUrl: null, playbackUrl: null,
playbackStatus: null, playbackStatus: null,
playbackGenerating: null, playbackGenerating: null,
songPublishedOnMusicLand: false,
songPublishedOnMusicLandAt: null,
playbackPublishedOnMusicLand: false,
playbackPublishedOnMusicLandAt: null,
}) })
// Incrémenter le compteur de chansons générées // Incrémenter le compteur de chansons générées
+246 -263
View File
@@ -1,22 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Image as ExpoImage } from 'expo-image' import { Image as ExpoImage } from 'expo-image'
import { import { Linking, Platform, ScrollView, StyleSheet, Text, View, Alert } from 'react-native'
BackHandler,
Linking,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
Alert,
} from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import * as FileSystem from 'expo-file-system' import * as FileSystem from 'expo-file-system'
import * as Sharing from 'expo-sharing' import * as Sharing from 'expo-sharing'
import BorderGradientButton from '../../components/BorderGradientButton'
import GradientButton from '../../components/GradientButton' import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
import { projectsRef, serverTimestamp } from '../../config/firebase'
import Page from '../../layouts/Page' import Page from '../../layouts/Page'
import { Routes } from '../../navigation/Routes' import { Routes } from '../../navigation/Routes'
import { goBack, navigate, push } from '../../navigation/NavigationService' import { goBack, navigate, push } from '../../navigation/NavigationService'
@@ -27,15 +18,11 @@ import { FONT_FAMILY } from '../../styles/Fonts'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
import { background } from '../../assets' import { background } from '../../assets'
import { getStageAction } from '../../utils/projectStages' import { getStageAction } from '../../utils/projectStages'
import ClubAdvantagesCard from '../Profile/components/ClubAdvantagesCard'
import useGlobalLoading from '../../hooks/useGlobalLoading' import useGlobalLoading from '../../hooks/useGlobalLoading'
import { MaterialCommunityIcons } from '@expo/vector-icons'
import { isWeb } from '../../hooks/useLayoutType' import { isWeb } from '../../hooks/useLayoutType'
import { getArtistDisplayName } from '../../utils/artistName' import { getArtistDisplayName } from '../../utils/artistName'
import { toDate } from '../../utils/dateFormatting' import { toDate } from '../../utils/dateFormatting'
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo' import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
import AdventureChoiceModal from './components/AdventureChoiceModal'
import ShareAndCreditsModal from './components/ShareAndCreditsModal'
import { musiclandShareHeading } from '../../data' import { musiclandShareHeading } from '../../data'
const SongDownload = ({ route }) => { const SongDownload = ({ route }) => {
@@ -54,16 +41,12 @@ const SongDownload = ({ route }) => {
const [isDownloading, setIsDownloading] = useState(false) const [isDownloading, setIsDownloading] = useState(false)
const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false) const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false)
const [downloadPaymentPending, setDownloadPaymentPending] = useState(false) const [downloadPaymentPending, setDownloadPaymentPending] = useState(false)
const [pendingDownloadIntent, setPendingDownloadIntent] = useState(null)
const [isPublishing, setIsPublishing] = useState(false)
const { videos } = useUser() const { videos } = useUser()
const part1Url = isWeb ? videos?.benaiPart1 : videos?.benaiPart1 const part1Url = isWeb ? videos?.benaiPart1 : videos?.benaiPart1
const [showIntro, setShowIntro] = useState(null) const [showIntro, setShowIntro] = useState(null)
const [showAdventureModal, setShowAdventureModal] = useState(false) const introTriggeredRef = useRef(false)
const [showShareModal, setShowShareModal] = useState(false)
const [adventureChoice, setAdventureChoice] = useState(() =>
skipAdventureGate ? 'stop' : 'pending'
)
const adventureGateTriggeredRef = useRef(false)
const reopenAdventureModalOnFocusRef = useRef(false)
const projectForStage = useMemo(() => { const projectForStage = useMemo(() => {
if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) { if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) {
@@ -110,84 +93,29 @@ const SongDownload = ({ route }) => {
const downloadPurchaseStatus = projectForStage?.downloadPurchase?.status || null const downloadPurchaseStatus = projectForStage?.downloadPurchase?.status || null
const hasPaidDownload = downloadPurchaseStatus === 'paid' const hasPaidDownload = downloadPurchaseStatus === 'paid'
const canDownloadDirectly = hasActiveSubscription || hasPaidDownload || hasPurchased const canDownloadDirectly = hasActiveSubscription || hasPaidDownload || hasPurchased
const downloadLabel = canDownloadDirectly const downloadAccessLabel = canDownloadDirectly
? 'Télécharger mon morceau' ? 'Téléchargement inclus avec ton accès actuel.'
: 'Télécharger ce morceau pour 1,99' : 'Le téléchargement de ce morceau coûte 1,99 €.'
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, reopenAdventureChoice, shouldShowDownloadPage]) }, [backRoute])
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
}, [])
)
useEffect(() => { useEffect(() => {
if (adventureGateTriggeredRef.current) { if (skipAdventureGate || introTriggeredRef.current) return
return
}
if (adventureChoice !== 'pending') {
return
}
if (typeof videos === 'undefined') { if (typeof videos === 'undefined') {
return return
} }
adventureGateTriggeredRef.current = true introTriggeredRef.current = true
if (part1Url) { if (part1Url) {
setShowIntro(part1Url) setShowIntro(part1Url)
} else {
setShowAdventureModal(true)
} }
}, [adventureChoice, part1Url, videos]) }, [part1Url, skipAdventureGate, videos])
useEffect(() => {
if (adventureChoice !== 'pending') {
return
}
if (!adventureGateTriggeredRef.current) {
return
}
if (showIntro) {
return
}
if (!showAdventureModal) {
setShowAdventureModal(true)
}
}, [adventureChoice, showAdventureModal, showIntro])
const continueFlow = useCallback(async () => { const continueFlow = useCallback(async () => {
if (!selectedOption) { if (!selectedOption) {
@@ -225,25 +153,13 @@ const SongDownload = ({ route }) => {
} finally { } finally {
await setLoading(false) await setLoading(false)
} }
}, [ }, [coverOptions, coverUrl, projectForStage, push, selectedOption, setLoading, updateProjectData])
coverOptions,
coverUrl,
projectForStage,
push,
selectedOption,
setLoading,
updateProjectData,
])
const handleCloseIntro = useCallback(() => { const handleCloseIntro = useCallback(() => {
setShowIntro(null) setShowIntro(null)
setShowAdventureModal(true)
}, []) }, [])
const handleContinueAdventure = useCallback(() => { const handleContinueAdventure = useCallback(() => {
reopenAdventureModalOnFocusRef.current = true
setShowAdventureModal(false)
setAdventureChoice('continue')
continueFlow() continueFlow()
}, [continueFlow]) }, [continueFlow])
@@ -255,7 +171,7 @@ const SongDownload = ({ route }) => {
selectedOption?.generatedUrl || selectedOption?.generatedUrl ||
null null
if (!downloadUrl || isDownloading) { if (!downloadUrl || isDownloading) {
return return false
} }
await setLoading(true, { message: 'Préparation du téléchargement...' }) await setLoading(true, { message: 'Préparation du téléchargement...' })
@@ -390,12 +306,22 @@ const SongDownload = ({ route }) => {
} }
if (isWeb) { if (isWeb) {
await triggerWebDownload(downloadUrl, projectForStage?.title) try {
await setLoading(false) await triggerWebDownload(downloadUrl, projectForStage?.title)
return return true
} catch (error) {
setTooltip({
type: 'error',
text: error?.message || 'Impossible de télécharger le morceau.',
})
return false
} finally {
await setLoading(false)
}
} }
setIsDownloading(true) setIsDownloading(true)
let didDownload = false
try { try {
const baseName = const baseName =
String(trackTitle || 'musicland-track') String(trackTitle || 'musicland-track')
@@ -427,6 +353,7 @@ const SongDownload = ({ route }) => {
await FileSystem.writeAsStringAsync(destUri, base64, { await FileSystem.writeAsStringAsync(destUri, base64, {
encoding: FileSystem.EncodingType.Base64, encoding: FileSystem.EncodingType.Base64,
}) })
didDownload = true
} catch (error) { } catch (error) {
console.log('[SongDownload] SAF write error', error?.message) console.log('[SongDownload] SAF write error', error?.message)
} }
@@ -437,12 +364,14 @@ const SongDownload = ({ route }) => {
mimeType: 'audio/mpeg', mimeType: 'audio/mpeg',
dialogTitle: musiclandShareHeading, dialogTitle: musiclandShareHeading,
}) })
didDownload = true
} catch (error) { } catch (error) {
console.log('[SongDownload] share error', error?.message) console.log('[SongDownload] share error', error?.message)
} }
} else { } else {
try { try {
await Linking.openURL(downloadResult.uri) await Linking.openURL(downloadResult.uri)
didDownload = true
} catch (error) { } catch (error) {
console.log('[SongDownload] open local file error', error?.message) console.log('[SongDownload] open local file error', error?.message)
} }
@@ -454,10 +383,60 @@ const SongDownload = ({ route }) => {
setIsDownloading(false) setIsDownloading(false)
await setLoading(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( const startSongDownloadCheckout = useCallback(
async ({ force = false } = {}) => { async (intent, { force = false } = {}) => {
if (!projectId) { if (!projectId) {
if (setTooltip) { if (setTooltip) {
setTooltip({ setTooltip({
@@ -474,170 +453,164 @@ const SongDownload = ({ route }) => {
return return
} }
setPendingDownloadIntent(intent)
setIsCheckoutLaunching(true) setIsCheckoutLaunching(true)
setDownloadPaymentPending(true) setDownloadPaymentPending(true)
try { try {
await createSongDownloadCheckout(projectId) await createSongDownloadCheckout(projectId)
} catch (error) { } catch (error) {
setDownloadPaymentPending(false) setDownloadPaymentPending(false)
setPendingDownloadIntent(null)
if (setTooltip) { if (setTooltip) {
setTooltip({ setTooltip({
type: 'error', type: 'error',
text: error?.message || "Une erreur est survenue lors du paiement.", text: error?.message || 'Une erreur est survenue lors du paiement.',
}) })
} else { } 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 { } finally {
setIsCheckoutLaunching(false) setIsCheckoutLaunching(false)
} }
}, },
[createSongDownloadCheckout, downloadPaymentPending, isCheckoutLaunching, projectId, setTooltip]
)
const handleDownloadChoice = useCallback(
(intent) => {
if (isDownloading || isCheckoutLaunching || isPublishing) {
return
}
if (canDownloadDirectly) {
executeDownloadChoice(intent)
return
}
if (downloadPaymentPending) {
Alert.alert(
'Paiement en attente',
"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(intent, { force: true }),
},
]
)
return
}
if (promptPurchaseConfirm) {
Alert.alert(
'Télécharger ce morceau',
'Voulez-vous télécharger ce morceau pour 1,99€ ?',
[
{ text: 'Annuler', style: 'cancel' },
{ text: 'Acheter', onPress: () => startSongDownloadCheckout(intent) },
],
{ cancelable: true }
)
return
}
startSongDownloadCheckout(intent)
},
[ [
createSongDownloadCheckout, canDownloadDirectly,
downloadPaymentPending, downloadPaymentPending,
executeDownloadChoice,
isCheckoutLaunching, isCheckoutLaunching,
projectId, isDownloading,
setTooltip, isPublishing,
promptPurchaseConfirm,
startSongDownloadCheckout,
] ]
) )
const handleDownloadPress = useCallback(() => {
if (isDownloading || isCheckoutLaunching) {
return
}
if (canDownloadDirectly) {
handleDownload()
return
}
if (downloadPaymentPending) {
Alert.alert(
'Paiement en attente',
"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 }) },
]
)
return
}
if (promptPurchaseConfirm) {
Alert.alert(
'Télécharger ce morceau',
'Voulez-vous télécharger ce morceau pour 1,99€ ?',
[
{ text: 'Annuler', style: 'cancel' },
{ text: 'Acheter', onPress: () => startSongDownloadCheckout() },
],
{ cancelable: true }
)
return
}
startSongDownloadCheckout()
}, [
canDownloadDirectly,
downloadPaymentPending,
handleDownload,
isCheckoutLaunching,
isDownloading,
promptPurchaseConfirm,
startSongDownloadCheckout,
])
useEffect(() => { useEffect(() => {
if (!downloadPaymentPending || !hasPaidDownload || isDownloading) { if (!downloadPaymentPending || !hasPaidDownload || !pendingDownloadIntent || isDownloading) {
return return
} }
const intent = pendingDownloadIntent
setDownloadPaymentPending(false) setDownloadPaymentPending(false)
handleDownload() setPendingDownloadIntent(null)
}, [downloadPaymentPending, handleDownload, hasPaidDownload, isDownloading]) executeDownloadChoice(intent)
}, [
const handleStopAdventure = useCallback(() => { downloadPaymentPending,
reopenAdventureModalOnFocusRef.current = false executeDownloadChoice,
setShowAdventureModal(false) hasPaidDownload,
setAdventureChoice('stop') isDownloading,
}, []) pendingDownloadIntent,
])
const handleGoHome = useCallback(() => {
navigate(Routes.BottomTab, {
screen: Routes.HomeStack,
params: {
screen: Routes.Home,
},
})
}, [])
return ( return (
<Page backgroundImg={background.studioBG2} headerType="NONE"> <Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={handleBackPress} progress={84} /> <MusicLandHeader onPressBack={handleBackPress} progress={84} />
{shouldShowDownloadPage ? ( <ScrollView
<ScrollView contentContainerStyle={[
contentContainerStyle={[ styles.container,
styles.container, !isWeb && styles.containerMobile,
!isWeb && styles.containerMobile, styles.scrollContent,
styles.scrollContent, ]}
]} showsVerticalScrollIndicator={false}
showsVerticalScrollIndicator={false} >
> <CreateLyricsHeader
<CreateLyricsHeader title="Ta pochette est validée" /> title="Ton morceau est prêt"
<ClubAdvantagesCard subTitle="Choisis ce que tu souhaites faire maintenant."
style={styles.clubCardSpacing} />
topContent={
<View style={styles.coverRow}>
{coverUrl ? (
<ExpoImage source={{ uri: coverUrl }} style={styles.coverImage} contentFit="cover" />
) : (
<View style={[styles.coverImage, styles.coverPlaceholder]}>
<Text style={styles.placeholderText}>Aucune pochette</Text>
</View>
)}
<View style={styles.downloadColumn}> <View style={styles.summaryCard}>
<Pressable {coverUrl ? (
style={[ <ExpoImage source={{ uri: coverUrl }} style={styles.coverImage} contentFit="cover" />
styles.downloadTile, ) : (
(isDownloading || isCheckoutLaunching) && { opacity: 0.6 }, <View style={[styles.coverImage, styles.coverPlaceholder]}>
]} <Text style={styles.placeholderText}>Aucune pochette</Text>
onPress={handleDownloadPress} </View>
disabled={isDownloading || isCheckoutLaunching} )}
> <Text style={styles.trackTitle}>{trackTitle}</Text>
<MaterialCommunityIcons name="download" size={22} color={Palette.white} /> </View>
<Text style={styles.downloadText}>{downloadLabel}</Text>
</Pressable> <View style={styles.choiceSection}>
{!canDownloadDirectly && downloadPaymentPending ? ( <Text style={styles.sectionTitle}>Téléchargement</Text>
<Text style={styles.downloadPendingText}> <Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
Paiement en attente de confirmation... <BorderGradientButton
</Text> title="Option 1 · Téléchargement à des fins personnelles"
) : null} onPress={() => handleDownloadChoice('personal')}
</View> disabled={isDownloading || isCheckoutLaunching || isPublishing}
</View> loading={isDownloading && !isPublishing}
} loadingText="Téléchargement en cours..."
height={58}
titleStyle={styles.choiceButtonText}
/> />
<GradientButton <GradientButton
title="Revenir à l'accueil" title={
onPress={handleGoHome} isPublishing
containerStyle={styles.returnHomeButton} ? '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}
/> />
</ScrollView> {!canDownloadDirectly && downloadPaymentPending ? (
) : ( <Text style={styles.downloadPendingText}>Paiement en attente de confirmation...</Text>
<View style={styles.adventureGatePlaceholder} /> ) : 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>
<FullscreenIntroVideo url={showIntro} visible={!!showIntro} onClose={handleCloseIntro} /> <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> </Page>
) )
} }
@@ -659,11 +632,15 @@ const styles = StyleSheet.create({
scrollContent: { scrollContent: {
paddingBottom: gutters * 2.6, paddingBottom: gutters * 2.6,
}, },
coverRow: { summaryCard: {
flexDirection: isWeb ? 'row' : 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: gutters * 0.8, 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: { coverImage: {
width: 150, width: 150,
@@ -680,24 +657,42 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterMedium, fontFamily: FONT_FAMILY.InterMedium,
color: Palette.grayMid, color: Palette.grayMid,
}, },
downloadTile: { trackTitle: {
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: {
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 15, fontSize: 18,
color: Palette.white, 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: { downloadPendingText: {
marginTop: 6, marginTop: 6,
@@ -706,18 +701,6 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
opacity: 0.8, 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 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%',
},
})