feat: download options
This commit is contained in:
+146
-144
@@ -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' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+17
-14
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -57,6 +57,7 @@ export const Routes = {
|
||||
|
||||
PlaybackOnboarding: 'PlaybackOnboarding',
|
||||
Playback: 'Playback',
|
||||
PlaybackImageRightsConsent: 'PlaybackImageRightsConsent',
|
||||
PlaybackGuide: 'PlaybackGuide',
|
||||
PlaybackAi: 'PlaybackAi',
|
||||
PlaybackAiStatus: 'PlaybackAiStatus',
|
||||
|
||||
@@ -35,6 +35,7 @@ const HIDDEN_ROUTE_NAMES = new Set([
|
||||
Routes.PlaybackExample,
|
||||
Routes.PlaybackOnboarding,
|
||||
Routes.Playback,
|
||||
Routes.PlaybackImageRightsConsent,
|
||||
Routes.PlaybackGuide,
|
||||
Routes.PlaybackAi,
|
||||
Routes.PlaybackAiStatus,
|
||||
|
||||
@@ -72,7 +72,12 @@ const HitParade = () => {
|
||||
}, [selectedLanguage])
|
||||
const { data: allSongs } = useDataFromRef({
|
||||
ref: allSongsRef,
|
||||
format: (docs) => docs.filter((item) => item?.hasPlayback !== true),
|
||||
format: (docs) =>
|
||||
docs.filter(
|
||||
(item) =>
|
||||
item?.songPublishedOnMusicLand !== false &&
|
||||
(item?.hasPlayback !== true || item?.playbackPublishedOnMusicLand === false)
|
||||
),
|
||||
simpleRef: false,
|
||||
listener: true,
|
||||
condition: true,
|
||||
@@ -81,7 +86,12 @@ const HitParade = () => {
|
||||
|
||||
const { data: topMonthSongs } = useDataFromRef({
|
||||
ref: projectsRef.where('monthViews', '>', 0).orderBy('monthViews', 'desc').limit(20),
|
||||
format: (docs) => docs.filter((item) => item?.hasPlayback !== true),
|
||||
format: (docs) =>
|
||||
docs.filter(
|
||||
(item) =>
|
||||
item?.songPublishedOnMusicLand !== false &&
|
||||
(item?.hasPlayback !== true || item?.playbackPublishedOnMusicLand === false)
|
||||
),
|
||||
simpleRef: false,
|
||||
listener: false,
|
||||
condition: selectedLanguage === 'all',
|
||||
@@ -90,7 +100,12 @@ const HitParade = () => {
|
||||
|
||||
const { data: allTimeSongs } = useDataFromRef({
|
||||
ref: projectsRef.where('views', '>', 0).orderBy('views', 'desc').limit(20),
|
||||
format: (docs) => docs.filter((item) => item?.hasPlayback !== true),
|
||||
format: (docs) =>
|
||||
docs.filter(
|
||||
(item) =>
|
||||
item?.songPublishedOnMusicLand !== false &&
|
||||
(item?.hasPlayback !== true || item?.playbackPublishedOnMusicLand === false)
|
||||
),
|
||||
simpleRef: false,
|
||||
listener: false,
|
||||
condition: selectedLanguage === 'all',
|
||||
|
||||
@@ -112,7 +112,10 @@ const SearchResultsList = ({
|
||||
return musics
|
||||
}
|
||||
|
||||
return musics.filter((project) => project?.hasPlayback !== true)
|
||||
return musics.filter(
|
||||
(project) =>
|
||||
project?.hasPlayback !== true || project?.playbackPublishedOnMusicLand === false
|
||||
)
|
||||
}, [musics, selected])
|
||||
|
||||
const shouldShowMusics =
|
||||
|
||||
@@ -46,7 +46,11 @@ const Playback = ({ route, navigation }) => {
|
||||
|
||||
const onPressRecord = useCallback(() => {
|
||||
if (isManualBlocked) return
|
||||
navigate(Routes.RecordPlayback, { project, returnToAdventureModal })
|
||||
navigate(Routes.PlaybackImageRightsConsent, {
|
||||
project,
|
||||
flow: 'record',
|
||||
returnToAdventureModal,
|
||||
})
|
||||
}, [isManualBlocked, project, returnToAdventureModal])
|
||||
|
||||
const onPressPlaybackAi = useCallback(() => {
|
||||
@@ -54,8 +58,12 @@ const Playback = ({ route, navigation }) => {
|
||||
navigate(Routes.PlaybackAiStatus, { project })
|
||||
return
|
||||
}
|
||||
navigate(Routes.PlaybackAi, { project })
|
||||
}, [project])
|
||||
navigate(Routes.PlaybackImageRightsConsent, {
|
||||
project,
|
||||
flow: 'ai',
|
||||
returnToAdventureModal,
|
||||
})
|
||||
}, [project, returnToAdventureModal])
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -86,7 +94,11 @@ const Playback = ({ route, navigation }) => {
|
||||
onPress={onPressRecord}
|
||||
disabled={!project || isManualBlocked}
|
||||
/>
|
||||
<BorderGradientButton title={aiButtonLabel} onPress={onPressPlaybackAi} disabled={!project} />
|
||||
<BorderGradientButton
|
||||
title={aiButtonLabel}
|
||||
onPress={onPressPlaybackAi}
|
||||
disabled={!project}
|
||||
/>
|
||||
{isManualBlocked ? (
|
||||
<Text style={styles.blockedText}>
|
||||
Le playback manuel est bloqué tant que le playback IA est en cours ou en attente de
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
|
||||
import { background } from '../../assets'
|
||||
import AppCheckbox from '../../components/AppCheckbox'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
import { projectsRef, serverTimestamp } from '../../config/firebase'
|
||||
import Page from '../../layouts/Page'
|
||||
import { Routes } from '../../navigation'
|
||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||
import { useUserData } from '../../providers/UserDataProvider'
|
||||
import { gutters, Palette } from '../../styles'
|
||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
||||
|
||||
const PlaybackImageRightsConsent = ({ route }) => {
|
||||
const { project: routeProject, flow, returnToAdventureModal = false } = route.params || {}
|
||||
const { selectedProject } = useUserData() || {}
|
||||
const { setTooltip } = useMinuit()
|
||||
const [hasAcceptedImageRights, setHasAcceptedImageRights] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const project = useMemo(() => {
|
||||
if (routeProject?.id && selectedProject?.id === routeProject.id) {
|
||||
return selectedProject
|
||||
}
|
||||
return routeProject || null
|
||||
}, [routeProject, selectedProject])
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (!hasAcceptedImageRights || isSaving) return
|
||||
|
||||
if (!project?.id || (flow !== 'record' && flow !== 'ai')) {
|
||||
setTooltip({
|
||||
type: 'error',
|
||||
text: "Impossible de poursuivre vers l'espace Playback.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await projectsRef.doc(project.id).set(
|
||||
{
|
||||
imageRightsAccepted: true,
|
||||
imageRightsAcceptedAt: serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
const params = { project, returnToAdventureModal }
|
||||
navigate(flow === 'record' ? Routes.RecordPlayback : Routes.PlaybackAi, params)
|
||||
} catch (error) {
|
||||
setTooltip({
|
||||
type: 'error',
|
||||
text: error?.message || "Impossible d'enregistrer la confirmation.",
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Page backgroundImg={background.playbackBG2} backgroundColor="#07111B" headerType="NONE">
|
||||
<MusicLandHeader progress={28} onPressBack={goBack} />
|
||||
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
|
||||
<CreateLyricsHeader
|
||||
title="Droit à l'image"
|
||||
subTitle="Cette confirmation est nécessaire pour continuer."
|
||||
/>
|
||||
|
||||
<View style={styles.disclaimerCard}>
|
||||
<Text style={styles.disclaimerTitle}>Avant de créer ton playback</Text>
|
||||
<Text style={styles.disclaimerText}>
|
||||
La photo ou la vidéo utilisée doit représenter une personne qui a donné son accord.
|
||||
N'utilise pas l'image d'un tiers sans son autorisation.
|
||||
</Text>
|
||||
<Text style={styles.disclaimerText}>
|
||||
Si une autre personne apparaît dans le contenu, tu dois disposer de son autorisation
|
||||
pour l'enregistrement, le traitement et, si tu le choisis plus tard, la publication sur
|
||||
MusicLand.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.consentContainer}>
|
||||
<AppCheckbox
|
||||
selected={hasAcceptedImageRights}
|
||||
onPress={() => setHasAcceptedImageRights((current) => !current)}
|
||||
label="Je confirme disposer des droits et autorisations nécessaires sur toutes les personnes représentées."
|
||||
/>
|
||||
</View>
|
||||
|
||||
<GradientButton
|
||||
title="Continuer vers mon playback"
|
||||
onPress={handleContinue}
|
||||
disabled={!hasAcceptedImageRights || isSaving}
|
||||
loading={isSaving}
|
||||
loadingText="Enregistrement..."
|
||||
containerStyle={styles.continueButton}
|
||||
/>
|
||||
</ScrollView>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlaybackImageRightsConsent
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
width: '100%',
|
||||
maxWidth: 620,
|
||||
alignSelf: 'center',
|
||||
paddingHorizontal: gutters,
|
||||
paddingTop: gutters,
|
||||
paddingBottom: gutters * 2,
|
||||
gap: gutters,
|
||||
},
|
||||
disclaimerCard: {
|
||||
padding: gutters,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.16)',
|
||||
backgroundColor: 'rgba(5, 12, 24, 0.82)',
|
||||
gap: 12,
|
||||
},
|
||||
disclaimerTitle: {
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 18,
|
||||
},
|
||||
disclaimerText: {
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
opacity: 0.86,
|
||||
},
|
||||
consentContainer: {
|
||||
padding: gutters,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
continueButton: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -38,6 +38,7 @@ const Playbacks = () => {
|
||||
loading,
|
||||
} = useDataFromRef({
|
||||
ref: projectsRef.where('playbackUrl', '!=', null),
|
||||
format: (docs) => docs.filter((item) => item?.playbackPublishedOnMusicLand !== false),
|
||||
simpleRef: false,
|
||||
listener: false,
|
||||
usePagination: true,
|
||||
|
||||
@@ -40,12 +40,10 @@ const chunkArray = (items = [], size = 10) => {
|
||||
const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
const isWeb = Platform.OS === 'web'
|
||||
const [selectedCategory, setSelectedCategory] = useState('Playbacks')
|
||||
const {
|
||||
data: allPlaybacks,
|
||||
loading: allPlaybacksLoading,
|
||||
} = useDataFromRef({
|
||||
const { data: allPlaybacks, loading: allPlaybacksLoading } = useDataFromRef({
|
||||
ref: projectsRef.where('playbackUrl', '!=', null),
|
||||
format: (docs) => docs.filter((item) => !!item?.playbackUrl),
|
||||
format: (docs) =>
|
||||
docs.filter((item) => !!item?.playbackUrl && item?.playbackPublishedOnMusicLand !== false),
|
||||
simpleRef: false,
|
||||
listener: true,
|
||||
condition: true,
|
||||
@@ -53,7 +51,8 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
|
||||
const { data: topMonthPlaybacks, loading: topMonthLoading } = useDataFromRef({
|
||||
ref: projectsRef.where('monthViews', '>', 0).orderBy('monthViews', 'desc').limit(20),
|
||||
format: (docs) => docs.filter((item) => !!item?.playbackUrl),
|
||||
format: (docs) =>
|
||||
docs.filter((item) => !!item?.playbackUrl && item?.playbackPublishedOnMusicLand !== false),
|
||||
simpleRef: false,
|
||||
listener: false,
|
||||
condition: true,
|
||||
@@ -61,7 +60,8 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
|
||||
const { data: allTimePlaybacks, loading: allTimeLoading } = useDataFromRef({
|
||||
ref: projectsRef.where('views', '>', 0).orderBy('views', 'desc').limit(20),
|
||||
format: (docs) => docs.filter((item) => !!item?.playbackUrl),
|
||||
format: (docs) =>
|
||||
docs.filter((item) => !!item?.playbackUrl && item?.playbackPublishedOnMusicLand !== false),
|
||||
simpleRef: false,
|
||||
listener: false,
|
||||
condition: true,
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<FullscreenIntroVideo
|
||||
@@ -429,75 +450,59 @@ const PlaybackDownload = ({ route }) => {
|
||||
onClose={() => setIsAfterPlaybackVideoVisible(false)}
|
||||
/>
|
||||
<Page
|
||||
backgroundImg={
|
||||
action === 'playback' ? background.playbackBG2 : background.productionBG2
|
||||
}
|
||||
backgroundImg={action === 'playback' ? background.playbackBG2 : background.productionBG2}
|
||||
backgroundColor="#07111B"
|
||||
headerType="NONE"
|
||||
>
|
||||
<MusicLandHeader onPressBack={goBack} progress={50} />
|
||||
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
|
||||
<CreateLyricsHeader title={'Publier ton playback'} />
|
||||
|
||||
<ClubAdvantagesCard
|
||||
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>
|
||||
}
|
||||
<CreateLyricsHeader
|
||||
title="Ton playback est prêt"
|
||||
subTitle="Choisis l'utilisation de ta vidéo."
|
||||
/>
|
||||
|
||||
<View style={styles.publishConsentContainer}>
|
||||
<AppCheckbox
|
||||
selected={hasAcceptedPublication}
|
||||
onPress={() => setHasAcceptedPublication((prevState) => !prevState)}
|
||||
label="J'accepte la diffusion de mon playback sur Musicland."
|
||||
/>
|
||||
<Text style={styles.publishConsentDescription}>
|
||||
Cette confirmation est requise avant de lancer la publication.
|
||||
</Text>
|
||||
<View style={styles.summaryCard}>
|
||||
{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>
|
||||
)}
|
||||
<Text style={styles.playbackTitle}>{projectForDownload?.title || 'Mon playback'}</Text>
|
||||
</View>
|
||||
|
||||
<BorderGradientButton
|
||||
title={'Publier'}
|
||||
onPress={() => {
|
||||
handlePublish()
|
||||
}}
|
||||
disabled={isPublishing || !hasAcceptedPublication}
|
||||
loading={isPublishing}
|
||||
loadingText="Publication en cours..."
|
||||
containerStyle={styles.continueButton}
|
||||
/>
|
||||
<View style={styles.choiceSection}>
|
||||
<Text style={styles.sectionTitle}>Téléchargement</Text>
|
||||
<Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
|
||||
<BorderGradientButton
|
||||
title="Option 1 · Téléchargement à des fins personnelles"
|
||||
onPress={() => handlePlaybackChoice('personal')}
|
||||
disabled={isPublishing || isDownloading || isCheckoutLaunching}
|
||||
loading={isDownloading && !isPublishing}
|
||||
loadingText="Téléchargement en cours..."
|
||||
height={58}
|
||||
titleStyle={styles.choiceButtonText}
|
||||
/>
|
||||
<GradientButton
|
||||
title={
|
||||
isPublishing
|
||||
? 'Téléchargement et publication en cours...'
|
||||
: 'Option 2 · Télécharger et publier sur MusicLand'
|
||||
}
|
||||
onPress={() => handlePlaybackChoice('publish')}
|
||||
disabled={isPublishing || isDownloading || isCheckoutLaunching}
|
||||
height={58}
|
||||
textStyle={styles.choiceButtonText}
|
||||
/>
|
||||
{!canDownloadPlayback && downloadPaymentPending ? (
|
||||
<Text style={styles.downloadPendingText}>Paiement en attente de confirmation...</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Page>
|
||||
</>
|
||||
@@ -509,15 +514,22 @@ export default PlaybackDownload
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
width: '100%',
|
||||
maxWidth: 620,
|
||||
alignSelf: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
gap: 14,
|
||||
},
|
||||
coverRow: {
|
||||
flexDirection: isWeb ? 'row' : 'column',
|
||||
summaryCard: {
|
||||
alignItems: 'center',
|
||||
justifyContent: isWeb ? 'center' : 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.14)',
|
||||
backgroundColor: 'rgba(5, 12, 24, 0.76)',
|
||||
},
|
||||
coverImage: {
|
||||
...size({ size: 140 }),
|
||||
@@ -525,23 +537,45 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.18)',
|
||||
},
|
||||
downloadTile: {
|
||||
flexDirection: 'row',
|
||||
coverPlaceholder: {
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#8C4BFF',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.06)',
|
||||
},
|
||||
downloadText: {
|
||||
placeholderText: {
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
color: Palette.grayMid,
|
||||
},
|
||||
playbackTitle: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 15,
|
||||
fontSize: 18,
|
||||
color: Palette.white,
|
||||
textAlign: 'center',
|
||||
},
|
||||
choiceSection: {
|
||||
gap: 10,
|
||||
padding: 16,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(37, 36, 56, 0.94)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 18,
|
||||
color: Palette.white,
|
||||
},
|
||||
downloadColumn: {
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
sectionDescription: {
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: Palette.white,
|
||||
opacity: 0.8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
choiceButtonText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
},
|
||||
downloadPendingText: {
|
||||
marginTop: 6,
|
||||
@@ -550,20 +584,4 @@ const styles = StyleSheet.create({
|
||||
color: Palette.white,
|
||||
opacity: 0.8,
|
||||
},
|
||||
clubCardSpacing: {
|
||||
marginTop: 10,
|
||||
},
|
||||
publishConsentContainer: {
|
||||
gap: 6,
|
||||
marginTop: 2,
|
||||
},
|
||||
publishConsentDescription: {
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 13,
|
||||
color: Palette.white,
|
||||
opacity: 0.8,
|
||||
},
|
||||
continueButton: {
|
||||
marginTop: 10,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+246
-263
@@ -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 (
|
||||
<Page backgroundImg={background.studioBG2} headerType="NONE">
|
||||
<MusicLandHeader onPressBack={handleBackPress} progress={84} />
|
||||
{shouldShowDownloadPage ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
!isWeb && styles.containerMobile,
|
||||
styles.scrollContent,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<CreateLyricsHeader title="Ta pochette est validée" />
|
||||
<ClubAdvantagesCard
|
||||
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>
|
||||
)}
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
!isWeb && styles.containerMobile,
|
||||
styles.scrollContent,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<CreateLyricsHeader
|
||||
title="Ton morceau est prêt"
|
||||
subTitle="Choisis ce que tu souhaites faire maintenant."
|
||||
/>
|
||||
|
||||
<View style={styles.downloadColumn}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.downloadTile,
|
||||
(isDownloading || isCheckoutLaunching) && { opacity: 0.6 },
|
||||
]}
|
||||
onPress={handleDownloadPress}
|
||||
disabled={isDownloading || isCheckoutLaunching}
|
||||
>
|
||||
<MaterialCommunityIcons name="download" size={22} color={Palette.white} />
|
||||
<Text style={styles.downloadText}>{downloadLabel}</Text>
|
||||
</Pressable>
|
||||
{!canDownloadDirectly && downloadPaymentPending ? (
|
||||
<Text style={styles.downloadPendingText}>
|
||||
Paiement en attente de confirmation...
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
<View style={styles.summaryCard}>
|
||||
{coverUrl ? (
|
||||
<ExpoImage source={{ uri: coverUrl }} style={styles.coverImage} contentFit="cover" />
|
||||
) : (
|
||||
<View style={[styles.coverImage, styles.coverPlaceholder]}>
|
||||
<Text style={styles.placeholderText}>Aucune pochette</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.trackTitle}>{trackTitle}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.choiceSection}>
|
||||
<Text style={styles.sectionTitle}>Téléchargement</Text>
|
||||
<Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
|
||||
<BorderGradientButton
|
||||
title="Option 1 · Téléchargement à des fins personnelles"
|
||||
onPress={() => handleDownloadChoice('personal')}
|
||||
disabled={isDownloading || isCheckoutLaunching || isPublishing}
|
||||
loading={isDownloading && !isPublishing}
|
||||
loadingText="Téléchargement en cours..."
|
||||
height={58}
|
||||
titleStyle={styles.choiceButtonText}
|
||||
/>
|
||||
<GradientButton
|
||||
title="Revenir à l'accueil"
|
||||
onPress={handleGoHome}
|
||||
containerStyle={styles.returnHomeButton}
|
||||
title={
|
||||
isPublishing
|
||||
? 'Téléchargement et publication en cours...'
|
||||
: 'Option 2 · Télécharger et publier sur MusicLand'
|
||||
}
|
||||
onPress={() => handleDownloadChoice('publish')}
|
||||
disabled={isDownloading || isCheckoutLaunching || isPublishing}
|
||||
height={58}
|
||||
textStyle={styles.choiceButtonText}
|
||||
/>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.adventureGatePlaceholder} />
|
||||
)}
|
||||
{!canDownloadDirectly && downloadPaymentPending ? (
|
||||
<Text style={styles.downloadPendingText}>Paiement en attente de confirmation...</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.continueSection}>
|
||||
<Text style={styles.sectionTitle}>Continuer l'aventure</Text>
|
||||
<Text style={styles.sectionDescription}>
|
||||
Retrouve Théo dans l'espace Playback pour donner vie à ton morceau en vidéo.
|
||||
</Text>
|
||||
<BorderGradientButton
|
||||
title="Continuer avec Théo vers l'espace Playback"
|
||||
onPress={handleContinueAdventure}
|
||||
disabled={isDownloading || isCheckoutLaunching || isPublishing}
|
||||
height={58}
|
||||
titleStyle={styles.choiceButtonText}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
<FullscreenIntroVideo url={showIntro} visible={!!showIntro} onClose={handleCloseIntro} />
|
||||
<AdventureChoiceModal
|
||||
isVisible={showAdventureModal}
|
||||
setIsVisible={setShowAdventureModal}
|
||||
onContinue={handleContinueAdventure}
|
||||
onStop={handleStopAdventure}
|
||||
/>
|
||||
<ShareAndCreditsModal
|
||||
isVisible={showShareModal}
|
||||
setIsVisible={setShowShareModal}
|
||||
onNavigateToCredits={() => {
|
||||
setShowShareModal(false)
|
||||
navigate(Routes.DownloadPrices, { action: 'song' })
|
||||
}}
|
||||
project={projectForStage}
|
||||
selectedOption={selectedOption}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -659,11 +632,15 @@ const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
paddingBottom: gutters * 2.6,
|
||||
},
|
||||
coverRow: {
|
||||
flexDirection: isWeb ? 'row' : 'column',
|
||||
summaryCard: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: gutters * 0.8,
|
||||
padding: gutters,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.14)',
|
||||
backgroundColor: 'rgba(5, 12, 24, 0.76)',
|
||||
},
|
||||
coverImage: {
|
||||
width: 150,
|
||||
@@ -680,24 +657,42 @@ const styles = StyleSheet.create({
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
color: Palette.grayMid,
|
||||
},
|
||||
downloadTile: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingVertical: gutters * 0.9,
|
||||
paddingHorizontal: gutters * 1.2,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#8C4BFF',
|
||||
borderWidth: 0,
|
||||
},
|
||||
downloadColumn: {
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
downloadText: {
|
||||
trackTitle: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 15,
|
||||
fontSize: 18,
|
||||
color: Palette.white,
|
||||
textAlign: 'center',
|
||||
},
|
||||
choiceSection: {
|
||||
gap: gutters * 0.65,
|
||||
padding: gutters,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(37, 36, 56, 0.94)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
},
|
||||
continueSection: {
|
||||
gap: gutters * 0.65,
|
||||
padding: gutters,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.07)',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 18,
|
||||
color: Palette.white,
|
||||
},
|
||||
sectionDescription: {
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: Palette.white,
|
||||
opacity: 0.8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
choiceButtonText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
},
|
||||
downloadPendingText: {
|
||||
marginTop: 6,
|
||||
@@ -706,18 +701,6 @@ const styles = StyleSheet.create({
|
||||
color: Palette.white,
|
||||
opacity: 0.8,
|
||||
},
|
||||
adventureGatePlaceholder: {
|
||||
flex: 1,
|
||||
},
|
||||
clubCardSpacing: {
|
||||
marginTop: gutters * 0.5,
|
||||
},
|
||||
returnHomeButton: {
|
||||
width: '100%',
|
||||
maxWidth: 320,
|
||||
alignSelf: 'center',
|
||||
marginTop: gutters * 0.6,
|
||||
},
|
||||
})
|
||||
|
||||
export default SongDownload
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import { gutters, Palette } from '../../../styles'
|
||||
import { FONT_FAMILY } from '../../../styles/Fonts'
|
||||
import BorderGradientButton from '../../../components/BorderGradientButton'
|
||||
import GradientButton from '../../../components/GradientButton'
|
||||
import Overlay from '../../../components/Overlay'
|
||||
|
||||
const AdventureChoiceModal = ({ isVisible, setIsVisible, onContinue, onStop }) => {
|
||||
return (
|
||||
<Overlay isVisible={isVisible} setIsVisible={setIsVisible} blurIntensity={15}>
|
||||
<View style={styles.modalCard}>
|
||||
<Text style={styles.modalTitle}>Quelle est la suite ?</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<GradientButton
|
||||
title="Je continue l'aventure Musicland vers l'espace vidéo"
|
||||
onPress={onContinue}
|
||||
containerStyle={styles.modalAction}
|
||||
textStyle={styles.modalActionText}
|
||||
/>
|
||||
<BorderGradientButton
|
||||
title="J'arrête l'aventure et je télécharge ma chanson"
|
||||
onPress={onStop}
|
||||
containerStyle={styles.modalAction}
|
||||
titleStyle={styles.modalActionText}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Overlay>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdventureChoiceModal
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalCard: {
|
||||
width: '90%',
|
||||
maxWidth: 420,
|
||||
alignSelf: 'center',
|
||||
padding: gutters * 1.4,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(37, 36, 56, 0.96)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
gap: gutters * 0.8,
|
||||
},
|
||||
modalTitle: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 18,
|
||||
color: Palette.white,
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
modalActions: {
|
||||
gap: gutters * 0.6,
|
||||
marginTop: gutters * 0.6,
|
||||
},
|
||||
modalAction: {
|
||||
width: '100%',
|
||||
},
|
||||
modalActionText: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
})
|
||||
@@ -1,826 +0,0 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react'
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
Pressable,
|
||||
Linking,
|
||||
Alert,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
} from 'react-native'
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { Image as ExpoImage } from 'expo-image'
|
||||
import { gutters, Palette } from '../../../styles'
|
||||
import { FONT_FAMILY } from '../../../styles/Fonts'
|
||||
import Overlay from '../../../components/Overlay'
|
||||
import { useStripe } from '../../../providers/StripeProvider'
|
||||
import CreditAmount from '../../../components/CreditAmount'
|
||||
import GradientButton from '../../../components/GradientButton'
|
||||
import AppCheckbox from '../../../components/AppCheckbox'
|
||||
import { icons, planBadges } from '../../../assets'
|
||||
import { isWeb } from '../../../hooks/useLayoutType'
|
||||
import { navigate } from '../../../navigation/NavigationService'
|
||||
import { Routes } from '../../../navigation'
|
||||
import { musiclandShareHeading } from '../../../data'
|
||||
import {
|
||||
COIN_PACK_DETAILS,
|
||||
COIN_PACK_SECTION_TITLE,
|
||||
getCoinPackKey,
|
||||
} from '../../../utils/coinPackDisplay'
|
||||
import {
|
||||
getSubscriptionCardDisplay,
|
||||
getSubscriptionPlanKey,
|
||||
} from '../../../utils/subscriptionCardDisplay'
|
||||
|
||||
// --- HELPER FUNCTIONS & CONSTANTS (Copied from Subscriptions.js) ---
|
||||
|
||||
const formatCurrency = (amount, currency = 'eur') => {
|
||||
if (typeof amount !== 'number') {
|
||||
return null
|
||||
}
|
||||
const normalized = amount / 100
|
||||
const upperCurrency =
|
||||
typeof currency === 'string' && currency.trim() ? currency.trim().toUpperCase() : 'EUR'
|
||||
|
||||
if (typeof Intl !== 'undefined' && Intl.NumberFormat) {
|
||||
try {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: upperCurrency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(normalized)
|
||||
} catch (_error) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
return `${normalized.toFixed(2)} ${upperCurrency}`
|
||||
}
|
||||
|
||||
const getIntervalLabel = (recurring) => {
|
||||
if (!recurring || typeof recurring !== 'object') {
|
||||
return null
|
||||
}
|
||||
const interval = recurring.interval || 'month'
|
||||
const count = recurring.interval_count || 1
|
||||
const vocabulary = {
|
||||
day: { singular: 'jour', plural: 'jours' },
|
||||
week: { singular: 'semaine', plural: 'semaines' },
|
||||
month: { singular: 'mois', plural: 'mois' },
|
||||
year: { singular: 'an', plural: 'ans' },
|
||||
}
|
||||
const terms = vocabulary[interval] || vocabulary.month
|
||||
if (count <= 1) {
|
||||
return `par ${terms.singular}`
|
||||
}
|
||||
return `tous les ${count} ${count > 1 ? terms.plural : terms.singular}`
|
||||
}
|
||||
|
||||
// --- SUBSCRIPTION CARD COMPONENT (Copied) ---
|
||||
|
||||
function SubscriptionCard({ plan, selected, onSelect, isAnnual }) {
|
||||
const planKey = getSubscriptionPlanKey(plan)
|
||||
const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency)
|
||||
const intervalLabel = getIntervalLabel(plan?.recurring)
|
||||
const coinsPerMonth =
|
||||
typeof plan?.coinsPerMonth === 'number' && Number.isFinite(plan.coinsPerMonth)
|
||||
? Math.round(plan.coinsPerMonth)
|
||||
: null
|
||||
const cardDisplay = getSubscriptionCardDisplay({
|
||||
planKey,
|
||||
isAnnual,
|
||||
formattedPrice,
|
||||
intervalLabel,
|
||||
coinsPerMonth,
|
||||
fallbackTitle: plan?.product?.name || plan?.nickname || plan?.priceId || 'Abonnement',
|
||||
})
|
||||
const isPopular = cardDisplay.popular
|
||||
|
||||
const planBadgeKey = planKey
|
||||
const planBadgeSource = planBadgeKey && planBadges[planBadgeKey] ? planBadges[planBadgeKey] : null
|
||||
|
||||
const handleSelect = React.useCallback(() => {
|
||||
if (typeof onSelect === 'function' && plan?.priceId) {
|
||||
onSelect(plan.priceId)
|
||||
}
|
||||
}, [onSelect, plan?.priceId])
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handleSelect}
|
||||
style={({ pressed }) => [
|
||||
styles.cardWrapper,
|
||||
selected && styles.cardWrapperSelected,
|
||||
pressed && styles.cardWrapperPressed,
|
||||
plan?.active === false && styles.cardWrapperInactive,
|
||||
isPopular && styles.cardWrapperPopular,
|
||||
]}
|
||||
disabled={plan?.active === false}
|
||||
>
|
||||
<BlurView intensity={18} tint="dark" style={styles.cardBlur}>
|
||||
<View style={styles.cardContent}>
|
||||
{plan?.badge ? (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{plan.badge}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.planName}>{cardDisplay.title}</Text>
|
||||
{planBadgeSource ? (
|
||||
<ExpoImage
|
||||
source={planBadgeSource}
|
||||
style={styles.planBadgeImage}
|
||||
contentFit="contain"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{cardDisplay.primaryPrice || cardDisplay.creditsLabel ? (
|
||||
<View style={styles.priceBlock}>
|
||||
{cardDisplay.primaryPrice ? (
|
||||
<Text style={styles.priceValue}>{cardDisplay.primaryPrice}</Text>
|
||||
) : null}
|
||||
{cardDisplay.secondaryPrice ? (
|
||||
<Text style={styles.secondaryPriceText}>{cardDisplay.secondaryPrice}</Text>
|
||||
) : null}
|
||||
{cardDisplay.creditsLabel ? (
|
||||
<Text style={styles.coinsPromoText}>{cardDisplay.creditsLabel}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{Array.isArray(cardDisplay.benefitItems) && cardDisplay.benefitItems.length ? (
|
||||
<View style={styles.cardBenefits}>
|
||||
{cardDisplay.benefitItems.map((item, index) =>
|
||||
item?.type === 'plus' ? (
|
||||
<Text key={`benefit-plus-${index}`} style={styles.benefitPlus}>
|
||||
{item.text}
|
||||
</Text>
|
||||
) : (
|
||||
<Text key={`benefit-text-${index}`} style={styles.downloadLabel}>
|
||||
{item.text}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{isPopular ? (
|
||||
<View style={styles.popularBadge}>
|
||||
<Text style={styles.popularBadgeText}>Le plus populaire !</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</BlurView>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
// --- MAIN WRAPPER COMPONENT ---
|
||||
|
||||
const ShareAndCreditsModal = ({
|
||||
isVisible,
|
||||
setIsVisible,
|
||||
project,
|
||||
selectedOption,
|
||||
}) => {
|
||||
const trackTitle = project?.title || 'Musicland Track'
|
||||
|
||||
const {
|
||||
subscriptions,
|
||||
coinPacks,
|
||||
createSubscriptionCheckout,
|
||||
createCoinPackCheckout,
|
||||
isCatalogLoading,
|
||||
} = useStripe()
|
||||
|
||||
const [selectedPeriodKey, setSelectedPeriodKey] = useState('monthly')
|
||||
const [selectedPriceId, setSelectedPriceId] = useState(null)
|
||||
const [processingPriceId, setProcessingPriceId] = useState(null)
|
||||
const [errorMessage, setErrorMessage] = useState(null)
|
||||
const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true)
|
||||
|
||||
const normalizedPlansByPeriod = useMemo(() => {
|
||||
// Basic normalization/filter logic similar to Subscriptions.js
|
||||
const normalize = (plans) => {
|
||||
if (!Array.isArray(plans)) return []
|
||||
return plans.filter((p) => p && p.priceId)
|
||||
}
|
||||
return {
|
||||
monthly: normalize(subscriptions?.monthly),
|
||||
annual: normalize(subscriptions?.annual),
|
||||
}
|
||||
}, [subscriptions])
|
||||
|
||||
// Set default selection
|
||||
React.useEffect(() => {
|
||||
const plans = normalizedPlansByPeriod[selectedPeriodKey] || []
|
||||
if (plans.length > 0 && !selectedPriceId) {
|
||||
// Optional: Auto-select popular/first plan
|
||||
// But for now let's leave it null to force user choice or pick first
|
||||
}
|
||||
}, [normalizedPlansByPeriod, selectedPeriodKey, selectedPriceId])
|
||||
|
||||
|
||||
const handleShare = async (platform) => {
|
||||
const shareUrl =
|
||||
project?.songUrl ||
|
||||
project?.playbackUrl ||
|
||||
selectedOption?.finalUrl ||
|
||||
'https://musicland.ai'
|
||||
const shareText = `Check out my new song "${trackTitle}" created with MusicLand! ${shareUrl}`
|
||||
|
||||
if (platform === 'whatsapp') {
|
||||
Linking.openURL(`whatsapp://send?text=${encodeURIComponent(shareText)}`).catch(() => {
|
||||
Alert.alert('Erreur', 'WhatsApp is not installed')
|
||||
})
|
||||
} else if (platform === 'facebook') {
|
||||
Linking.openURL(
|
||||
`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`
|
||||
).catch(() => {
|
||||
Alert.alert('Erreur', 'Unable to open Facebook')
|
||||
})
|
||||
} else if (platform === 'instagram' || platform === 'tiktok') {
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(shareUrl, { dialogTitle: musiclandShareHeading })
|
||||
} else {
|
||||
Linking.openURL(shareUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleBuyCredits = async (pack) => {
|
||||
const targetId = pack?.productId
|
||||
if (!targetId) return
|
||||
setProcessingPriceId(targetId)
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
await createCoinPackCheckout(targetId)
|
||||
} catch (error) {
|
||||
setErrorMessage(error?.message || 'Erreur lors de l\'achat de crédits.')
|
||||
} finally {
|
||||
setProcessingPriceId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
if (!selectedPriceId) return
|
||||
setProcessingPriceId(selectedPriceId)
|
||||
try {
|
||||
await createSubscriptionCheckout(selectedPriceId)
|
||||
} catch (error) {
|
||||
setErrorMessage(error?.message || 'Erreur lors de l\'abonnement.')
|
||||
} finally {
|
||||
setProcessingPriceId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoHome = useCallback(() => {
|
||||
setIsVisible(false)
|
||||
navigate(Routes.Home)
|
||||
}, [setIsVisible])
|
||||
|
||||
const currentPlans = normalizedPlansByPeriod[selectedPeriodKey] || []
|
||||
|
||||
return (
|
||||
<Overlay isVisible={isVisible} setIsVisible={setIsVisible} blurIntensity={20}>
|
||||
<View style={styles.modalCard}>
|
||||
<View style={styles.closeButtonContainer}>
|
||||
<Pressable onPress={() => setIsVisible(false)} style={styles.closeButton}>
|
||||
<MaterialCommunityIcons name="close" size={24} color={Palette.white} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
{/* SHARE SECTION */}
|
||||
<Text style={styles.modalTitle}>Partage ton succès !</Text>
|
||||
<View style={styles.shareSection}>
|
||||
<Text style={styles.shareSubtitle}>Partage mon morceau</Text>
|
||||
<View style={styles.shareButtons}>
|
||||
<Pressable style={styles.shareBtn} onPress={() => handleShare('whatsapp')}>
|
||||
<MaterialCommunityIcons name="whatsapp" size={32} color="#25D366" />
|
||||
</Pressable>
|
||||
<Pressable style={styles.shareBtn} onPress={() => handleShare('facebook')}>
|
||||
<MaterialCommunityIcons name="facebook" size={32} color="#1877F2" />
|
||||
</Pressable>
|
||||
<Pressable style={styles.shareBtn} onPress={() => handleShare('instagram')}>
|
||||
<MaterialCommunityIcons name="instagram" size={32} color="#C13584" />
|
||||
</Pressable>
|
||||
<Pressable style={styles.shareBtn} onPress={() => handleShare('tiktok')}>
|
||||
<MaterialCommunityIcons name="music-note" size={32} color="#000000" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* CREDITS SECTION */}
|
||||
{coinPacks && coinPacks.length > 0 ? (
|
||||
<View style={styles.sectionContainer}>
|
||||
<Text style={styles.sectionTitle}>{COIN_PACK_SECTION_TITLE}</Text>
|
||||
<View style={styles.creditsGrid}>
|
||||
{coinPacks.map((pack) => {
|
||||
const rawAmount = pack.unitAmount ?? pack.amount ?? pack.price ?? 0
|
||||
const price = formatCurrency(rawAmount, pack.currency)
|
||||
const coins = Number(pack.metadata?.coins || 0)
|
||||
const packKey = getCoinPackKey(pack)
|
||||
const details = packKey ? COIN_PACK_DETAILS[packKey] : null
|
||||
const displayName = details?.label || pack.name
|
||||
const creditsText = details?.creditsDisplay
|
||||
const displayPrice = details?.priceDisplay || price || `${rawAmount / 100}€`
|
||||
const isPopular = details?.popular === true
|
||||
|
||||
return (
|
||||
<View key={pack.productId} style={styles.creditCard}>
|
||||
<View style={styles.creditInfoContainer}>
|
||||
<Text style={styles.creditName}>{displayName}</Text>
|
||||
{creditsText ? (
|
||||
<Text style={styles.creditAmountCustom}>{creditsText}</Text>
|
||||
) : (
|
||||
<CreditAmount
|
||||
value={coins}
|
||||
iconSize={24}
|
||||
textStyle={styles.creditAmountText}
|
||||
/>
|
||||
)}
|
||||
{details?.disclaimer ? (
|
||||
<Text style={styles.packDisclaimer}>{details.disclaimer}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{isPopular ? (
|
||||
<View style={styles.popularBadge}>
|
||||
<Text style={styles.popularBadgeText}>Le plus populaire !</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<GradientButton
|
||||
title={processingPriceId === pack.productId ? '...' : displayPrice}
|
||||
onPress={() => handleBuyCredits(pack)}
|
||||
disabled={Boolean(processingPriceId)}
|
||||
style={styles.creditButton}
|
||||
textStyle={styles.creditButtonText}
|
||||
gradientStyle={{ paddingVertical: 8, paddingHorizontal: 16 }}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* SUBSCRIPTIONS SECTION */}
|
||||
<View style={styles.sectionContainer}>
|
||||
<Text style={styles.sectionTitle}>Rejoignez le club MusicLand</Text>
|
||||
|
||||
{/* Period Toggle */}
|
||||
<View style={styles.segmentedControl}>
|
||||
{['monthly', 'annual'].map((key) => {
|
||||
const isActive = selectedPeriodKey === key
|
||||
const label = key === 'monthly' ? 'Mensuel' : 'Annuel'
|
||||
const showAnnualPromo = key === 'annual'
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
onPress={() => {
|
||||
setSelectedPeriodKey(key)
|
||||
setSelectedPriceId(null)
|
||||
}}
|
||||
style={[styles.segmentButton, isActive && styles.segmentButtonActive]}
|
||||
>
|
||||
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive]}>
|
||||
{label}
|
||||
</Text>
|
||||
{showAnnualPromo && (
|
||||
<View style={styles.segmentBadge}>
|
||||
<Text style={styles.segmentBadgeText}>-16%</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Plans Grid */}
|
||||
<View style={styles.plansGrid}>
|
||||
{isCatalogLoading && !currentPlans.length ? (
|
||||
<ActivityIndicator color={Palette.white} />
|
||||
) : (
|
||||
currentPlans.map((plan) => (
|
||||
<View key={plan.priceId} style={{ width: isWeb ? '30%' : '100%' }}>
|
||||
<SubscriptionCard
|
||||
plan={plan}
|
||||
selected={selectedPriceId === plan.priceId}
|
||||
onSelect={setSelectedPriceId}
|
||||
isAnnual={selectedPeriodKey === 'annual'}
|
||||
/>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: 20, width: '100%', alignItems: 'center' }}>
|
||||
<GradientButton
|
||||
title={processingPriceId && !coinPacks.find(p => p.productId === processingPriceId) ? 'Traitement...' : 'Choisir cet abonnement'}
|
||||
onPress={handleSubscribe}
|
||||
disabled={!selectedPriceId || Boolean(processingPriceId)}
|
||||
style={{ width: isWeb ? 320 : '100%' }}
|
||||
/>
|
||||
</View>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
|
||||
{/* BROADCAST SECTION */}
|
||||
<View style={styles.broadcastSection}>
|
||||
<View style={styles.consentContainer}>
|
||||
<AppCheckbox
|
||||
selected={hasAcceptedPublication}
|
||||
onPress={() => setHasAcceptedPublication((prev) => !prev)}
|
||||
label="J'accepte de diffuser mon contenu sur la plateforme de streaming de MusicLand"
|
||||
/>
|
||||
</View>
|
||||
<GradientButton
|
||||
title="Diffuser"
|
||||
onPress={handleGoHome}
|
||||
disabled={!hasAcceptedPublication}
|
||||
containerStyle={styles.broadcastButton}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Overlay>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShareAndCreditsModal
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalCard: {
|
||||
width: isWeb ? '90%' : '95%',
|
||||
maxWidth: 1000,
|
||||
height: isWeb ? '90%' : '85%',
|
||||
alignSelf: 'center',
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(23, 22, 33, 0.98)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: gutters,
|
||||
gap: gutters * 1.5,
|
||||
paddingBottom: gutters * 2,
|
||||
},
|
||||
closeButtonContainer: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
zIndex: 100,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
borderRadius: 20,
|
||||
},
|
||||
modalTitle: {
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
fontSize: 24,
|
||||
color: Palette.white,
|
||||
textAlign: 'center',
|
||||
marginTop: 10,
|
||||
},
|
||||
shareSection: {
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
shareSubtitle: {
|
||||
fontSize: 16,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
color: Palette.white,
|
||||
opacity: 0.9,
|
||||
},
|
||||
shareButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 20,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
shareBtn: {
|
||||
padding: 8,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
sectionContainer: {
|
||||
gap: 16,
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255, 255, 255, 0.08)',
|
||||
paddingTop: gutters,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
fontSize: 20,
|
||||
color: Palette.white,
|
||||
textAlign: 'center',
|
||||
},
|
||||
creditsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 16,
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
creditCard: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
width: isWeb ? 220 : '100%',
|
||||
minHeight: 160,
|
||||
gap: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
creditInfoContainer: {
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
creditName: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
textAlign: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
creditAmountCustom: {
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
fontSize: 16,
|
||||
color: Palette.white,
|
||||
textAlign: 'center',
|
||||
},
|
||||
creditAmountText: {
|
||||
fontSize: 20,
|
||||
},
|
||||
packDisclaimer: {
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 10,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
creditButton: {
|
||||
marginTop: 'auto',
|
||||
width: '100%',
|
||||
height: 36,
|
||||
minHeight: 36,
|
||||
},
|
||||
creditButtonText: {
|
||||
fontSize: 13,
|
||||
},
|
||||
segmentedControl: {
|
||||
flexDirection: 'row',
|
||||
alignSelf: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 4,
|
||||
borderRadius: 999,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
segmentButton: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 18,
|
||||
borderRadius: 999,
|
||||
position: 'relative',
|
||||
},
|
||||
segmentButtonActive: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.18)',
|
||||
},
|
||||
segmentLabel: {
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
},
|
||||
segmentLabelActive: {
|
||||
color: Palette.white,
|
||||
},
|
||||
segmentBadge: {
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 999,
|
||||
backgroundColor: Palette.red,
|
||||
},
|
||||
segmentBadgeText: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 11,
|
||||
color: Palette.white,
|
||||
},
|
||||
plansGrid: {
|
||||
width: '100%',
|
||||
flexDirection: isWeb ? 'row' : 'column',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
// Subscription Card Styles
|
||||
cardWrapper: {
|
||||
width: '100%',
|
||||
minHeight: 280,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
backgroundColor: 'rgba(12, 14, 18, 0.45)',
|
||||
},
|
||||
cardWrapperSelected: {
|
||||
borderColor: Palette.primary,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
cardWrapperPopular: {
|
||||
borderColor: Palette.primary,
|
||||
backgroundColor: 'rgba(112, 35, 247, 0.1)',
|
||||
},
|
||||
cardWrapperPressed: {
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
cardWrapperInactive: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
cardBlur: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
cardContent: {
|
||||
flex: 1,
|
||||
gap: 16,
|
||||
},
|
||||
badge: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: 'rgba(112, 35, 247, 0.25)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
badgeText: {
|
||||
color: Palette.primary,
|
||||
fontSize: 11,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
},
|
||||
cardHeader: {
|
||||
gap: 4,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
planName: {
|
||||
color: Palette.white,
|
||||
fontSize: 18,
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
},
|
||||
planBadgeImage: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
cardSubtitle: {
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 13,
|
||||
},
|
||||
priceBlock: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
secondaryPriceText: {
|
||||
color: Palette.primary,
|
||||
fontSize: 15,
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
},
|
||||
priceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
gap: 6,
|
||||
},
|
||||
priceValue: {
|
||||
color: Palette.white,
|
||||
fontSize: 20,
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
},
|
||||
period: {
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
fontSize: 13,
|
||||
},
|
||||
coinsPromoText: {
|
||||
color: Palette.primary,
|
||||
fontSize: 14,
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
},
|
||||
coinsPerMonthRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
coinsPerMonth: {
|
||||
fontSize: 14,
|
||||
color: Palette.primary,
|
||||
},
|
||||
coinsPerMonthSuffix: {
|
||||
fontSize: 13,
|
||||
color: Palette.primary,
|
||||
},
|
||||
cardBenefits: {
|
||||
gap: 6,
|
||||
},
|
||||
downloadLabel: {
|
||||
color: Palette.white,
|
||||
fontSize: 13,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
textAlign: 'center',
|
||||
},
|
||||
benefitPlus: {
|
||||
color: Palette.white,
|
||||
fontSize: 22,
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
},
|
||||
benefitsTitle: {
|
||||
color: Palette.white,
|
||||
fontSize: 13,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
},
|
||||
benefitBlock: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
benefitText: {
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 12,
|
||||
},
|
||||
benefitTextBold: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
color: Palette.white,
|
||||
},
|
||||
cardBenefitsItem: {
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 12,
|
||||
},
|
||||
popularBadge: {
|
||||
alignSelf: 'center',
|
||||
backgroundColor: Palette.primary,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
marginTop: 'auto',
|
||||
},
|
||||
popularBadgeText: {
|
||||
color: Palette.white,
|
||||
fontSize: 10,
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
annualBadge: {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
backgroundColor: Palette.red + '80',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
annualBadgeText: {
|
||||
color: Palette.white + '80',
|
||||
fontSize: 11,
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
},
|
||||
errorText: {
|
||||
color: Palette.red,
|
||||
textAlign: 'center',
|
||||
marginTop: 10,
|
||||
},
|
||||
broadcastSection: {
|
||||
marginTop: gutters,
|
||||
paddingTop: gutters,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255, 255, 255, 0.08)',
|
||||
gap: gutters,
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
},
|
||||
consentContainer: {
|
||||
width: '100%',
|
||||
paddingHorizontal: gutters,
|
||||
},
|
||||
broadcastButton: {
|
||||
width: isWeb ? 320 : '100%',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user