diff --git a/functions/helpers/prompts.js b/functions/helpers/prompts.js index fbca5bb..62ae5e1 100644 --- a/functions/helpers/prompts.js +++ b/functions/helpers/prompts.js @@ -4,8 +4,19 @@ exports.generatePicturePrompt = (project = {}) => { lyrics: lyricsRaw, musicConfig = {}, coverStyle: coverStyleRaw = "", + artistName: artistNameRaw = "", } = project || {}; + const sanitizeInline = (value = "") => { + if (typeof value !== "string") return ""; + return value.replace(/[\r\n]+/g, " ").replace(/[<>]/g, "").trim(); + }; + + const titleForPrompt = sanitizeInline(title) || "Sans titre"; + const artistName = + sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || ""); + const hasArtistName = artistName.length > 0; + const { genres = [], tempo = "", @@ -55,11 +66,17 @@ exports.generatePicturePrompt = (project = {}) => { const styleLine = coverStyle ? `${coverStyle}. ${paletteHint}` : `${paletteHint}. Style libre, artistique et lumineux.`; + const typographyLine = hasArtistName + ? `Reproduis strictement le titre de la chanson ${titleForPrompt} sans modification, sans traduction et sans ajout ou suppression de caractères. Ajoute également le nom de l'artiste ${artistName} de manière artistique, parfaitement lisible et hiérarchisée, en harmonie avec le style abstrait et lumineux de la pochette.` + : `Reproduis strictement le titre de la chanson ${titleForPrompt} sans modification, sans traduction et sans ajout ou suppression de caractères. Intègre-le de manière artistique et parfaitement lisible, en harmonie avec le style abstrait et lumineux de la pochette.`; + const artistLine = hasArtistName + ? ` ${artistName}\n` + : ""; const prompt = ` Créer une pochette d'album en adéquation avec les paroles de la musique - ${title || "Sans titre"} - ${tagsLine} + ${titleForPrompt} +${artistLine} ${tagsLine} ${lyricsLine} @@ -67,7 +84,7 @@ exports.generatePicturePrompt = (project = {}) => { Image carrée 1024x1024 pixels, résolution haute. La composition doit être dynamique et remplir toute la surface, sans laisser de bordures ni de zones vides. - Reproduis strictement le titre de la chanson ${title || ""} sans modification, sans traduction et sans ajout ou suppression de caractères. Intègre-le de manière artistique et parfaitement lisible, en harmonie avec le style abstrait et lumineux de la pochette. + ${typographyLine} Aucune faute d'orthographe n'est tolérée. Respecte la casse et l'orthographe exactes du titre fourni. diff --git a/functions/src/cover.js b/functions/src/cover.js index 1643505..987b6f4 100644 --- a/functions/src/cover.js +++ b/functions/src/cover.js @@ -14,6 +14,65 @@ const { sendNotification } = require("./notifications"); const bucket = admin.storage().bucket(); const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png"); +const pickFirstNonEmpty = (...values) => { + for (const value of values) { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + } + return ""; +}; + +const combineNames = (...parts) => + parts + .map((part) => (typeof part === "string" ? part.trim() : "")) + .filter(Boolean) + .join(" ") + .trim(); + +async function resolveArtistName(project = {}) { + const owner = project?.owner || {}; + const direct = pickFirstNonEmpty( + project?.artistName, + project?.userName, + owner?.artistName, + owner?.userName, + owner?.displayName + ); + if (direct) return direct; + + const userId = + typeof project?.userId === "string" && project.userId.trim() + ? project.userId.trim() + : ""; + if (!userId) return ""; + + try { + const userSnapshot = await refList.users.doc(userId).get(); + if (!userSnapshot?.exists) return ""; + const userData = userSnapshot.data() || {}; + const fullName = combineNames(userData.firstName, userData.lastName); + return ( + pickFirstNonEmpty( + userData.artistName, + userData.userName, + userData.displayName, + fullName + ) || "" + ); + } catch (error) { + logger.warn("⚠️ [Cover] Unable to resolve artist name", { + projectId: project?.id || null, + userId, + error: error?.message || String(error), + }); + return ""; + } +} + async function buildCoverWithLogo(backgroundUrl, targetPath) { logger.info("🖼️ [Cover] Adding logo to generated background"); const [{ data: backgroundBuffer }, logoBuffer] = await Promise.all([ @@ -66,7 +125,11 @@ async function buildCoverWithLogo(backgroundUrl, targetPath) { // Shared core for generating and saving the cover, and updating the project async function performCoverGeneration(project) { const t0 = Date.now(); - const prompt = generatePicturePrompt(project); + const artistName = await resolveArtistName(project); + const prompt = generatePicturePrompt({ + ...project, + artistName, + }); // Utiliser generateImageV2 qui renvoie directement l'URL publique logger.info("🎨 [Cover] Calling model V2", { projectId: project.id, diff --git a/src/assets/UI/libraryBG2Web.png b/src/assets/UI/libraryBG2Web.png new file mode 100644 index 0000000..4705feb Binary files /dev/null and b/src/assets/UI/libraryBG2Web.png differ diff --git a/src/assets/index.js b/src/assets/index.js index a2d2c5c..1262cc1 100644 --- a/src/assets/index.js +++ b/src/assets/index.js @@ -202,6 +202,7 @@ export const background = { libraryBG, libraryBgWeb: require("./UI/libraryBgWeb.png"), libraryBG2, + libraryBG2Web: require("./UI/libraryBG2Web.png"), profileBG, profileBgWeb: require("./UI/profileBgWeb.png"), hitParadeBG, diff --git a/src/components/FeatureCarousel/FeatureCarousel.js b/src/components/FeatureCarousel/FeatureCarousel.js index ae0861f..97dc078 100644 --- a/src/components/FeatureCarousel/FeatureCarousel.js +++ b/src/components/FeatureCarousel/FeatureCarousel.js @@ -15,7 +15,7 @@ import { useWindowDimensions, } from "react-native"; import { responsiveHeight } from "../../actions/responsiveSizes"; -import { ai } from "../../assets"; +import { ai, cardsImg } from "../../assets"; import { gutters } from "../../styles"; import { getCreationStageStates } from "../../utils/projectStages"; import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot"; @@ -36,15 +36,15 @@ const STAGE_CARD_CONTENT = [ }, { key: "director", - title: "John", - description: "Come back when your audio is ready!", + title: "Theo", + description: "Theo t'accompagne pour créer ton playback.", image: ai.rightIcon, }, { key: "publisher", - title: "Bena", + title: "Publication", description: "Ta vidéo est prête ? Direction YouTube !", - image: ai.bena, + image: cardsImg.production, }, ]; diff --git a/src/components/FeatureCarousel/FeatureCarousel.web.js b/src/components/FeatureCarousel/FeatureCarousel.web.js index b04faa6..8d27d1d 100644 --- a/src/components/FeatureCarousel/FeatureCarousel.web.js +++ b/src/components/FeatureCarousel/FeatureCarousel.web.js @@ -14,7 +14,7 @@ import { View, useWindowDimensions, } from "react-native"; -import { ai } from "../../assets"; +import { ai, cardsImg } from "../../assets"; import { getCreationStageStates } from "../../utils/projectStages"; import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot"; import PersonaCard from "../cards/PersonaCard/PersonaCard"; @@ -29,21 +29,21 @@ const STAGE_CARD_CONTENT = [ }, { key: "beatmaker", - title: "Malik", + title: "Theo", description: "Come back, when you'll have lyrics!", image: ai.rightIcon, }, { key: "director", - title: "John", - description: "Come back when your audio is ready!", + title: "Theo", + description: "Theo t'accompagne pour créer ton playback.", image: ai.rightIcon, }, { key: "publisher", - title: "Bena", + title: "Publication", description: "Ta vidéo est prête ? Direction YouTube !", - image: ai.bena, + image: cardsImg.production, }, ]; diff --git a/src/components/modal/CoinPackModal.js b/src/components/modal/CoinPackModal.js index 18df13f..b33f9e3 100644 --- a/src/components/modal/CoinPackModal.js +++ b/src/components/modal/CoinPackModal.js @@ -1,7 +1,6 @@ import React from "react"; import { ActivityIndicator, - Linking, Modal, Pressable, ScrollView, @@ -15,16 +14,11 @@ import GradientButton from "../GradientButton"; import CreateLyricsHeader from "../../screens/Writing/components/CreateLyricsHeader"; import { Palette, gutters } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; -import { getFunctionsClient } from "../../config/firebase"; import { isWeb } from "../../hooks/useLayoutType"; import CreditAmount from "../CreditAmount"; +import { useStripe } from "../../providers/StripeProvider"; const WEB_MODAL_MAX_WIDTH = 1000; -const FUNCTIONS_REGION = "europe-west1"; -const STRIPE_SUCCESS_URL = - "https://dashboard.stripe.com/test/billing/starter-guide/checkout-success"; -const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL; - const formatCurrency = (amount, currency = "eur") => { if (typeof amount !== "number") { return null; @@ -92,49 +86,60 @@ function CoinPackCard({ pack, selected, onSelect }) { } const CoinPackModal = ({ visible, onClose }) => { - const [coinPacks, setCoinPacks] = React.useState([]); const [selectedPackId, setSelectedPackId] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(false); const [isProcessing, setIsProcessing] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(null); const modalMaxWidth = isWeb ? WEB_MODAL_MAX_WIDTH : undefined; - const fetchCoinPacks = React.useCallback(async () => { - setIsLoading(true); - setErrorMessage(null); - try { - const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable( - "subscription-listCoinPacks", - ); - const { data } = await callable(); - const packs = Array.isArray(data?.packs) ? data.packs : []; - setCoinPacks(packs); - setSelectedPackId((current) => { - if ( - current && - packs.some((pack) => pack?.productId && pack.productId === current) - ) { - return current; - } - return packs[0]?.productId || null; - }); - } catch (error) { - console.error("[CoinPackModal] fetchCoinPacks error", error); - setErrorMessage( - error?.message || - "Impossible de charger les packs de pièces. Réessaie plus tard.", - ); - } finally { - setIsLoading(false); + const { + coinPacks, + isCatalogLoading, + catalogError, + refreshCatalog, + createCoinPackCheckout, + } = useStripe(); + + React.useEffect(() => { + if (!coinPacks.length) { + setSelectedPackId(null); + return; } - }, []); + setSelectedPackId((current) => { + if ( + current && + coinPacks.some((pack) => pack?.productId && pack.productId === current) + ) { + return current; + } + return coinPacks[0]?.productId || null; + }); + }, [coinPacks]); React.useEffect(() => { if (!visible) { return; } - fetchCoinPacks(); - }, [visible, fetchCoinPacks]); + if (!coinPacks.length && !isCatalogLoading && !catalogError) { + refreshCatalog(); + } + }, [ + visible, + coinPacks.length, + isCatalogLoading, + catalogError, + refreshCatalog, + ]); + + const closeModal = React.useCallback( + ({ force = false } = {}) => { + if (isProcessing && !force) { + return; + } + setIsProcessing(false); + onClose?.(); + }, + [isProcessing, onClose], + ); const handleCheckout = React.useCallback(async () => { if (!selectedPackId) { @@ -145,33 +150,8 @@ const CoinPackModal = ({ visible, onClose }) => { setErrorMessage(null); try { - const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable( - "subscription-createCoinPackCheckoutSession", - ); - const { data } = await callable({ - productId: selectedPackId, - returnUrls: { - successUrl: STRIPE_SUCCESS_URL, - cancelUrl: STRIPE_CANCEL_URL, - }, - }); - - const checkoutUrl = data?.url; - if (!checkoutUrl) { - throw new Error("Session Stripe introuvable."); - } - - if (isWeb) { - if (typeof window !== "undefined") { - window.location.assign(checkoutUrl); - } - } else { - const canOpen = await Linking.canOpenURL(checkoutUrl); - if (!canOpen) { - throw new Error("Impossible d'ouvrir l'URL de paiement."); - } - await Linking.openURL(checkoutUrl); - } + await createCoinPackCheckout(selectedPackId); + closeModal({ force: true }); } catch (error) { console.error("[CoinPackModal] checkout error", error); setErrorMessage( @@ -181,17 +161,17 @@ const CoinPackModal = ({ visible, onClose }) => { } finally { setIsProcessing(false); } - }, [selectedPackId, isWeb]); + }, [selectedPackId, createCoinPackCheckout, closeModal]); const handleClose = React.useCallback(() => { - if (isProcessing) { - return; - } - onClose?.(); - }, [isProcessing, onClose]); + closeModal(); + }, [closeModal]); + + const isLoadingCoinPacks = isCatalogLoading && !coinPacks.length; + const combinedErrorMessage = errorMessage || catalogError; const renderContent = () => { - if (isLoading) { + if (isLoadingCoinPacks) { return ( @@ -250,8 +230,8 @@ const CoinPackModal = ({ visible, onClose }) => { - {errorMessage ? ( - {errorMessage} + {combinedErrorMessage ? ( + {combinedErrorMessage} ) : null} {renderContent()} @@ -263,7 +243,7 @@ const CoinPackModal = ({ visible, onClose }) => { disabled={ !selectedPackId || isProcessing || - isLoading || + isLoadingCoinPacks || !coinPacks.length } /> diff --git a/src/data/data.js b/src/data/data.js index 309cd42..073aa8e 100644 --- a/src/data/data.js +++ b/src/data/data.js @@ -475,6 +475,7 @@ export const INSTRUMENTS = [ "Synthétiseur", "Guitare acoustique", "Guitare électrique", + "Batterie", "Banjo", "Violon", "Saxophone", diff --git a/src/hooks/usePaymentSession.js b/src/hooks/usePaymentSession.js index b64e188..7642b60 100644 --- a/src/hooks/usePaymentSession.js +++ b/src/hooks/usePaymentSession.js @@ -1,10 +1,10 @@ -import { useGlobal, useContext } from "reactn"; +import { useGlobal } from "reactn"; import firebase from "../config/firebase"; import { checkBillingDetails } from "../helpers"; -import { StripeEmbeddedContext } from "../providers/StripeEmbeddedProvider"; import { useWebView } from "../providers/WebViewProvider"; +import { useStripe } from "../providers/StripeProvider"; import useLayoutType from "./useLayoutType"; @@ -17,7 +17,7 @@ const usePaymentSession = () => { const [currentProjectID] = useGlobal("currentProjectID"); - const { setClientSecret } = useContext(StripeEmbeddedContext); + const { openEmbeddedCheckout } = useStripe(); const onCreatePaymentSession = async ({ numberOfCoin = 0, @@ -48,7 +48,7 @@ const usePaymentSession = () => { if (isNative) { setWebViewUrl(data); } else { - setClientSecret(data); + openEmbeddedCheckout(data); } } else { throw new Error("Erreur lors de la création du paiement"); diff --git a/src/providers/SharedProviders.js b/src/providers/SharedProviders.js index e6c57e3..64d6003 100644 --- a/src/providers/SharedProviders.js +++ b/src/providers/SharedProviders.js @@ -7,7 +7,7 @@ import { SheetProvider } from "react-native-actions-sheet"; import NotificationProvider from "./NotificationProvider"; import PlayerProvider from "./PlayerProvider"; import SplashAnimationProvider from "./SplashAnimationProvider"; -import StripeEmbeddedProvider from "./StripeEmbeddedProvider"; +import StripeProvider from "./StripeProvider"; import UniversalLinkProvider from "./UniversalLinkProvider"; import UserDataProvider from "./UserDataProvider"; import WebViewProvider from "./WebViewProvider"; @@ -19,7 +19,7 @@ const SharedProviders = ({ children }) => { [SplashAnimationProvider, {}], [WebViewProvider, {}], [UserDataProvider, {}], - [StripeEmbeddedProvider, {}], + [StripeProvider, {}], [UniversalLinkProvider, {}], [BottomSheetModalProvider, {}], [SheetProvider, {}], diff --git a/src/providers/StripeEmbeddedProvider.js b/src/providers/StripeEmbeddedProvider.js deleted file mode 100644 index 79823fe..0000000 --- a/src/providers/StripeEmbeddedProvider.js +++ /dev/null @@ -1,73 +0,0 @@ -import { - EmbeddedCheckoutProvider, - EmbeddedCheckout, -} from "@stripe/react-stripe-js"; -import { useState, createContext } from "react"; -import { View, StyleSheet } from "react-native"; - -import { loadStripe } from "@stripe/stripe-js"; - -import { - STRIPE_PUBLISHABLE_KEY_LIVE, - STRIPE_PUBLISHABLE_KEY_TEST, -} from "../data/keys"; -import { mainBorderRadius } from "../styles/Style"; -import Overlay from "../components/Overlay"; -import Button from "../components/Button"; -import useLayoutType from "../hooks/useLayoutType"; - -const isStripeTesting = false; -const stripePromise = loadStripe( - isStripeTesting ? STRIPE_PUBLISHABLE_KEY_TEST : STRIPE_PUBLISHABLE_KEY_LIVE -); - -export const StripeEmbeddedContext = createContext(); - -export default ({ children }) => { - const { isMobile } = useLayoutType(); - - const [clientSecret, setClientSecret] = useState(null); - - return ( - - {children} - setClientSecret(null)} - > - - - - - - - -