diff --git a/functions/src/heygen.js b/functions/src/heygen.js index 292e844..4d82779 100644 --- a/functions/src/heygen.js +++ b/functions/src/heygen.js @@ -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 } @@ -302,180 +304,180 @@ exports.playbackWebhook = onRequest( secrets: [HEYGEN_API_KEY, HEYGEN_WEBHOOK_TOKEN], }, async (req, res) => { - if (req.method !== 'POST') { - 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' }) + if (req.method !== 'POST') { + res.status(405).json({ ok: false, message: 'Method not allowed' }) 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) { - logger.warn('[HeyGen] webhook ignored: missing identifiers', { - eventType, - bodyKeys: Object.keys(req.body || {}), - }) - res.status(400).json({ ok: false, message: 'Missing callback_id or video_id' }) - return - } + if (!token || token !== expectedToken) { + res.status(401).json({ ok: false, message: 'Unauthorized webhook token' }) + return + } - const projectDoc = await findProjectForWebhook({ callbackId, videoId }) - if (!projectDoc) { - logger.info('[HeyGen] webhook ignored: project not found', { - callbackId, - videoId, - eventType, - }) - res.status(202).json({ ok: true, ignored: true }) - return - } + const { eventType, callbackId, videoId } = extractWebhookPayload(req.body || {}) - const project = projectDoc.data() || {} - const currentCallbackId = trimString(project?.playbackCallbackId) - const currentVideoId = trimString(project?.playbackJobId) + if (!callbackId && !videoId) { + logger.warn('[HeyGen] webhook ignored: missing identifiers', { + 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) { - 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, + const projectDoc = await findProjectForWebhook({ callbackId, videoId }) + if (!projectDoc) { + logger.info('[HeyGen] webhook ignored: project not found', { + callbackId, + videoId, + eventType, }) res.status(202).json({ ok: true, ignored: true }) return } - const notificationRef = db - .collection('notifications') - .doc( + const project = projectDoc.data() || {} + const currentCallbackId = trimString(project?.playbackCallbackId) + 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({ projectId: projectDoc.id, videoId: canonicalVideoId, }) ) - await db.runTransaction(async (transaction) => { - const [latestProjectSnap, notificationSnap] = await Promise.all([ - transaction.get(projectDoc.ref), - transaction.get(notificationRef), - ]) - const latestProject = latestProjectSnap.data() || {} - const userId = trimString(latestProject?.userId) - const projectTitle = trimString(latestProject?.title) || 'ton projet' + await db.runTransaction(async (transaction) => { + const [latestProjectSnap, notificationSnap] = await Promise.all([ + transaction.get(projectDoc.ref), + transaction.get(notificationRef), + ]) + const latestProject = latestProjectSnap.data() || {} + const userId = trimString(latestProject?.userId) + const projectTitle = trimString(latestProject?.title) || 'ton projet' - transaction.set( - projectDoc.ref, + transaction.set( + 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', - playbackStatus: PLAYBACK_STATUS.DRAFT_READY, + playbackStatus: PLAYBACK_STATUS.FAILED, playbackGenerating: false, - playbackDraftUrl: canonicalVideoUrl, + playbackError: failureMessage, 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.FAILED }) + return + } + + logger.info('[HeyGen] webhook acknowledged without terminal status', { + projectId: projectDoc.id, + canonicalVideoId, + canonicalStatus, + eventType, }) - res.status(200).json({ ok: true, status: PLAYBACK_STATUS.DRAFT_READY }) - return + 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' }) } - - 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' }) - } } ) diff --git a/functions/src/rankings.js b/functions/src/rankings.js index 6bc797f..6818a8e 100644 --- a/functions/src/rankings.js +++ b/functions/src/rankings.js @@ -36,21 +36,24 @@ 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 data = doc.data() || {} - return { - rank: index + 1, - projectId: doc.id, - title: data.title || null, - userId: data.userId || null, - userName: data.userName || null, - coverUrl: data.coverUrl || null, - songUrl: data.songUrl || null, - views: data.views || 0, - } - }) + const topProjects = topProjectsSnap.docs + .filter((doc) => doc.data()?.songPublishedOnMusicLand !== false) + .slice(0, 3) + .map((doc, index) => { + const data = doc.data() || {} + return { + rank: index + 1, + projectId: doc.id, + title: data.title || null, + userId: data.userId || null, + userName: data.userName || null, + coverUrl: data.coverUrl || null, + songUrl: data.songUrl || null, + views: data.views || 0, + } + }) const docRef = firestore.collection('monthlyTopSongs').doc(context.monthKey) diff --git a/src/hooks/usePlaylistMusicSearch.js b/src/hooks/usePlaylistMusicSearch.js index ee0651e..8db4a24 100644 --- a/src/hooks/usePlaylistMusicSearch.js +++ b/src/hooks/usePlaylistMusicSearch.js @@ -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, } diff --git a/src/hooks/useSearch.js b/src/hooks/useSearch.js index 92ac929..0937bf6 100644 --- a/src/hooks/useSearch.js +++ b/src/hooks/useSearch.js @@ -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, } } diff --git a/src/navigation/MainStack.js b/src/navigation/MainStack.js index fe99793..e4f3017 100644 --- a/src/navigation/MainStack.js +++ b/src/navigation/MainStack.js @@ -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, diff --git a/src/navigation/Routes.js b/src/navigation/Routes.js index 42be972..5eba6bc 100644 --- a/src/navigation/Routes.js +++ b/src/navigation/Routes.js @@ -57,6 +57,7 @@ export const Routes = { PlaybackOnboarding: 'PlaybackOnboarding', Playback: 'Playback', + PlaybackImageRightsConsent: 'PlaybackImageRightsConsent', PlaybackGuide: 'PlaybackGuide', PlaybackAi: 'PlaybackAi', PlaybackAiStatus: 'PlaybackAiStatus', diff --git a/src/providers/PlayerProvider.js b/src/providers/PlayerProvider.js index ee86b8e..8253f10 100644 --- a/src/providers/PlayerProvider.js +++ b/src/providers/PlayerProvider.js @@ -35,6 +35,7 @@ const HIDDEN_ROUTE_NAMES = new Set([ Routes.PlaybackExample, Routes.PlaybackOnboarding, Routes.Playback, + Routes.PlaybackImageRightsConsent, Routes.PlaybackGuide, Routes.PlaybackAi, Routes.PlaybackAiStatus, diff --git a/src/screens/HitParade/HitParade.js b/src/screens/HitParade/HitParade.js index da0749e..f4a5ce2 100644 --- a/src/screens/HitParade/HitParade.js +++ b/src/screens/HitParade/HitParade.js @@ -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', diff --git a/src/screens/Library/components/SearchResultsList.js b/src/screens/Library/components/SearchResultsList.js index 45aa93f..a605e96 100644 --- a/src/screens/Library/components/SearchResultsList.js +++ b/src/screens/Library/components/SearchResultsList.js @@ -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 = diff --git a/src/screens/Playback/Playback.js b/src/screens/Playback/Playback.js index 66260ae..02341d2 100644 --- a/src/screens/Playback/Playback.js +++ b/src/screens/Playback/Playback.js @@ -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 ( { onPress={onPressRecord} disabled={!project || isManualBlocked} /> - + {isManualBlocked ? ( Le playback manuel est bloqué tant que le playback IA est en cours ou en attente de diff --git a/src/screens/Playback/PlaybackImageRightsConsent.js b/src/screens/Playback/PlaybackImageRightsConsent.js new file mode 100644 index 0000000..c09ff6a --- /dev/null +++ b/src/screens/Playback/PlaybackImageRightsConsent.js @@ -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 ( + + + + + + + Avant de créer ton playback + + 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. + + + 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. + + + + + setHasAcceptedImageRights((current) => !current)} + label="Je confirme disposer des droits et autorisations nécessaires sur toutes les personnes représentées." + /> + + + + + + ) +} + +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', + }, +}) diff --git a/src/screens/Playbacks/Playbacks.js b/src/screens/Playbacks/Playbacks.js index 1fdd627..6a484d3 100644 --- a/src/screens/Playbacks/Playbacks.js +++ b/src/screens/Playbacks/Playbacks.js @@ -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, diff --git a/src/screens/Playbacks/Playbacks.web.js b/src/screens/Playbacks/Playbacks.web.js index 8f80dec..9161b30 100644 --- a/src/screens/Playbacks/Playbacks.web.js +++ b/src/screens/Playbacks/Playbacks.web.js @@ -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, diff --git a/src/screens/Playbacks/components/PlaybackCharts.js b/src/screens/Playbacks/components/PlaybackCharts.js index 8eb7297..125243f 100644 --- a/src/screens/Playbacks/components/PlaybackCharts.js +++ b/src/screens/Playbacks/components/PlaybackCharts.js @@ -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, diff --git a/src/screens/Production/PlaybackDownload.js b/src/screens/Production/PlaybackDownload.js index 6cdb6ca..73ab5ef 100644 --- a/src/screens/Production/PlaybackDownload.js +++ b/src/screens/Production/PlaybackDownload.js @@ -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]) - - 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, - isPublishing, - processMerge, + action, + currentUID, + pendingPlaybackUrl, 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( - 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) - } - } + const handlePlaybackChoice = useCallback( + (intent) => { + if (isDownloading || isPublishing || isCheckoutLaunching) return + if (canDownloadPlayback) { + executePlaybackChoice(intent) + return + } + startPlaybackDownloadCheckout(intent) + }, + [ + canDownloadPlayback, + executePlaybackChoice, + isCheckoutLaunching, + isDownloading, + isPublishing, + startPlaybackDownloadCheckout, + ] + ) useEffect(() => { - if (!downloadPaymentPending || !hasPaidPlaybackDownload || isDownloading) { + if ( + !downloadPaymentPending || + !hasPaidPlaybackDownload || + !pendingDownloadIntent || + isDownloading + ) { return } + const intent = pendingDownloadIntent 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(), - }, - { 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) - } - } + setPendingDownloadIntent(null) + executePlaybackChoice(intent) + }, [ + downloadPaymentPending, + executePlaybackChoice, + hasPaidPlaybackDownload, + isDownloading, + pendingDownloadIntent, + ]) return ( <> { onClose={() => setIsAfterPlaybackVideoVisible(false)} /> - - - - {projectForDownload?.coverUrl ? ( - - ) : ( - - Aucune pochette - - )} - - - - - {downloadLabel} - - {!canDownloadPlayback && downloadPaymentPending ? ( - - Paiement en attente de confirmation... - - ) : null} - - - } + - - setHasAcceptedPublication((prevState) => !prevState)} - label="J'accepte la diffusion de mon playback sur Musicland." - /> - - Cette confirmation est requise avant de lancer la publication. - + + {projectForDownload?.coverUrl ? ( + + ) : ( + + Aucune pochette + + )} + {projectForDownload?.title || 'Mon playback'} - { - handlePublish() - }} - disabled={isPublishing || !hasAcceptedPublication} - loading={isPublishing} - loadingText="Publication en cours..." - containerStyle={styles.continueButton} - /> + + Téléchargement + {downloadAccessLabel} + handlePlaybackChoice('personal')} + disabled={isPublishing || isDownloading || isCheckoutLaunching} + loading={isDownloading && !isPublishing} + loadingText="Téléchargement en cours..." + height={58} + titleStyle={styles.choiceButtonText} + /> + handlePlaybackChoice('publish')} + disabled={isPublishing || isDownloading || isCheckoutLaunching} + height={58} + textStyle={styles.choiceButtonText} + /> + {!canDownloadPlayback && downloadPaymentPending ? ( + Paiement en attente de confirmation... + ) : null} + @@ -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, - }, }) diff --git a/src/screens/Profile/Profile.js b/src/screens/Profile/Profile.js index 21fc98a..fc4ec3e 100644 --- a/src/screens/Profile/Profile.js +++ b/src/screens/Profile/Profile.js @@ -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 diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js index 776d85e..a3ea88b 100644 --- a/src/screens/Studio/SongReady.js +++ b/src/screens/Studio/SongReady.js @@ -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 diff --git a/src/screens/cover/SongDownload.js b/src/screens/cover/SongDownload.js index 5f807b3..0dc5933 100644 --- a/src/screens/cover/SongDownload.js +++ b/src/screens/cover/SongDownload.js @@ -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) { - await triggerWebDownload(downloadUrl, projectForStage?.title) - await setLoading(false) - return + 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) + } } 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,170 +453,164 @@ 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] + ) + + 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, + executeDownloadChoice, isCheckoutLaunching, - projectId, - setTooltip, + isDownloading, + 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(() => { - 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 ( - {shouldShowDownloadPage ? ( - - - - {coverUrl ? ( - - ) : ( - - Aucune pochette - - )} + + - - - - {downloadLabel} - - {!canDownloadDirectly && downloadPaymentPending ? ( - - Paiement en attente de confirmation... - - ) : null} - - - } + + {coverUrl ? ( + + ) : ( + + Aucune pochette + + )} + {trackTitle} + + + + Téléchargement + {downloadAccessLabel} + handleDownloadChoice('personal')} + disabled={isDownloading || isCheckoutLaunching || isPublishing} + loading={isDownloading && !isPublishing} + loadingText="Téléchargement en cours..." + height={58} + titleStyle={styles.choiceButtonText} /> handleDownloadChoice('publish')} + disabled={isDownloading || isCheckoutLaunching || isPublishing} + height={58} + textStyle={styles.choiceButtonText} /> - - ) : ( - - )} + {!canDownloadDirectly && downloadPaymentPending ? ( + Paiement en attente de confirmation... + ) : null} + + + + Continuer l'aventure + + Retrouve Théo dans l'espace Playback pour donner vie à ton morceau en vidéo. + + + + - - { - setShowShareModal(false) - navigate(Routes.DownloadPrices, { action: 'song' }) - }} - project={projectForStage} - selectedOption={selectedOption} - /> ) } @@ -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 diff --git a/src/screens/cover/components/AdventureChoiceModal.js b/src/screens/cover/components/AdventureChoiceModal.js deleted file mode 100644 index b19ade3..0000000 --- a/src/screens/cover/components/AdventureChoiceModal.js +++ /dev/null @@ -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 ( - - - Quelle est la suite ? - - - - - - - ) -} - -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', - }, -}) diff --git a/src/screens/cover/components/ShareAndCreditsModal.js b/src/screens/cover/components/ShareAndCreditsModal.js deleted file mode 100644 index e112c35..0000000 --- a/src/screens/cover/components/ShareAndCreditsModal.js +++ /dev/null @@ -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 ( - [ - styles.cardWrapper, - selected && styles.cardWrapperSelected, - pressed && styles.cardWrapperPressed, - plan?.active === false && styles.cardWrapperInactive, - isPopular && styles.cardWrapperPopular, - ]} - disabled={plan?.active === false} - > - - - {plan?.badge ? ( - - {plan.badge} - - ) : null} - - - - {cardDisplay.title} - {planBadgeSource ? ( - - ) : null} - - - - {cardDisplay.primaryPrice || cardDisplay.creditsLabel ? ( - - {cardDisplay.primaryPrice ? ( - {cardDisplay.primaryPrice} - ) : null} - {cardDisplay.secondaryPrice ? ( - {cardDisplay.secondaryPrice} - ) : null} - {cardDisplay.creditsLabel ? ( - {cardDisplay.creditsLabel} - ) : null} - - ) : null} - - {Array.isArray(cardDisplay.benefitItems) && cardDisplay.benefitItems.length ? ( - - {cardDisplay.benefitItems.map((item, index) => - item?.type === 'plus' ? ( - - {item.text} - - ) : ( - - {item.text} - - ) - )} - - ) : null} - - {isPopular ? ( - - Le plus populaire ! - - ) : null} - - - - ) -} - -// --- 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 ( - - - - setIsVisible(false)} style={styles.closeButton}> - - - - - - {/* SHARE SECTION */} - Partage ton succès ! - - Partage mon morceau - - handleShare('whatsapp')}> - - - handleShare('facebook')}> - - - handleShare('instagram')}> - - - handleShare('tiktok')}> - - - - - - {/* CREDITS SECTION */} - {coinPacks && coinPacks.length > 0 ? ( - - {COIN_PACK_SECTION_TITLE} - - {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 ( - - - {displayName} - {creditsText ? ( - {creditsText} - ) : ( - - )} - {details?.disclaimer ? ( - {details.disclaimer} - ) : null} - - {isPopular ? ( - - Le plus populaire ! - - ) : null} - handleBuyCredits(pack)} - disabled={Boolean(processingPriceId)} - style={styles.creditButton} - textStyle={styles.creditButtonText} - gradientStyle={{ paddingVertical: 8, paddingHorizontal: 16 }} - /> - - ) - })} - - - ) : null} - - {/* SUBSCRIPTIONS SECTION */} - - Rejoignez le club MusicLand - - {/* Period Toggle */} - - {['monthly', 'annual'].map((key) => { - const isActive = selectedPeriodKey === key - const label = key === 'monthly' ? 'Mensuel' : 'Annuel' - const showAnnualPromo = key === 'annual' - return ( - { - setSelectedPeriodKey(key) - setSelectedPriceId(null) - }} - style={[styles.segmentButton, isActive && styles.segmentButtonActive]} - > - - {label} - - {showAnnualPromo && ( - - -16% - - )} - - ) - })} - - - {/* Plans Grid */} - - {isCatalogLoading && !currentPlans.length ? ( - - ) : ( - currentPlans.map((plan) => ( - - - - )) - )} - - - - p.productId === processingPriceId) ? 'Traitement...' : 'Choisir cet abonnement'} - onPress={handleSubscribe} - disabled={!selectedPriceId || Boolean(processingPriceId)} - style={{ width: isWeb ? 320 : '100%' }} - /> - - {errorMessage ? {errorMessage} : null} - - - {/* BROADCAST SECTION */} - - - setHasAcceptedPublication((prev) => !prev)} - label="J'accepte de diffuser mon contenu sur la plateforme de streaming de MusicLand" - /> - - - - - - - ) -} - -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%', - }, -})