diff --git a/functions/src/notifications.js b/functions/src/notifications.js index 012c6eb..3c8b2af 100644 --- a/functions/src/notifications.js +++ b/functions/src/notifications.js @@ -13,7 +13,7 @@ const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null; // Initialisation de Expo SDK let expo = new Expo(); -const EMAIL_FROM = "MusicLand "; +const EMAIL_FROM = "MusicLand "; const DEFAULT_EMAIL_TITLE = "MusicLand"; function getCollectionRef(collectionName = "") { diff --git a/functions/src/subscription/schedule.js b/functions/src/subscription/schedule.js index 2cee821..f0dae19 100644 --- a/functions/src/subscription/schedule.js +++ b/functions/src/subscription/schedule.js @@ -12,7 +12,7 @@ const { const { ACTIVE_SUBSCRIPTION_STATUSES } = require("./constants"); // Toggle to stop monthly grants for annual subscriptions while keeping logic handy. -const ENABLE_ANNUAL_GRANT_SCHEDULER = false; +const ENABLE_ANNUAL_GRANT_SCHEDULER = true; const processAnnualSubscriptionAllowances = onSchedule( { diff --git a/functions/src/users.js b/functions/src/users.js index 9a8784b..32ceef8 100644 --- a/functions/src/users.js +++ b/functions/src/users.js @@ -14,7 +14,7 @@ const { onRequest } = require("firebase-functions/https"); const resendClient = new Resend(RESEND_API_KEY); const WELCOME_EMAIL_FROM = - process.env.RESEND_FROM_EMAIL || "MusicLand "; + process.env.RESEND_FROM_EMAIL || "MusicLand "; const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand"; exports.testWelcomMail = onRequest(async (req, res) => { diff --git a/src/components/SearchBar.js b/src/components/SearchBar.js index 13a311a..a4c40cf 100644 --- a/src/components/SearchBar.js +++ b/src/components/SearchBar.js @@ -1,6 +1,6 @@ import { BlurView } from "expo-blur"; -import React from "react"; -import { Image, TextInput, View } from "react-native"; +import React, { useCallback, useEffect, useRef } from "react"; +import { Image, Pressable, TextInput } from "react-native"; import { icons } from "../assets"; import { Palette } from "../styles"; import { FONT_FAMILY } from "../styles/Fonts"; @@ -10,8 +10,99 @@ const SearchBar = ({ placeholder = "Que souhaites-tu écouter?", textInputProps = {}, }) => { + const inputRef = useRef(null); + const hasForwardedFocusRef = useRef(false); + const focusFallbackRef = useRef(null); + + const { + onFocus: onFocusProp, + onPressIn: onPressInProp, + ref: textInputRefProp, + ...restTextInputProps + } = textInputProps; + + const forwardFocus = useCallback( + (event) => { + if (hasForwardedFocusRef.current) return; + hasForwardedFocusRef.current = true; + onFocusProp?.(event); + }, + [onFocusProp], + ); + + const handleFocus = useCallback( + (event) => { + forwardFocus(event); + }, + [forwardFocus], + ); + + const focusInput = useCallback(() => { + const node = inputRef.current; + if (!node) { + return; + } + if ( + typeof node.isFocused === "function" && + node.isFocused() && + hasForwardedFocusRef.current + ) { + return; + } + + hasForwardedFocusRef.current = false; + node.focus?.(); + + if (focusFallbackRef.current) { + cancelAnimationFrame(focusFallbackRef.current); + } + + focusFallbackRef.current = requestAnimationFrame(() => { + // Some platforms do not propagate the focus event when focus() is called programmatically. + if (!hasForwardedFocusRef.current) { + forwardFocus(); + } + + if (typeof node.isFocused === "function" && !node.isFocused()) { + node.focus?.(); + } + }); + }, [forwardFocus]); + + useEffect(() => { + return () => { + if (focusFallbackRef.current) { + cancelAnimationFrame(focusFallbackRef.current); + } + }; + }, []); + + const setRefs = useCallback( + (node) => { + inputRef.current = node; + + if (typeof textInputRefProp === "function") { + textInputRefProp(node); + } else if (textInputRefProp) { + textInputRefProp.current = node; + } + }, + [textInputRefProp], + ); + + const handleInputPressIn = useCallback( + (event) => { + focusInput(); + onPressInProp?.(event); + }, + [focusInput, onPressInProp], + ); + return ( - + - + ); }; diff --git a/src/components/SubscriptionBadge.js b/src/components/SubscriptionBadge.js new file mode 100644 index 0000000..8087e2d --- /dev/null +++ b/src/components/SubscriptionBadge.js @@ -0,0 +1,32 @@ +import React from "react"; +import { Image } from "react-native"; +import { subBadges } from "../assets"; + +const allowedLevels = new Set(["starter", "pro", "premium"]); + +const normalizeLevel = (value) => { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + return allowedLevels.has(normalized) ? normalized : null; +}; + +const SubscriptionBadge = ({ level = null, size = 20, style = null }) => { + const normalized = normalizeLevel(level); + const source = normalized ? subBadges[normalized] : null; + + if (!source) { + return null; + } + + return ( + + ); +}; + +export default React.memo(SubscriptionBadge); diff --git a/src/components/modal/CoinPackModal.js b/src/components/modal/CoinPackModal.js index 4476791..12f7c8f 100644 --- a/src/components/modal/CoinPackModal.js +++ b/src/components/modal/CoinPackModal.js @@ -18,7 +18,7 @@ import { isWeb } from "../../hooks/useLayoutType"; import CreditAmount from "../CreditAmount"; import { useStripe } from "../../providers/StripeProvider"; -const WEB_MODAL_MAX_WIDTH = 1000; +const WEB_MODAL_MAX_WIDTH = 820; const formatCurrency = (amount, currency = "eur") => { if (typeof amount !== "number") { return null; @@ -229,14 +229,6 @@ function CoinPackCard({ ) : null} {pack?.name ? {pack.name} : null} - - - - Diffusion sur mes plates formes de streaming - - - *Eligible au Hit parade Chanson/Video - - {formattedPrice ? ( {formattedPrice} @@ -388,6 +380,9 @@ const CoinPackModal = ({ visible, onClose }) => { width: "100%", maxWidth: modalMaxWidth, alignSelf: "center", + borderWidth: isWeb ? 1 : 0, + borderColor: "rgba(255,255,255,0.16)", + backgroundColor: isWeb ? "rgba(12, 10, 18, 0.85)" : undefined, }} > @@ -452,6 +447,7 @@ const styles = StyleSheet.create({ gap: 24, alignItems: "center", paddingBottom: gutters, + paddingHorizontal: isWeb ? gutters * 1.5 : 0, width: "100%", maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : undefined, }, @@ -503,7 +499,7 @@ const styles = StyleSheet.create({ maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : "100%", alignSelf: "center", gap: gutters, - paddingHorizontal: isWeb ? 8 : 0, + paddingHorizontal: isWeb ? gutters * 1.5 : 0, }, packListWeb: { flexDirection: "row", @@ -567,16 +563,6 @@ const styles = StyleSheet.create({ color: "rgba(255, 255, 255, 0.72)", textAlign: "center", }, - perksList: { - gap: 6, - alignItems: "center", - }, - perkItem: { - fontFamily: FONT_FAMILY.InterRegular, - fontSize: 13, - color: "rgba(255, 255, 255, 0.72)", - textAlign: "center", - }, priceBlock: { gap: 2, alignItems: "center", diff --git a/src/screens/CreatePassword.js b/src/screens/CreatePassword.js index 16c963d..ff79e8f 100644 --- a/src/screens/CreatePassword.js +++ b/src/screens/CreatePassword.js @@ -1,10 +1,13 @@ import { useRoute } from "@react-navigation/native"; -import React, { useMemo, useState } from "react"; -import { Text, View } from "react-native"; +import React, { useState } from "react"; +import { Modal, Pressable, ScrollView, Text, View } from "react-native"; +import { responsiveHeight } from "react-native-responsive-dimensions"; import { useGlobal } from "reactn"; import { background } from "../assets"; +import BorderGradientButton from "../components/BorderGradientButton"; import GradientButton from "../components/GradientButton"; import { Input } from "../components/Input"; +import AppCheckbox from "../components/AppCheckbox"; import ItemContainer from "../components/ItemContainer/ItemContainer"; import { checkIfPasswordIsStrongEnough, @@ -30,10 +33,11 @@ const CreatePassword = () => { const [password, setPassword] = useState(""); const [passwordError, setPasswordError] = useState(""); const [loading, setLoading] = useState(false); + const [isCguAccepted, setIsCguAccepted] = useState(false); + const [isCguModalVisible, setIsCguModalVisible] = useState(false); + const [hasScrolledCguToEnd, setHasScrolledCguToEnd] = useState(false); const [, setTooltip] = useGlobal("_tooltip"); - - const handlePasswordChange = (text) => { setPassword(text); if (!text) { @@ -52,6 +56,30 @@ const CreatePassword = () => { const isPasswordValid = checkIfPasswordIsStrongEnough({ password }); + const handleOpenCguModal = () => { + setHasScrolledCguToEnd(false); + setIsCguModalVisible(true); + }; + + const handleCloseCguModal = () => { + setIsCguModalVisible(false); + setHasScrolledCguToEnd(false); + }; + + const handleCguScroll = ({ nativeEvent }) => { + const { layoutMeasurement, contentOffset, contentSize } = nativeEvent; + const paddingToBottom = 20; + const contentFits = contentSize.height <= layoutMeasurement.height + 1; + const isAtEnd = + layoutMeasurement.height + contentOffset.y >= + contentSize.height - paddingToBottom; + if (isAtEnd) { + setHasScrolledCguToEnd(true); + } else if (contentFits) { + setHasScrolledCguToEnd(true); + } + }; + const onCreateAccount = async () => { try { if (!isPasswordValid) { @@ -62,6 +90,14 @@ const CreatePassword = () => { return; } + if (!isCguAccepted) { + setTooltip({ + text: "Tu dois accepter les CGU pour continuer", + type: "error", + }); + return; + } + setLoading(true); const cred = await firebase .auth() @@ -110,8 +146,19 @@ const CreatePassword = () => { headerType="NAVIGATION" title="Inscription" > - - + + @@ -154,18 +201,303 @@ const CreatePassword = () => { ) : null} + + + + + + Voir les CGU + + + + + + + + + + X + + + + + Conditions Générales d'Utilisation + + + + Cupidatat incididunt aute aute velit deserunt labore excepteur + eu velit cillum est. Laboris in sunt ea Lorem culpa velit nisi + ad elit esse voluptate id adipisicing nulla magna. Duis qui + fugiat consequat elit velit aute ad. Reprehenderit proident + duis exercitation velit proident aute minim. Anim + reprehenderit mollit consectetur id sint adipisicing pariatur + tempor sit pariatur proident nostrud. Sit culpa tempor enim ut + consectetur sunt aute est reprehenderit incididunt incididunt + in ullamco consectetur. Eiusmod non incididunt proident + eiusmod. Esse Lorem ut amet in est id aute consectetur. + + + Aliqua est sint consectetur occaecat exercitation. Elit nisi + velit ad qui eiusmod reprehenderit proident labore nostrud + labore consequat enim fugiat non. Eiusmod occaecat pariatur + deserunt velit elit sint irure fugiat excepteur labore ad + velit ex deserunt laborum. Aliqua excepteur reprehenderit + nostrud aliqua pariatur aliqua excepteur Lorem consectetur. Ut + est culpa ullamco ipsum Lorem ullamco ea labore anim. + + + Et ut ex duis tempor amet aliquip cupidatat sit sit. Aliqua + consectetur amet duis fugiat nulla duis culpa exercitation + reprehenderit. Ea voluptate proident ad elit pariatur do. + Culpa incididunt deserunt dolor ut officia aliquip ut + occaecat eiusmod sint veniam. Deserunt consequat adipisicing + aliquip velit labore fugiat aute culpa id sint. Adipisicing eu + commodo ex do aliqua in labore laboris sit sunt adipisicing + excepteur. Do sint velit veniam pariatur consequat proident + cupidatat in incididunt ullamco. + + + Culpa eu cillum fugiat elit eiusmod enim. Cupidatat culpa + aliquip culpa et tempor est velit. Aute id deserunt non ut + minim deserunt adipisicing sit veniam eu id incididunt + adipisicing dolore. Eiusmod nisi excepteur est voluptate + consequat reprehenderit non exercitation commodo aute. + + + Eu culpa enim dolore anim ipsum eu veniam mollit dolore + officia cupidatat laborum officia do. Ad proident in anim nisi + exercitation consequat exercitation occaecat consequat ut + laborum esse consectetur ullamco. Enim elit sit in id velit + quis nostrud nostrud eu labore aute reprehenderit voluptate + deserunt. Minim voluptate et duis voluptate enim duis. + + + Minim laboris deserunt minim ex. Duis aliquip cillum proident. + Magna velit nisi fugiat. Non id proident pariatur elit in + exercitation et amet id qui ad laborum nulla ullamco ea. + + + Cillum nisi aute dolore culpa est veniam cupidatat sunt sint + ipsum. Amet ullamco minim non voluptate cillum dolore sunt + irure nulla pariatur excepteur voluptate id. Commodo ullamco + aliqua non ea mollit ullamco do minim dolor magna. Ipsum eu + minim quis laborum do dolore labore eiusmod et. Ullamco + officia velit anim. Exercitation voluptate reprehenderit ex et + do eu fugiat tempor do cillum ad. + + + Cupidatat consectetur excepteur commodo laborum incididunt + laboris minim dolore ut ipsum dolor ullamco culpa aliquip. + Cillum proident quis consectetur voluptate labore nisi Lorem + pariatur in esse. Amet ipsum mollit officia fugiat Lorem ipsum + elit officia. Esse reprehenderit magna quis irure sit + consectetur dolore sunt mollit aliquip eiusmod voluptate amet. + Ex irure culpa ea cupidatat nulla ea labore aute occaecat + consequat consectetur cillum amet. Velit ad do occaecat non + elit quis. Amet id reprehenderit ullamco amet tempor deserunt + exercitation elit consectetur minim aliqua. Cillum do aliquip + do ea ipsum veniam deserunt in ipsum pariatur nisi proident et + ut deserunt. + + + Mollit et adipisicing velit tempor deserunt excepteur fugiat + eiusmod adipisicing. Tempor do ex duis dolor deserunt cillum + officia est nisi mollit fugiat. Amet duis ea laboris officia + aliquip id sint voluptate consectetur velit elit + reprehenderit nostrud exercitation. Ea dolore adipisicing nulla + incididunt laboris sint commodo non mollit eiusmod. Tempor + nulla eu laborum tempor veniam laboris consequat non consequat + exercitation pariatur velit. Voluptate magna mollit esse + incididunt id. Pariatur eu irure esse ullamco fugiat culpa. + + + Sint dolore aliqua pariatur do mollit occaecat deserunt qui + proident non exercitation mollit cillum culpa. Est cupidatat + consequat ea commodo laborum labore sunt excepteur labore aute + est amet anim. Tempor deserunt labore mollit enim officia + aliqua occaecat. Ullamco mollit qui mollit do ex irure enim. + + + {hasScrolledCguToEnd ? ( + + { + setIsCguAccepted(true); + handleCloseCguModal(); + }} + /> + + + ) : ( + + Fais défiler jusqu'en bas pour afficher les actions + + )} + + + + ); }; diff --git a/src/screens/HitParade/HitParade.js b/src/screens/HitParade/HitParade.js index aafb3a6..bd2d128 100644 --- a/src/screens/HitParade/HitParade.js +++ b/src/screens/HitParade/HitParade.js @@ -21,7 +21,7 @@ import { background, icons } from "../../assets"; import BorderGradientButton from "../../components/BorderGradientButton"; import SearchBar from "../../components/SearchBar"; import ShareBtn from "../../components/ShareBtn/ShareBtn"; -import { projectsRef } from "../../config/firebase"; +import firebase, { projectsRef, usersRef } from "../../config/firebase"; import useDataFromRef from "../../hooks/useDataFromRef"; import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails"; import useSearch from "../../hooks/useSearch"; @@ -37,6 +37,25 @@ import SearchResultsList from "../Library/components/SearchResultsList"; import PlaybacksCard from "./components/PlaybacksCard"; import SongCard from "./components/SongCard"; import BorderGradient from "../../components/BorderGradient/BorderGradient.web"; + +const allowedPremiumLevels = new Set(["starter", "pro", "premium"]); + +const normalizePremiumLevel = (value) => { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + return allowedPremiumLevels.has(normalized) ? normalized : null; +}; + +const chunkArray = (items = [], size = 10) => { + const chunks = []; + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)); + } + return chunks; +}; + const HitParade = () => { const [selectedCategory, setSelectedCategory] = useState("Chansons"); const navigateToMusicDetails = useNavigateToMusicDetails(); @@ -92,6 +111,7 @@ const HitParade = () => { title={item?.title || "Sans titre"} artist={item?.userName} coverUrl={item?.coverUrl || null} + subscriptionLevel={getCreatorLevel(item)} onPress={() => navigateToMusicDetails({ projectId: item.id, @@ -118,6 +138,7 @@ const HitParade = () => { title={item?.title || "Sans titre"} artist={item?.userName} thumbnailUrl={resolvePlaybackThumbnail(item)} + subscriptionLevel={getCreatorLevel(item)} onPress={() => navigate(Routes.Playbacks, { projectId: item.id })} /> )} @@ -133,6 +154,7 @@ const HitParade = () => { title={item?.title || "Sans titre"} artist={item?.userName} coverUrl={item?.coverUrl || null} + subscriptionLevel={getCreatorLevel(item)} onPress={() => navigateToMusicDetails({ projectId: item.id, @@ -154,6 +176,7 @@ const HitParade = () => { title={item?.title || "Sans titre"} artist={item?.userName} thumbnailUrl={resolvePlaybackThumbnail(item)} + subscriptionLevel={getCreatorLevel(item)} onPress={() => navigate(Routes.Playbacks, { projectId: item.id })} /> ))} @@ -188,6 +211,99 @@ const HitParade = () => { [musics] ); + const [creatorsById, setCreatorsById] = useState({}); + + const requiredUserIds = useMemo(() => { + const collected = new Set(); + const collectUserId = (project) => { + const uid = + project && typeof project.userId === "string" + ? project.userId.trim() + : null; + if (uid) { + collected.add(uid); + } + }; + songsList.forEach(collectUserId); + playbackList.forEach(collectUserId); + return Array.from(collected); + }, [playbackList, songsList]); + + const fetchCreatorsByIds = useCallback( + async (userIds) => { + if (!Array.isArray(userIds) || userIds.length === 0) { + return; + } + + const normalizedIds = userIds + .map((id) => (typeof id === "string" ? id.trim() : null)) + .filter(Boolean); + + if (normalizedIds.length === 0) { + return; + } + + const pendingIds = new Set(normalizedIds); + const nextCreators = {}; + + const idChunks = chunkArray(normalizedIds, 10); + + await Promise.all( + idChunks.map(async (chunk) => { + try { + const snapshot = await usersRef + .where(firebase.firestore.FieldPath.documentId(), "in", chunk) + .get(); + + snapshot.docs.forEach((doc) => { + const data = doc.data() || {}; + nextCreators[doc.id] = { + premiumLevel: normalizePremiumLevel(data.premiumLevel), + }; + pendingIds.delete(doc.id); + }); + } catch (error) { + console.log( + "HitParade: unable to fetch creators", + error?.message || error + ); + } + }) + ); + + pendingIds.forEach((userId) => { + nextCreators[userId] = { premiumLevel: null }; + }); + + if (Object.keys(nextCreators).length > 0) { + setCreatorsById((previous) => ({ ...previous, ...nextCreators })); + } + }, + [setCreatorsById] + ); + + useEffect(() => { + const missingIds = requiredUserIds.filter((id) => !creatorsById[id]); + if (missingIds.length === 0) { + return; + } + fetchCreatorsByIds(missingIds); + }, [creatorsById, fetchCreatorsByIds, requiredUserIds]); + + const getCreatorLevel = useCallback( + (project) => { + const uid = + project && typeof project.userId === "string" + ? project.userId.trim() + : null; + if (!uid) { + return null; + } + return creatorsById?.[uid]?.premiumLevel || null; + }, + [creatorsById] + ); + const playbackResults = useMemo( () => (Array.isArray(playbacks) ? playbacks : []), [playbacks] @@ -246,7 +362,7 @@ const HitParade = () => { }, [closeDropdown, dropdownVisible, isWeb]); const shouldShowResults = isWeb && dropdownVisible; - const shouldBlurContent = shouldShowResults && hasSearchQuery; + const shouldBlurContent = shouldShowResults; const handlePlayRandomSong = useCallback(() => { const arr = Array.isArray(topSongs) ? topSongs : []; @@ -520,7 +636,9 @@ const HitParade = () => { StyleSheet.absoluteFillObject, { zIndex: 10, + borderRadius: 18, backgroundColor: "rgba(0, 0, 0, 0.25)", + overflow: "hidden", }, ]} /> diff --git a/src/screens/HitParade/components/PlaybacksCard.js b/src/screens/HitParade/components/PlaybacksCard.js index b064109..8f54f50 100644 --- a/src/screens/HitParade/components/PlaybacksCard.js +++ b/src/screens/HitParade/components/PlaybacksCard.js @@ -4,6 +4,7 @@ import { BlurView } from "expo-blur"; import { img } from "../../../assets"; import { Palette, Style } from "../../../styles"; import { FONT_FAMILY } from "../../../styles/Fonts"; +import SubscriptionBadge from "../../../components/SubscriptionBadge"; const PlaybacksCard = ({ rank = 1, @@ -12,6 +13,7 @@ const PlaybacksCard = ({ thumbnailUrl = null, coverUrl = null, onPress = () => null, + subscriptionLevel = null, }) => { const resolveUri = (value) => typeof value === "string" && value.trim().length > 0 ? value : null; @@ -38,43 +40,67 @@ const PlaybacksCard = ({ style={{ ...Style.containerRow, gap: 15, + alignItems: "center", + justifyContent: "space-between", + width: "100%", }} > - - {rank} - - - - {title} - - - {artist} + {rank} + + + + {title} + + + + {artist} + + + + diff --git a/src/screens/HitParade/components/SongCard.js b/src/screens/HitParade/components/SongCard.js index 12fea59..0e847e9 100644 --- a/src/screens/HitParade/components/SongCard.js +++ b/src/screens/HitParade/components/SongCard.js @@ -12,6 +12,7 @@ import { BlurView } from "expo-blur"; import Style, { size } from "../../../styles/Style"; import { Palette } from "../../../styles"; import { FONT_FAMILY } from "../../../styles/Fonts"; +import SubscriptionBadge from "../../../components/SubscriptionBadge"; const SongCard = ({ rank = 1, @@ -19,6 +20,7 @@ const SongCard = ({ artist = "MusicLand", coverUrl = null, onPress = null, + subscriptionLevel = null, }) => { return ( {rank} - + {title} - - {artist} - + + {artist} + + + diff --git a/src/screens/Library/Library.web.js b/src/screens/Library/Library.web.js index be59be1..977f292 100644 --- a/src/screens/Library/Library.web.js +++ b/src/screens/Library/Library.web.js @@ -104,7 +104,7 @@ const Library = () => { }; const shouldShowResults = dropdownVisible; - const shouldBlurContent = dropdownVisible && hasSearchQuery; + const shouldBlurContent = dropdownVisible; const handleChangeText = (value) => { setSearch(value); @@ -214,8 +214,9 @@ const Library = () => { StyleSheet.absoluteFillObject, { zIndex: 10, - borderRadius: 0, + borderRadius: 18, backgroundColor: "rgba(0, 0, 0, 0.25)", + overflow: "hidden", }, ]} /> diff --git a/src/screens/Studio/GeneratingSong.js b/src/screens/Studio/GeneratingSong.js index 93510f7..a8f77e0 100644 --- a/src/screens/Studio/GeneratingSong.js +++ b/src/screens/Studio/GeneratingSong.js @@ -2,16 +2,15 @@ import { useIsFocused } from "@react-navigation/native"; import { BlurView } from "expo-blur"; import moment from "moment"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Image, Platform, Text, View } from "react-native"; +import { Platform, Text, View } from "react-native"; import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import { responsiveHeight } from "react-native-responsive-dimensions"; -import { ai, background } from "../../assets"; +import { background } from "../../assets"; import AppAlert from "../../components/Alert"; import GradientButton from "../../components/GradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; import ProgressBar from "../../components/ProgressBar"; import firebase, { projectsRef } from "../../config/firebase"; -import { isWeb } from "../../hooks/useLayoutType"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; import { navigate } from "../../navigation/NavigationService"; @@ -23,6 +22,7 @@ import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; const GeneratingSong = () => { const { selectedProjectId, selectedProject } = useUser(); const [progress, setProgress] = useState(0); + const [isGenerationInFlight, setIsGenerationInFlight] = useState(false); const { setIsLoading } = useMinuit(); const progressTimerRef = useRef(null); const navigatedRef = useRef(false); @@ -59,21 +59,29 @@ const GeneratingSong = () => { ? selectedProject.musicUrls.filter(Boolean) : []; const hasReadySong = readyUrls.length > 0 || !!selectedProject?.songUrl; + const isActivelyGenerating = + status === "GENERATING" || + (isGenerationInFlight && + status !== "FAILED" && + status !== "GENERATED"); - if (status !== "GENERATING") { + if (!isActivelyGenerating) { // Reset local start when leaving generating state localStartRef.current = null; } if (status === "GENERATED") { clearTimer(); + setIsGenerationInFlight(false); setProgress(hasReadySong ? 100 : maxGeneratingProgress); return () => clearTimer(); } - if (status !== "GENERATING") { + if (!isActivelyGenerating) { clearTimer(); - setProgress(0); + if (status !== "FAILED") { + setProgress(0); + } return () => clearTimer(); } @@ -81,11 +89,8 @@ const GeneratingSong = () => { const gs = selectedProject?.generationStartAt; if (gs?.toDate) return gs.toDate(); if (gs) return new Date(gs); - if (status === "GENERATING") { - if (!localStartRef.current) localStartRef.current = new Date(); - return localStartRef.current; - } - return null; + if (!localStartRef.current) localStartRef.current = new Date(); + return localStartRef.current; }; const update = () => { @@ -97,7 +102,7 @@ const GeneratingSong = () => { const elapsed = moment().diff(moment(startDate)); const raw = Math.floor((elapsed / totalMs) * 100); // While status is GENERATING, block visual progress at 90% - const pct = Math.max(0, Math.min(maxGeneratingProgress, raw)); + const pct = Math.max(1, Math.min(maxGeneratingProgress, raw)); setProgress(pct); }; @@ -106,6 +111,7 @@ const GeneratingSong = () => { progressTimerRef.current = global.setInterval(update, 1000); return () => clearTimer(); }, [ + isGenerationInFlight, selectedProject?.musicStatus, selectedProject?.generationStartAt, selectedProject?.musicUrls, @@ -121,6 +127,15 @@ const GeneratingSong = () => { } }, [selectedProject?.musicStatus, selectedProjectId]); + useEffect(() => { + if ( + selectedProject?.musicStatus === "GENERATED" || + selectedProject?.musicStatus === "FAILED" + ) { + setIsGenerationInFlight(false); + } + }, [selectedProject?.musicStatus]); + const effectiveConfig = useMemo(() => { // Use selectedProject.musicConfig only const cfg = selectedProject?.musicConfig || {}; @@ -137,6 +152,11 @@ const GeneratingSong = () => { async function startMusicGeneration() { try { console.log("startMusicGeneration"); + setIsGenerationInFlight(true); + if (!localStartRef.current) { + localStartRef.current = new Date(); + } + setProgress(1); await setIsLoading(true); const callable = firebase .functions() @@ -216,6 +236,7 @@ const GeneratingSong = () => { } } askedRef.current = false; + setIsGenerationInFlight(false); } finally { await setIsLoading(false); } @@ -279,7 +300,11 @@ const GeneratingSong = () => { ]); const progressStatus = isFailed ? "error" : "default"; - const progressLabel = isFailed ? "Erreur" : `${progress}%`; + const displayProgress = Math.max( + 0, + Math.min(100, Math.round(progress || 0)), + ); + const progressLabel = isFailed ? "Erreur" : `${displayProgress}%`; return ( @@ -338,7 +363,7 @@ const GeneratingSong = () => { { return `tous les ${count} ${count > 1 ? terms.plural : terms.singular}`; }; -function SubscriptionCard({ plan, selected, onSelect }) { +function SubscriptionCard({ plan, selected, onSelect, isAnnual }) { const planKey = getPlanKeyForBadge(plan); const planName = (planKey && PLAN_DISPLAY_NAME_BY_KEY[planKey]) || @@ -87,7 +87,6 @@ function SubscriptionCard({ plan, selected, onSelect }) { const planBadgeKey = planKey; const planBadgeSource = planBadgeKey && subBadges[planBadgeKey] ? subBadges[planBadgeKey] : null; - const planFeatures = SUBSCRIPTION_FEATURES; const handleSelect = React.useCallback(() => { if (typeof onSelect === "function" && plan?.priceId) { onSelect(plan.priceId); @@ -156,22 +155,23 @@ function SubscriptionCard({ plan, selected, onSelect }) { ) : null} - {planFeatures?.length ? ( - - {planFeatures.map((feature) => ( - - {feature} - - ))} - - ) : null} + {isAnnual ? ( + + 2 mois offert + + ) : null} ); } const PRICE_PRIORITY_BY_PERIOD = { + monthly: [ + "price_1SPgitCzf2o5bDRdbnhLFx6f", // Starter + "price_1SPgjCCzf2o5bDRdr08Xzp8u", // Pro + "price_1SPgjaCzf2o5bDRdd9Xo2u26", // Premium + ], annual: [ "price_1SPgkDCzf2o5bDRdNGLVNeQ3", // Starter "price_1SPgkXCzf2o5bDRdejBVxEBY", // Pro @@ -180,6 +180,11 @@ const PRICE_PRIORITY_BY_PERIOD = { }; const PACK_PRICE_ID_BY_PERIOD = { + monthly: { + starter: "price_1SPgitCzf2o5bDRdbnhLFx6f", + pro: "price_1SPgjCCzf2o5bDRdr08Xzp8u", + premium: "price_1SPgjaCzf2o5bDRdd9Xo2u26", + }, annual: { starter: "price_1SPgkDCzf2o5bDRdNGLVNeQ3", pro: "price_1SPgkXCzf2o5bDRdejBVxEBY", @@ -212,12 +217,6 @@ const PLAN_DISPLAY_NAME_BY_KEY = { premium: "Play Backer Gold", }; -const SUBSCRIPTION_FEATURES = [ - "*Eligible au Hit parade Vidéo", - "Privilège Membre : Récompense doublée", - "Eligible au hit parade Artiste trimestriel et annuel", -]; - const getPlanKeyForBadge = (plan) => { if (!plan || typeof plan !== "object") { return null; @@ -293,7 +292,7 @@ const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360; const PAGE_BACKGROUND_COLOR = "#303438"; const SUBSCRIPTION_DISCLAIMER = "Résiliable en un clic à tout moment. Les prix sont indiqués TTC. Contacte-nous pour des besoins spécifiques (facturation annuelle, volume, offres éducation)."; -const CARD_MIN_HEIGHT = isWeb ? 320 : 240; +const CARD_MIN_HEIGHT = isWeb ? 260 : 180; export default function Subscriptions() { const route = useRoute(); @@ -313,16 +312,29 @@ export default function Subscriptions() { catalogError, createSubscriptionCheckout, } = useStripe(); + const [selectedPeriodKey, setSelectedPeriodKey] = React.useState("annual"); const [selectedPriceId, setSelectedPriceId] = React.useState(null); const [processingPriceId, setProcessingPriceId] = React.useState(null); const [errorMessage, setErrorMessage] = React.useState(null); const initialPackHandledRef = React.useRef(false); - const normalizedPlans = React.useMemo( - () => normalizePlans(subscriptions?.annual, "annual"), - [subscriptions?.annual] + const normalizedPlansByPeriod = React.useMemo( + () => ({ + monthly: normalizePlans(subscriptions?.monthly, "monthly"), + annual: normalizePlans(subscriptions?.annual, "annual"), + }), + [subscriptions?.annual, subscriptions?.monthly] ); - const hasAnyPlan = (normalizedPlans?.length || 0) > 0; + + const availablePeriods = React.useMemo( + () => + ["monthly", "annual"].filter( + (period) => (normalizedPlansByPeriod?.[period]?.length || 0) > 0 + ), + [normalizedPlansByPeriod] + ); + + const hasAnyPlan = availablePeriods.length > 0; const isLoadingPlans = isCatalogLoading && !hasAnyPlan; const combinedErrorMessage = errorMessage || catalogError; @@ -331,25 +343,39 @@ export default function Subscriptions() { }, [initialSubscriptionPack]); React.useEffect(() => { + const periodEntries = Object.entries(normalizedPlansByPeriod).filter( + ([, plans]) => (plans?.length || 0) > 0 + ); + + if (!periodEntries.length) { + setSelectedPriceId(null); + return; + } + const shouldApplyPack = Boolean(initialSubscriptionPack) && !initialPackHandledRef.current; let matchedPriceId = null; + let matchedPeriodKey = selectedPeriodKey; + let nextPeriodKey = + periodEntries.find(([periodKey]) => periodKey === selectedPeriodKey)?.[0] || + periodEntries[0]?.[0] || + "annual"; - if (shouldApplyPack && normalizedPlans?.length) { + if (shouldApplyPack) { const desired = initialSubscriptionPack.trim().toLowerCase(); - const candidateId = PACK_PRICE_ID_BY_PERIOD?.annual?.[desired]; - if (candidateId) { - const exists = normalizedPlans.some( - (plan) => plan?.priceId === candidateId - ); - if (exists) { - matchedPriceId = candidateId; + for (const [periodKey, plans] of periodEntries) { + const candidateId = PACK_PRICE_ID_BY_PERIOD?.[periodKey]?.[desired]; + if (candidateId) { + const exists = plans.some((plan) => plan?.priceId === candidateId); + if (exists) { + matchedPriceId = candidateId; + matchedPeriodKey = periodKey; + break; + } } - } - if (!matchedPriceId) { - const matched = normalizedPlans.find((plan) => { + const matched = plans.find((plan) => { const label = (plan?.product?.name || plan?.nickname || "") .toString() .toLowerCase(); @@ -358,25 +384,38 @@ export default function Subscriptions() { if (matched?.priceId) { matchedPriceId = matched.priceId; + matchedPeriodKey = periodKey; + break; } } } - setSelectedPriceId((current) => { + if (matchedPriceId) { + nextPeriodKey = matchedPeriodKey; + } + + const plansForPeriod = normalizedPlansByPeriod?.[nextPeriodKey] || []; + + const nextPriceId = (() => { if (matchedPriceId) { return matchedPriceId; } - - const hasCurrent = normalizedPlans?.some( - (plan) => plan.priceId === current + const hasCurrent = plansForPeriod.some( + (plan) => plan.priceId === selectedPriceId ); - if (hasCurrent) { - return current; + return selectedPriceId; } + return plansForPeriod?.[0]?.priceId || null; + })(); - return normalizedPlans?.[0]?.priceId || null; - }); + if (nextPeriodKey !== selectedPeriodKey) { + setSelectedPeriodKey(nextPeriodKey); + } + + if (nextPriceId !== selectedPriceId) { + setSelectedPriceId(nextPriceId); + } if (matchedPriceId || (shouldApplyPack && !isCatalogLoading)) { initialPackHandledRef.current = true; @@ -384,10 +423,12 @@ export default function Subscriptions() { }, [ initialSubscriptionPack, isCatalogLoading, - normalizedPlans, + normalizedPlansByPeriod, + selectedPeriodKey, + selectedPriceId, ]); - const currentPlans = normalizedPlans || []; + const currentPlans = normalizedPlansByPeriod?.[selectedPeriodKey] || []; const handleSelect = React.useCallback( (priceId) => { @@ -438,13 +479,36 @@ export default function Subscriptions() { goBack(); }, []); + const handlePeriodChange = React.useCallback( + (periodKey) => { + if (!periodKey || periodKey === selectedPeriodKey) { + return; + } + setSelectedPeriodKey(periodKey); + setSelectedPriceId(null); + setProcessingPriceId(null); + }, + [selectedPeriodKey] + ); + const renderHeaderSection = React.useCallback(() => { return ( <> - Choisissez l’abonnement qui vous correspond + Rejoignez le club MusicLand + + Privilège Membre : + + + - Eligible au concours mensuel chanson/Vidéo + + + - Crédits gratuits tous les mois + + + {combinedErrorMessage ? ( {combinedErrorMessage} @@ -453,6 +517,66 @@ export default function Subscriptions() { ); }, [combinedErrorMessage]); + const renderSegmentedControl = React.useCallback(() => { + const segments = [ + { key: "monthly", label: "Mensuel", plans: normalizedPlansByPeriod?.monthly }, + { key: "annual", label: "Annuel", plans: normalizedPlansByPeriod?.annual }, + ]; + + const visibleSegments = segments.filter( + (segment) => (segment.plans?.length || 0) > 0 + ); + + if (visibleSegments.length <= 1) { + return null; + } + + return ( + + {visibleSegments.map((segment) => { + const isActive = selectedPeriodKey === segment.key; + const showAnnualPromo = segment.key === "annual"; + return ( + handlePeriodChange(segment.key)} + style={[ + styles.segmentButton, + isActive && styles.segmentButtonActive, + ]} + > + + {segment.label} + + {showAnnualPromo ? ( + + -16% + + ) : null} + + ); + })} + + ); + }, [ + handlePeriodChange, + isMobile, + normalizedPlansByPeriod, + selectedPeriodKey, + ]); + const renderMobilePlanItem = React.useCallback( ({ item }) => ( @@ -460,10 +584,11 @@ export default function Subscriptions() { plan={item} selected={selectedPriceId === item.priceId} onSelect={handleSelect} + isAnnual={selectedPeriodKey === "annual"} /> ), - [handleSelect, selectedPriceId] + [handleSelect, selectedPeriodKey, selectedPriceId] ); const renderMobileEmptyComponent = React.useCallback(() => { @@ -497,9 +622,10 @@ export default function Subscriptions() { return ( {renderHeaderSection()} + {renderSegmentedControl()} ); - }, [renderHeaderSection]); + }, [renderHeaderSection, renderSegmentedControl]); const renderMobileBottomActions = React.useCallback(() => { return ( @@ -588,6 +714,8 @@ export default function Subscriptions() { <> {renderHeaderSection()} + {renderSegmentedControl()} + {isLoadingPlans && currentPlanCount === 0 ? ( @@ -607,6 +735,7 @@ export default function Subscriptions() { plan={plan} selected={selectedPriceId === plan.priceId} onSelect={handleSelect} + isAnnual={selectedPeriodKey === "annual"} /> ))} @@ -700,6 +829,29 @@ const styles = StyleSheet.create({ gap: 12, alignItems: "center", }, + benefitsBox: { + width: "100%", + gap: 6, + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 16, + backgroundColor: "rgba(255, 255, 255, 0.08)", + }, + benefitsTitle: { + fontFamily: FONT_FAMILY.InterSemiBold, + fontSize: 14, + color: Palette.white, + textAlign: "center", + }, + benefitsList: { + gap: 2, + }, + benefitsItem: { + fontFamily: FONT_FAMILY.InterMedium, + fontSize: 13, + color: "rgba(255, 255, 255, 0.85)", + textAlign: "center", + }, webBackContainer: { alignSelf: "flex-start", marginBottom: 12, @@ -760,6 +912,61 @@ const styles = StyleSheet.create({ alignItems: "stretch", justifyContent: "center", }, + segmentedControl: { + flexDirection: "row", + alignSelf: "center", + justifyContent: "center", + padding: 4, + borderRadius: 999, + backgroundColor: "rgba(255, 255, 255, 0.08)", + marginTop: isWeb ? 12 : 8, + marginBottom: isWeb ? 8 : 4, + }, + 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, + }, + annualBadge: { + position: "absolute", + top: isWeb ? 12 : 10, + right: isWeb ? 12 : 10, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 999, + backgroundColor: Palette.red, + }, + annualBadgeText: { + fontFamily: FONT_FAMILY.InterSemiBold, + fontSize: 12, + color: Palette.white, + }, actions: { width: "100%", alignItems: "center", @@ -772,6 +979,7 @@ const styles = StyleSheet.create({ width: "100%", minWidth: 0, minHeight: CARD_MIN_HEIGHT, + position: "relative", borderRadius: 24, overflow: "hidden", borderWidth: 1, @@ -854,16 +1062,6 @@ const styles = StyleSheet.create({ fontSize: 13, color: Palette.primary, }, - features: { - marginTop: isWeb ? 12 : 8, - gap: 6, - }, - featureText: { - fontFamily: FONT_FAMILY.InterMedium, - fontSize: 13, - lineHeight: 18, - color: "rgba(255, 255, 255, 0.85)", - }, priceValue: { fontFamily: FONT_FAMILY.InterBold, fontSize: isWeb ? 22 : 20, @@ -904,6 +1102,10 @@ const styles = StyleSheet.create({ backgroundColor: PAGE_BACKGROUND_COLOR, alignItems: "center", }, + segmentedControlMobile: { + alignSelf: "center", + marginBottom: 0, + }, mobileList: { flex: 1, width: "100%", diff --git a/src/screens/cover/PouchReady.js b/src/screens/cover/PouchReady.js index af8ee59..fc1b115 100644 --- a/src/screens/cover/PouchReady.js +++ b/src/screens/cover/PouchReady.js @@ -445,8 +445,17 @@ const PouchReady = () => { Génération en cours. )} - - + + { size={22} color={Palette.white} /> - Télécharger la musique + + Acheter ce morceau pour 1,99€ +