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)}
- >
-
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/src/providers/StripeProvider.js b/src/providers/StripeProvider.js
new file mode 100644
index 0000000..bbc5778
--- /dev/null
+++ b/src/providers/StripeProvider.js
@@ -0,0 +1,342 @@
+import React from "react";
+import { Linking, StyleSheet, View } from "react-native";
+import {
+ EmbeddedCheckout,
+ EmbeddedCheckoutProvider,
+} from "@stripe/react-stripe-js";
+import { loadStripe } from "@stripe/stripe-js";
+
+import Overlay from "../components/Overlay";
+import Button from "../components/Button";
+import useLayoutType from "../hooks/useLayoutType";
+import { mainBorderRadius } from "../styles/Style";
+import {
+ STRIPE_PUBLISHABLE_KEY_LIVE,
+ STRIPE_PUBLISHABLE_KEY_TEST,
+} from "../data/keys";
+import { getFunctionsClient } from "../config/firebase";
+import { isWeb } from "../hooks/useLayoutType";
+import { useUserData } from "./UserDataProvider";
+import { formatDate, toDate } from "../utils/dateFormatting";
+
+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 isStripeTesting = false;
+const stripePromise = loadStripe(
+ isStripeTesting ? STRIPE_PUBLISHABLE_KEY_TEST : STRIPE_PUBLISHABLE_KEY_LIVE,
+);
+
+export const StripeContext = React.createContext(null);
+
+export const useStripe = () => {
+ const context = React.useContext(StripeContext);
+ if (!context) {
+ throw new Error("useStripe must be used within a StripeProvider");
+ }
+ return context;
+};
+
+const StripeProvider = ({ children }) => {
+ const { isMobile } = useLayoutType();
+ const { currentUserData } = useUserData() || {};
+
+ const [clientSecret, setClientSecret] = React.useState(null);
+ const [subscriptions, setSubscriptions] = React.useState({
+ monthly: [],
+ annual: [],
+ });
+ const [coinPacks, setCoinPacks] = React.useState([]);
+ const [isCatalogLoading, setIsCatalogLoading] = React.useState(true);
+ const [catalogError, setCatalogError] = React.useState(null);
+ const [activeSubscription, setActiveSubscription] = React.useState(null);
+ const [isActiveSubscriptionLoading, setIsActiveSubscriptionLoading] =
+ React.useState(false);
+ const [activeSubscriptionError, setActiveSubscriptionError] =
+ React.useState(null);
+
+ const stripeCustomerId = currentUserData?.stripeCustomerId || null;
+ const localSubscriptionId =
+ currentUserData?.stripeSubscription?.id ||
+ currentUserData?.stripeSubscription?.subscriptionId ||
+ null;
+
+ const closeEmbeddedCheckout = React.useCallback(() => {
+ setClientSecret(null);
+ }, []);
+
+ const redirectToCheckout = React.useCallback(async (checkoutUrl) => {
+ if (!checkoutUrl) {
+ throw new Error("Session Stripe introuvable.");
+ }
+
+ if (isWeb) {
+ if (typeof window !== "undefined") {
+ window.location.assign(checkoutUrl);
+ return;
+ }
+ throw new Error("Navigation Stripe impossible dans cet environnement.");
+ }
+
+ const canOpen = await Linking.canOpenURL(checkoutUrl);
+ if (!canOpen) {
+ throw new Error("Impossible d'ouvrir l'URL de paiement.");
+ }
+ await Linking.openURL(checkoutUrl);
+ }, []);
+
+ const runCheckoutSession = React.useCallback(
+ async ({ callableName, payload, logTag }) => {
+ try {
+ const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
+ callableName,
+ );
+ const { data } = await callable(payload);
+ const checkoutUrl = data?.url;
+ if (!checkoutUrl) {
+ throw new Error("Session Stripe introuvable.");
+ }
+ await redirectToCheckout(checkoutUrl);
+ } catch (error) {
+ console.error(`[StripeProvider] ${logTag}`, error);
+ throw new Error(
+ error?.message ||
+ "Une erreur est survenue lors de la création de la session Stripe.",
+ );
+ }
+ },
+ [redirectToCheckout],
+ );
+
+ const createSubscriptionCheckout = React.useCallback(
+ async (priceId) => {
+ if (!priceId) {
+ throw new Error("Aucun abonnement sélectionné.");
+ }
+ await runCheckoutSession({
+ callableName: "subscription-createSubscriptionCheckoutSession",
+ payload: {
+ priceId,
+ returnUrls: {
+ successUrl: STRIPE_SUCCESS_URL,
+ cancelUrl: STRIPE_CANCEL_URL,
+ },
+ },
+ logTag: "subscription checkout error",
+ });
+ },
+ [runCheckoutSession],
+ );
+
+ const createCoinPackCheckout = React.useCallback(
+ async (productId) => {
+ if (!productId) {
+ throw new Error("Aucun pack sélectionné.");
+ }
+ await runCheckoutSession({
+ callableName: "subscription-createCoinPackCheckoutSession",
+ payload: {
+ productId,
+ returnUrls: {
+ successUrl: STRIPE_SUCCESS_URL,
+ cancelUrl: STRIPE_CANCEL_URL,
+ },
+ },
+ logTag: "coin pack checkout error",
+ });
+ },
+ [runCheckoutSession],
+ );
+
+ const fetchActiveSubscription = React.useCallback(async () => {
+ const hasLookupContext =
+ Boolean(stripeCustomerId) || Boolean(localSubscriptionId);
+ if (!hasLookupContext) {
+ setActiveSubscription(null);
+ setActiveSubscriptionError(null);
+ setIsActiveSubscriptionLoading(false);
+ return;
+ }
+
+ setIsActiveSubscriptionLoading(true);
+ setActiveSubscriptionError(null);
+ try {
+ const callable = getFunctionsClient(
+ FUNCTIONS_REGION,
+ ).httpsCallable("subscription-getActiveSubscription");
+ const { data } = await callable();
+ setActiveSubscription(data?.subscription || null);
+ } catch (error) {
+ console.error("[StripeProvider] getActiveSubscription error", error);
+ setActiveSubscriptionError(
+ error?.message ||
+ "Impossible de mettre à jour les informations d'abonnement.",
+ );
+ setActiveSubscription(null);
+ } finally {
+ setIsActiveSubscriptionLoading(false);
+ }
+ }, [stripeCustomerId, localSubscriptionId]);
+
+ const fetchStripeCatalog = React.useCallback(async () => {
+ setIsCatalogLoading(true);
+ setCatalogError(null);
+ const functionsClient = getFunctionsClient(FUNCTIONS_REGION);
+ let lastError = null;
+
+ try {
+ const callable =
+ functionsClient.httpsCallable("subscription-listSubscriptionPlans");
+ const { data } = await callable();
+ const nextPlans = data?.plans || {};
+ setSubscriptions({
+ monthly: Array.isArray(nextPlans?.monthly) ? nextPlans.monthly : [],
+ annual: Array.isArray(nextPlans?.annual) ? nextPlans.annual : [],
+ });
+ } catch (error) {
+ lastError = error;
+ console.error("[StripeProvider] listSubscriptionPlans error", error);
+ }
+
+ try {
+ const callable =
+ functionsClient.httpsCallable("subscription-listCoinPacks");
+ const { data } = await callable();
+ setCoinPacks(Array.isArray(data?.packs) ? data.packs : []);
+ } catch (error) {
+ lastError = error;
+ console.error("[StripeProvider] listCoinPacks error", error);
+ }
+
+ if (lastError) {
+ setCatalogError(
+ lastError?.message ||
+ "Impossible de récupérer les informations Stripe pour le moment.",
+ );
+ } else {
+ setCatalogError(null);
+ }
+ setIsCatalogLoading(false);
+ }, []);
+
+ React.useEffect(() => {
+ fetchStripeCatalog();
+ }, [fetchStripeCatalog]);
+
+ React.useEffect(() => {
+ fetchActiveSubscription();
+ }, [fetchActiveSubscription]);
+
+ const activeSubscriptionInfo = React.useMemo(() => {
+ const subscription = activeSubscription || null;
+ const nextRenewalDate =
+ toDate(subscription?.currentPeriodEnd) ||
+ toDate(subscription?.current_period_end) ||
+ null;
+ const subscribedSinceDate =
+ toDate(subscription?.created) ||
+ toDate(subscription?.createdAt) ||
+ toDate(subscription?.created_at) ||
+ null;
+
+ return {
+ subscription,
+ nextRenewalDate,
+ nextRenewalLabel: nextRenewalDate ? formatDate(nextRenewalDate) : null,
+ subscribedSinceDate,
+ subscribedSinceLabel: subscribedSinceDate
+ ? formatDate(subscribedSinceDate)
+ : null,
+ };
+ }, [activeSubscription]);
+
+ const providerValue = React.useMemo(
+ () => ({
+ subscriptions,
+ coinPacks,
+ isCatalogLoading,
+ catalogError,
+ refreshCatalog: fetchStripeCatalog,
+ createSubscriptionCheckout,
+ createCoinPackCheckout,
+ openEmbeddedCheckout: setClientSecret,
+ closeEmbeddedCheckout,
+ activeSubscription: activeSubscriptionInfo.subscription,
+ activeSubscriptionInfo,
+ isActiveSubscriptionLoading,
+ activeSubscriptionError,
+ refreshActiveSubscription: fetchActiveSubscription,
+ }),
+ [
+ subscriptions,
+ coinPacks,
+ isCatalogLoading,
+ catalogError,
+ fetchStripeCatalog,
+ createSubscriptionCheckout,
+ createCoinPackCheckout,
+ setClientSecret,
+ closeEmbeddedCheckout,
+ activeSubscriptionInfo,
+ isActiveSubscriptionLoading,
+ activeSubscriptionError,
+ fetchActiveSubscription,
+ ],
+ );
+
+ return (
+
+ {children}
+
+
+
+ {clientSecret ? (
+
+
+
+ ) : null}
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ overlayContent: {
+ position: "absolute",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ embeddedWrapper: {
+ alignSelf: "center",
+ height: "70vh",
+ borderRadius: mainBorderRadius,
+ overflow: "scroll",
+ },
+});
+
+export default StripeProvider;
diff --git a/src/providers/index.web.js b/src/providers/index.web.js
index c2ee959..fc8d0ca 100644
--- a/src/providers/index.web.js
+++ b/src/providers/index.web.js
@@ -2,7 +2,6 @@ import React from "react";
import LoadingProvider from "./LoadingProvider";
import TooltipProvider from "./TooltipProvider";
-import StripeEmbeddedProvider from "./StripeEmbeddedProvider";
import SharedProviders from "./SharedProviders";
@@ -10,9 +9,7 @@ export default ({ children }) => {
return (
-
- {children}
-
+ {children}
);
diff --git a/src/screens/ForgotPassword.js b/src/screens/ForgotPassword.js
index c59a9fc..d89cf04 100644
--- a/src/screens/ForgotPassword.js
+++ b/src/screens/ForgotPassword.js
@@ -99,8 +99,10 @@ export default ({ navigation }) => {
{
const HOME_BACKGROUND_WIDTH = isWeb ? 1280 : 520;
const HOME_BACKGROUND_HEIGHT = isWeb ? 760 : 360;
+const HOME_BACKGROUND_STYLE_WIDTH = HOME_BACKGROUND_WIDTH + 120;
+const HOME_BACKGROUND_STYLE_HEIGHT = HOME_BACKGROUND_HEIGHT + 80;
const STAGE_CARD_CONTENT = [
{
@@ -59,7 +60,7 @@ const STAGE_CARD_CONTENT = [
key: "beatmaker",
step: "ÉTAPE 2",
description:
- "Je suis Malik, responsable du studio de musicland, je vais mettre en musique tes paroles en fonction de tes goûts musicaux, ça va être top!",
+ "Bienvenue au studio MusicLand.\nNous mettons en musique tes paroles en fonction de tes inspirations, ça va être top !",
image: cardsImg.studio,
imagePosition: "right",
textAlign: "right",
@@ -79,7 +80,7 @@ const STAGE_CARD_CONTENT = [
key: "publisher",
step: "ÉTAPE 4",
description:
- "Je suis Mr Benhaï, Producteur de MusicLand et je vais te faire une proposition qui pourrait t’intéresser, on se retrouve à la sotie du studio!",
+ "À la sortie du studio, l'équipe production te propose les prochaines étapes pour partager ta musique avec le monde.",
image: cardsImg.production,
imagePosition: "right",
textAlign: "right",
@@ -292,14 +293,14 @@ const Home = ({ navigation, route }) => {
: null;
Alert(
"Céline",
- "Bravo ! Vous venez de terminer de créer les paroles de votre musique ! Maintenant vous pouvez passer à la prochaine étape : le Studio avec Malik. Dans cette étape vous allez pouvoir donner vie à votre chanson !",
+ "Bravo ! Vous venez de terminer de créer les paroles de votre musique ! Maintenant vous pouvez passer à la prochaine étape : le Studio pour donner vie à votre chanson !",
[
{
text: "Retour à l'accueil",
style: "cancel",
},
{
- text: "Continuer avec Malik",
+ text: "Continuer vers le Studio",
onPress: () => {
if (!currentProject?.id) {
return;
@@ -448,6 +449,68 @@ const Home = ({ navigation, route }) => {
navigate(Routes.Payments);
}, []);
+ const stageCardContainerStyle = isWeb ? styles.cardsGrid : styles.cardsStack;
+ const stageCardItemStyle = isWeb
+ ? styles.webStageCard
+ : styles.mobileStageCard;
+
+ const stageCardsList = (
+
+ {stageCards.map((card) => (
+ handleStagePress(card.key, card.isLocked)}
+ containerStyle={stageCardItemStyle}
+ variant={isWeb ? "web" : "mobile"}
+ />
+ ))}
+
+ );
+
+ const journeyContent = (
+ <>
+
+
+
+
+
+
+ 5 espaces à découvrir
+
+ {stageCardsList}
+
+
+ >
+ );
+
return adventureStarted ? (
{
contentFit="cover"
style={styles.centerImage}
/>
-
-
-
- {!isWeb && }
-
-
-
-
- 5 espaces à découvrir
-
-
- {stageCards.map((card) => (
- handleStagePress(card.key, card.isLocked)}
- />
- ))}
-
-
-
+ {isWeb ? (
+ {journeyContent}
+ ) : (
+
+ {journeyContent}
+
+ )}
@@ -534,7 +560,7 @@ const Home = ({ navigation, route }) => {
}}
>
@@ -572,20 +598,27 @@ const styles = StyleSheet.create({
alignSelf: "stretch",
alignItems: "stretch",
justifyContent: "flex-start",
+ position: "relative",
},
centerImage: {
- width: HOME_BACKGROUND_WIDTH + 120,
- height: HOME_BACKGROUND_HEIGHT + 80,
+ width: HOME_BACKGROUND_STYLE_WIDTH,
+ height: HOME_BACKGROUND_STYLE_HEIGHT,
borderRadius: 22,
overflow: "hidden",
position: "absolute",
- top: "45%",
+ top: isWeb ? "45%" : "50%",
left: "50%",
transform: [
- { translateX: -HOME_BACKGROUND_WIDTH / 2 },
- { translateY: -HOME_BACKGROUND_HEIGHT / 2 },
+ { translateX: -HOME_BACKGROUND_STYLE_WIDTH / 2 },
+ { translateY: -HOME_BACKGROUND_STYLE_HEIGHT / 2 },
],
pointerEvents: "none",
+ zIndex: 0,
+ },
+ contentOverlay: {
+ position: "relative",
+ width: "100%",
+ flexGrow: 1,
},
topBar: {
width: "100%",
@@ -594,8 +627,8 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "center",
gap: 12,
- marginTop: 24,
- marginBottom: 8,
+ marginTop: isWeb ? 24 : 0,
+ marginBottom: isWeb ? 8 : 16,
zIndex: 10,
},
projectDropDown: {
@@ -603,6 +636,15 @@ const styles = StyleSheet.create({
maxWidth: 420,
flexGrow: 1,
},
+ mobileScrollView: {
+ flex: 1,
+ width: "100%",
+ },
+ mobileScrollContent: {
+ paddingHorizontal: gutters,
+ paddingTop: 24,
+ paddingBottom: gutters * 6,
+ },
subtitle: {
width: "100%",
fontFamily: FONT_FAMILY.InterSemiBold,
@@ -621,4 +663,16 @@ const styles = StyleSheet.create({
justifyContent: "space-between",
rowGap: 20,
},
+ cardsStack: {
+ width: "100%",
+ flexDirection: "column",
+ alignItems: "stretch",
+ },
+ webStageCard: {
+ width: "48%",
+ },
+ mobileStageCard: {
+ width: "100%",
+ marginBottom: 20,
+ },
});
diff --git a/src/screens/Home/components/StageCard.js b/src/screens/Home/components/StageCard.js
index e701ee4..378255b 100644
--- a/src/screens/Home/components/StageCard.js
+++ b/src/screens/Home/components/StageCard.js
@@ -14,21 +14,41 @@ const StageCard = ({
textAlign = "left",
isLocked,
onPress,
+ containerStyle = null,
+ variant = "web",
}) => {
- const isImageOnLeft = imagePosition !== "right";
- const isTextRight = textAlign === "right";
+ const isMobileVariant = variant === "mobile";
+ const isImageOnLeft = !isMobileVariant && imagePosition !== "right";
+ const isTextRight = !isMobileVariant && textAlign === "right";
- const imageBlock = (
-
+ }),
+ };
+
+ const imageBlock = (
+
@@ -70,9 +94,11 @@ const StageCard = ({
alignItems: "center",
marginBottom: 8,
},
- isTextRight
- ? { justifyContent: "flex-end" }
- : { justifyContent: "flex-start" },
+ isMobileVariant
+ ? { justifyContent: "center" }
+ : isTextRight
+ ? { justifyContent: "flex-end" }
+ : { justifyContent: "flex-start" },
]}
>
@@ -92,21 +122,29 @@ const StageCard = ({
);
- const textBlock = (
-
{isLocked ? (
@@ -141,7 +191,11 @@ const StageCard = ({
{header}
);
- const content = isImageOnLeft ? (
+ const content = isMobileVariant ? (
+ <>
+ {imageBlock}
+ {textBlock}
+ >
+ ) : isImageOnLeft ? (
<>
{imageBlock}
{textBlock}
@@ -172,15 +231,14 @@ const StageCard = ({
onPress={onPress}
disabled={isLocked}
style={({ pressed }) => [
- {
- width: "48%",
- },
+ basePressableStyle,
+ containerStyle,
pressed && !isLocked && { opacity: 0.85 },
]}
>
{content}
diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js
index c1bfdbe..dc8d284 100644
--- a/src/screens/Library/MusicDetails.web.js
+++ b/src/screens/Library/MusicDetails.web.js
@@ -159,7 +159,7 @@ const MusicDetails = ({ route }) => {
title,
artist,
}),
- [projectId, title, artist]
+ [projectId, title, artist],
);
const handleShare = useCallback(() => {
@@ -488,7 +488,13 @@ const MusicDetails = ({ route }) => {
console.log("MusicDetails seek error", e?.message);
}
},
- [trackDescriptor, sliderDurationMs, isCurrentTrack, ensureLoaded, seekTrackTo]
+ [
+ trackDescriptor,
+ sliderDurationMs,
+ isCurrentTrack,
+ ensureLoaded,
+ seekTrackTo,
+ ],
);
const handleToggleLoop = useCallback(async () => {
@@ -582,13 +588,7 @@ const MusicDetails = ({ route }) => {
wasPlayingBeforeSeek.current = false;
lastSeekTargetMs.current = null;
}
- }, [
- ensureLoaded,
- isCurrentTrack,
- positionMs,
- resumeTrack,
- seekTrackTo,
- ]);
+ }, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack, seekTrackTo]);
const handleSeekBySeconds = useCallback(
async (deltaSeconds) => {
@@ -605,7 +605,7 @@ const MusicDetails = ({ route }) => {
console.log("MusicDetails seekBy error", e?.message);
}
},
- [trackDescriptor, positionMs, isCurrentTrack, ensureLoaded, seekTrackBy]
+ [trackDescriptor, positionMs, isCurrentTrack, ensureLoaded, seekTrackBy],
);
const handleLyricsSeek = useCallback(
@@ -638,7 +638,7 @@ const MusicDetails = ({ route }) => {
seekTrackTo,
isTrackPlaying,
resumeTrack,
- ]
+ ],
);
const description = useMemo(() => {
// Build a readable text from lyrics with section labels
@@ -676,12 +676,12 @@ const MusicDetails = ({ route }) => {
// Current time in seconds for highlighting
const currentTimeS = useMemo(
() => Math.max(0, (positionMs || 0) / 1000),
- [positionMs]
+ [positionMs],
);
// Preview lead: show words 0.5s earlier
const visibleTimeS = useMemo(
() => Math.max(0, currentTimeS + 0.5),
- [currentTimeS]
+ [currentTimeS],
);
// Helpers to group aligned words into sections and timed lines
const SECTION_TAG_REGEX = /^\s*\[([^\]]+)\]\s*$/i;
@@ -844,11 +844,11 @@ const MusicDetails = ({ route }) => {
const startS = lines.reduce(
(acc, line) =>
Math.min(acc, Number.isFinite(line.startS) ? line.startS : acc),
- Number.POSITIVE_INFINITY
+ Number.POSITIVE_INFINITY,
);
const endS = lines.reduce(
(acc, line) => Math.max(acc, Number(line.endS || 0)),
- 0
+ 0,
);
grouped.push({
key: current.key,
@@ -910,7 +910,7 @@ const MusicDetails = ({ route }) => {
const flatLines = useMemo(
() => sections.flatMap((section) => section.lines),
- [sections]
+ [sections],
);
const currentLineIdx = useMemo(() => {
if (!flatLines || flatLines.length === 0) return -1;
@@ -941,14 +941,14 @@ const MusicDetails = ({ route }) => {
lineRelativeYRef.current[lineIdx] = { sectionKey, relativeY };
updateLineAbsoluteOffset(lineIdx);
},
- [updateLineAbsoluteOffset]
+ [updateLineAbsoluteOffset],
);
const registerSectionOffset = useCallback(
(sectionKey, y, lines = []) => {
sectionYRef.current[sectionKey] = y;
lines.forEach((line) => updateLineAbsoluteOffset(line.globalIdx));
},
- [updateLineAbsoluteOffset]
+ [updateLineAbsoluteOffset],
);
useEffect(() => {
const y = lineYRef.current?.[currentLineIdx];
@@ -966,7 +966,9 @@ const MusicDetails = ({ route }) => {
topStickyContent={renderWebBackButton}
title={action === "userProfile" ? "Mon profil" : "Détail musique"}
backgroundImg={
- action === "userProfile" ? background.profileBG : background.libraryBG2
+ action === "userProfile"
+ ? background.profileBG
+ : background.libraryBG2Web
}
shareBtn={
sharePayload
@@ -1179,7 +1181,7 @@ const MusicDetails = ({ route }) => {
registerSectionOffset(
section.key,
e.nativeEvent?.layout?.y,
- section.lines
+ section.lines,
)
}
style={[
@@ -1198,7 +1200,7 @@ const MusicDetails = ({ route }) => {
registerLineRelativeOffset(
line.globalIdx,
section.key,
- e.nativeEvent?.layout?.y ?? 0
+ e.nativeEvent?.layout?.y ?? 0,
);
}}
style={{
diff --git a/src/screens/NewMusicOptions.js b/src/screens/NewMusicOptions.js
index e7f6c52..7b303ab 100644
--- a/src/screens/NewMusicOptions.js
+++ b/src/screens/NewMusicOptions.js
@@ -2,7 +2,7 @@ import FontAwesome from "@expo/vector-icons/FontAwesome";
import { BlurView } from "expo-blur";
import React, { useCallback, useMemo } from "react";
import { Image, Platform, Pressable, Text, View } from "react-native";
-import { ai, background } from "../assets";
+import { ai, background, cardsImg } from "../assets";
import Page from "../layouts/Page";
import { navigate } from "../navigation/NavigationService";
import { useUserData } from "../providers/UserDataProvider";
@@ -32,15 +32,15 @@ const CREATE_DATA = [
stageKey: "director",
img: ai.john,
bg: background.playbackBG,
- label: "John",
- desc: "Come back when your\naudio is ready!",
+ label: "Theo",
+ desc: "Theo t'accompagne pour créer ton playback.",
type: "Director",
},
{
stageKey: "publisher",
- img: ai.bena,
+ img: cardsImg.production,
bg: background.productionBG2,
- label: "Bena",
+ label: "Publication",
desc: "Publions ta vidéo sur YouTube !",
type: "Producteur",
},
diff --git a/src/screens/Payments.js b/src/screens/Payments.js
index 8c17224..53c14f3 100644
--- a/src/screens/Payments.js
+++ b/src/screens/Payments.js
@@ -1,7 +1,6 @@
import React from "react";
import {
ActivityIndicator,
- Linking,
Pressable,
StyleSheet,
Text,
@@ -17,13 +16,7 @@ import { background, subBadges } from "../assets";
import { Palette, gutters } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { isWeb } from "../hooks/useLayoutType";
-import { getFunctionsClient } from "../config/firebase";
-
-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;
+import { useStripe } from "../providers/StripeProvider";
const formatCurrency = (amount, currency = "eur") => {
if (typeof amount !== "number") {
@@ -298,120 +291,114 @@ export default function Payments() {
initialPack && initialPack !== "packs" ? initialPack : null;
const backgroundImage = background.bgTrans;
- const [plans, setPlans] = React.useState({ monthly: [], annual: [] });
+ const {
+ subscriptions,
+ isCatalogLoading,
+ catalogError,
+ createSubscriptionCheckout,
+ } = useStripe();
const [selectedPriceIds, setSelectedPriceIds] = React.useState({
monthly: null,
annual: null,
});
const [billingPeriod, setBillingPeriod] = React.useState("monthly");
- const [isLoadingPlans, setIsLoadingPlans] = React.useState(true);
const [processingPriceId, setProcessingPriceId] = React.useState(null);
const [errorMessage, setErrorMessage] = React.useState(null);
const initialPackHandledRef = React.useRef(false);
+ const normalizedPlans = React.useMemo(
+ () => ({
+ monthly: normalizePlans(subscriptions?.monthly, "monthly"),
+ annual: normalizePlans(subscriptions?.annual, "annual"),
+ }),
+ [subscriptions?.monthly, subscriptions?.annual],
+ );
+ const hasAnyPlan =
+ (normalizedPlans?.monthly?.length || 0) > 0 ||
+ (normalizedPlans?.annual?.length || 0) > 0;
+ const isLoadingPlans = isCatalogLoading && !hasAnyPlan;
+ const combinedErrorMessage = errorMessage || catalogError;
+
React.useEffect(() => {
initialPackHandledRef.current = false;
}, [initialSubscriptionPack]);
- const fetchPlans = React.useCallback(async () => {
- setIsLoadingPlans(true);
- setErrorMessage(null);
- try {
- const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
- "subscription-listSubscriptionPlans",
- );
- const { data } = await callable();
- const normalizedPlans = {
- monthly: normalizePlans(data?.plans?.monthly, "monthly"),
- annual: normalizePlans(data?.plans?.annual, "annual"),
- };
+ React.useEffect(() => {
+ const shouldApplyPack =
+ Boolean(initialSubscriptionPack) && !initialPackHandledRef.current;
- setPlans(normalizedPlans);
+ let matchedPeriod = null;
+ let matchedPriceId = null;
- let matchedPeriod = null;
- let matchedPriceId = null;
- const shouldApplyPack =
- Boolean(initialSubscriptionPack) && !initialPackHandledRef.current;
-
- if (shouldApplyPack) {
- const desired = initialSubscriptionPack.trim().toLowerCase();
- for (const [periodKey, mapping] of Object.entries(
- PACK_PRICE_ID_BY_PERIOD,
- )) {
- const candidateId = mapping[desired];
- if (!candidateId) continue;
- const exists = (normalizedPlans[periodKey] || []).some(
- (plan) => plan?.priceId === candidateId,
- );
- if (exists) {
- matchedPeriod = periodKey;
- matchedPriceId = candidateId;
- break;
- }
+ if (shouldApplyPack && normalizedPlans) {
+ const desired = initialSubscriptionPack.trim().toLowerCase();
+ for (const [periodKey, mapping] of Object.entries(
+ PACK_PRICE_ID_BY_PERIOD,
+ )) {
+ const candidateId = mapping[desired];
+ if (!candidateId) continue;
+ const exists = (normalizedPlans[periodKey] || []).some(
+ (plan) => plan?.priceId === candidateId,
+ );
+ if (exists) {
+ matchedPeriod = periodKey;
+ matchedPriceId = candidateId;
+ break;
}
}
+ }
- setSelectedPriceIds((current) => {
- const next = { ...current };
+ setSelectedPriceIds((current) => {
+ const next = { ...current };
- PLAN_SEGMENTS.forEach(({ key }) => {
- const periodPlans = normalizedPlans[key] || [];
- if (!periodPlans.length) {
- next[key] = null;
- return;
- }
-
- const alreadySelected = periodPlans.some(
- (plan) => plan.priceId === next[key],
- );
-
- if (shouldApplyPack && matchedPeriod === null) {
- const matched = periodPlans.find((plan) => {
- const label = (plan?.product?.name || plan?.nickname || "")
- .toString()
- .toLowerCase();
- return label.includes(initialSubscriptionPack);
- });
-
- if (matched) {
- matchedPeriod = key;
- matchedPriceId = matched.priceId;
- }
- }
-
- next[key] = alreadySelected ? next[key] : periodPlans[0].priceId;
- });
-
- if (matchedPeriod && matchedPriceId) {
- next[matchedPeriod] = matchedPriceId;
+ PLAN_SEGMENTS.forEach(({ key }) => {
+ const periodPlans = normalizedPlans[key] || [];
+ if (!periodPlans.length) {
+ next[key] = null;
+ return;
}
- return next;
+ const alreadySelected = periodPlans.some(
+ (plan) => plan.priceId === next[key],
+ );
+
+ if (shouldApplyPack && matchedPeriod === null) {
+ const matched = periodPlans.find((plan) => {
+ const label = (plan?.product?.name || plan?.nickname || "")
+ .toString()
+ .toLowerCase();
+ return label.includes(initialSubscriptionPack);
+ });
+
+ if (matched) {
+ matchedPeriod = key;
+ matchedPriceId = matched.priceId;
+ }
+ }
+
+ next[key] = alreadySelected ? next[key] : periodPlans[0].priceId;
});
- setProcessingPriceId(null);
-
if (matchedPeriod && matchedPriceId) {
- setBillingPeriod(matchedPeriod);
- initialPackHandledRef.current = true;
- } else if (shouldApplyPack) {
- initialPackHandledRef.current = true;
+ next[matchedPeriod] = matchedPriceId;
}
- } catch (error) {
- console.error("[Payments] fetchPlans error", error);
- setErrorMessage(
- error?.message || "Impossible de récupérer les abonnements Stripe.",
- );
- } finally {
- setIsLoadingPlans(false);
+
+ return next;
+ });
+
+ if (matchedPeriod && matchedPriceId) {
+ setBillingPeriod(matchedPeriod);
+ initialPackHandledRef.current = true;
+ } else if (shouldApplyPack && !isCatalogLoading) {
+ initialPackHandledRef.current = true;
}
- }, [initialSubscriptionPack]);
+ }, [
+ normalizedPlans,
+ initialSubscriptionPack,
+ isCatalogLoading,
+ ]);
- React.useEffect(() => {
- fetchPlans();
- }, [fetchPlans]);
-
- const currentPlans = plans[billingPeriod] || [];
+ const currentPlans = normalizedPlans[billingPeriod] || [];
const selectedPriceId = selectedPriceIds[billingPeriod];
const handleSelect = React.useCallback(
@@ -433,33 +420,7 @@ export default function Payments() {
setProcessingPriceId(selectedPriceId);
setErrorMessage(null);
try {
- const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
- "subscription-createSubscriptionCheckoutSession",
- );
- const { data } = await callable({
- priceId: selectedPriceId,
- 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 createSubscriptionCheckout(selectedPriceId);
} catch (error) {
console.error("[Payments] checkout error", error);
setErrorMessage(
@@ -469,7 +430,7 @@ export default function Payments() {
} finally {
setProcessingPriceId(null);
}
- }, [isWeb, selectedPriceId]);
+ }, [selectedPriceId, createSubscriptionCheckout]);
React.useEffect(() => {
setSelectedPriceIds((current) => {
@@ -523,8 +484,8 @@ export default function Payments() {
- {errorMessage ? (
- {errorMessage}
+ {combinedErrorMessage ? (
+ {combinedErrorMessage}
) : null}
{isLoadingPlans && !currentPlans.length ? (
diff --git a/src/screens/Production/Production.js b/src/screens/Production/Production.js
index acb9328..879708f 100644
--- a/src/screens/Production/Production.js
+++ b/src/screens/Production/Production.js
@@ -1,7 +1,7 @@
-import { Image, StyleSheet, View } from "react-native";
+import { View } from "react-native";
import React from "react";
import Page from "../../layouts/Page";
-import { ai, background } from "../../assets";
+import { background } from "../../assets";
import { gutters } from "../../styles";
import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
@@ -12,7 +12,6 @@ import { Routes } from "../../navigation";
const Production = () => {
return (
-
{
};
export default Production;
-
-const styles = StyleSheet.create({
- img: {
- width: "100%",
- height: "70%",
- position: "absolute",
- bottom: -40,
- right: -30,
- },
-});
diff --git a/src/screens/Profile/ManageSubscription.js b/src/screens/Profile/ManageSubscription.js
index 8ce2545..ee828fe 100644
--- a/src/screens/Profile/ManageSubscription.js
+++ b/src/screens/Profile/ManageSubscription.js
@@ -11,6 +11,7 @@ import { Routes } from "../../navigation/Routes";
import { useUserData } from "../../providers/UserDataProvider";
import { gutters, Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
+import { formatDate, toDate } from "../../utils/dateFormatting";
const FUNCTIONS_REGION = "europe-west1";
@@ -73,49 +74,6 @@ const getStatusColors = (status) => {
}
};
-const toDate = (value) => {
- if (!value) {
- return null;
- }
- if (typeof value.toDate === "function") {
- try {
- return value.toDate();
- } catch (_error) {
- return null;
- }
- }
- if (value instanceof Date) {
- return value;
- }
- if (typeof value === "number" && Number.isFinite(value)) {
- if (value > 1e12) {
- return new Date(value);
- }
- return new Date(value * 1000);
- }
- if (typeof value === "object" && Number.isFinite(value.seconds)) {
- return new Date(value.seconds * 1000);
- }
- return null;
-};
-
-const formatDate = (date) => {
- if (!date) {
- return "À déterminer";
- }
- try {
- return new Intl.DateTimeFormat("fr-FR", {
- day: "2-digit",
- month: "long",
- year: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- }).format(date);
- } catch (_error) {
- return date.toString();
- }
-};
-
const capitalize = (value) => {
if (typeof value !== "string" || !value) {
return null;
diff --git a/src/screens/Publishing/PublishYoutube.js b/src/screens/Publishing/PublishYoutube.js
index d17617c..b2ab1b7 100644
--- a/src/screens/Publishing/PublishYoutube.js
+++ b/src/screens/Publishing/PublishYoutube.js
@@ -1,9 +1,8 @@
import { useRoute } from "@react-navigation/native";
-import { Image } from "expo-image";
import React, { useCallback, useMemo, useState } from "react";
import { Linking, Text, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
-import { ai, background } from "../../assets";
+import { background } from "../../assets";
import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
@@ -58,10 +57,10 @@ const PublishYoutube = () => {
: "Publier sur YouTube";
const statusMessage = useMemo(() => {
if (!playbackReady) {
- return "Termine la création de ton playback avec John pour débloquer la publication YouTube.";
+ return "Termine la création de ton playback avec Theo pour débloquer la publication YouTube.";
}
if (isPublishing) {
- return "Bena s'occupe de mettre ta vidéo en ligne... Patiente un instant.";
+ return "La publication de ta vidéo est en cours... Patiente un instant.";
}
if (youtubeError || youtubeStatus === "FAILED") {
return "La dernière tentative de publication a échoué. Tu peux réessayer ci-dessous.";
@@ -69,7 +68,7 @@ const PublishYoutube = () => {
if (hasYoutubePublication) {
return "Ta vidéo est publiée sur la chaîne YouTube MusicLand. Tu peux la partager dès maintenant.";
}
- return "Bena t’accompagne pour partager ton playback sur la chaîne YouTube MusicLand.";
+ return "Partage ton playback sur la chaîne YouTube MusicLand.";
}, [
hasYoutubePublication,
isPublishing,
@@ -175,11 +174,6 @@ const PublishYoutube = () => {
}}
>
-
{
}, []);
return (
-
{
};
export default Compose;
-
-const styles = StyleSheet.create({
- img: {
- width: "100%",
- height: "70%",
- position: "absolute",
- bottom: -40,
- right: -30,
- },
-});
diff --git a/src/screens/Studio/CreatingSong.js b/src/screens/Studio/CreatingSong.js
index 603e7a0..8175177 100644
--- a/src/screens/Studio/CreatingSong.js
+++ b/src/screens/Studio/CreatingSong.js
@@ -3,7 +3,7 @@ import moment from "moment";
import React, { useEffect, useState } from "react";
import { Image, Platform, Pressable, Text, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
-import { ai, icons } from "../../assets";
+import { icons } from "../../assets";
import AppAlert from "../../components/Alert";
import GradientButton from "../../components/GradientButton";
import ProgressBar from "../../components/ProgressBar";
@@ -194,22 +194,6 @@ const CreatingSong = ({ active, config }) => {
height: "50%",
}}
>
-
-
-
{
{
try {
const player = idx === 0 ? player0 : player1;
if (player && wasPlayingBeforeSeek.current[idx]) {
- await player.play?.();
+ if (player.resume) {
+ await player.resume?.();
+ } else {
+ await player.play?.();
+ }
setIsPlaying((p) => ({ ...p, [idx]: true }));
}
wasPlayingBeforeSeek.current[idx] = false;
diff --git a/src/screens/Writing/Lyrics.js b/src/screens/Writing/Lyrics.js
index ccbae26..708f7c1 100644
--- a/src/screens/Writing/Lyrics.js
+++ b/src/screens/Writing/Lyrics.js
@@ -286,7 +286,7 @@ const Lyrics = ({ navigation }) => {
await setIsLoading(false);
alert(
"Céline",
- "Bravo ! Vous venez de terminer de créer les paroles de votre musique ! Maintenant vous pouvez passer à la prochaine étape : le Studio avec Malik. Dans cette étape vous allez pouvoir donner vie à votre chanson !",
+ "Bravo ! Vous venez de terminer de créer les paroles de votre musique ! Maintenant vous pouvez passer à la prochaine étape : le Studio pour donner vie à votre chanson !",
[
{
text: "Retour à l'accueil",
@@ -300,7 +300,7 @@ const Lyrics = ({ navigation }) => {
}),
},
{
- text: "Continuer avec Malik",
+ text: "Continuer vers le Studio",
onPress: () => {
const targetRoute = beatmakerStage?.route || Routes.Compose;
navigate(targetRoute, beatmakerStage?.params);
diff --git a/src/screens/Writing/SongTo.js b/src/screens/Writing/SongTo.js
index 18c2580..351e573 100644
--- a/src/screens/Writing/SongTo.js
+++ b/src/screens/Writing/SongTo.js
@@ -8,7 +8,7 @@ const SongTo = ({ audience, setAudience }) => {
{
subTitle="Dis-nous en un peu plus pour qu’on puisse mieux t’aider."
/>
{
backgroundColor={"#2A2E33"}
headerType="NONE"
>
-
{
const playbackStage = getStageAction("director", projectForStage);
await setLoading(false);
alert(
- "Malik",
- "Super ! Ta pochette est validée. Tu peux retourner à l'accueil ou continuer avec John pour produire ton playback.",
+ "Playback prêt",
+ "Super ! Ta pochette est validée. Tu peux retourner à l'accueil ou continuer avec Theo pour produire ton playback.",
[
{
text: "Retour à l'accueil",
@@ -261,7 +261,7 @@ const PouchReady = () => {
onPress: () => navigate(Routes.Home),
},
{
- text: "Continuer avec John",
+ text: "Continuer avec Theo",
onPress: () => {
const targetRoute = playbackStage?.route || Routes.Playback;
const params = playbackStage?.params || {
diff --git a/src/screens/cover/ValidateCover.js b/src/screens/cover/ValidateCover.js
index 3b0496f..ef93323 100644
--- a/src/screens/cover/ValidateCover.js
+++ b/src/screens/cover/ValidateCover.js
@@ -117,8 +117,8 @@ const ValidateCover = () => {
const playbackStage = getStageAction("director", projectForStage);
await setIsLoading(false);
alert(
- "Malik",
- "Super ! Ta pochette est validée. Tu peux retourner à l'accueil ou continuer avec John pour produire ton playback.",
+ "Playback prêt",
+ "Super ! Ta pochette est validée. Tu peux retourner à l'accueil ou continuer avec Theo pour produire ton playback.",
[
{
text: "Retour à l'accueil",
@@ -126,7 +126,7 @@ const ValidateCover = () => {
onPress: () => navigate(Routes.Home),
},
{
- text: "Continuer avec John",
+ text: "Continuer avec Theo",
onPress: () => {
const targetRoute = playbackStage?.route || Routes.Playback;
const params =
diff --git a/src/utils/dateFormatting.js b/src/utils/dateFormatting.js
new file mode 100644
index 0000000..01bd3c2
--- /dev/null
+++ b/src/utils/dateFormatting.js
@@ -0,0 +1,44 @@
+const toDate = (value) => {
+ if (!value) {
+ return null;
+ }
+ if (typeof value.toDate === "function") {
+ try {
+ return value.toDate();
+ } catch (_error) {
+ return null;
+ }
+ }
+ if (value instanceof Date) {
+ return value;
+ }
+ if (typeof value === "number" && Number.isFinite(value)) {
+ if (value > 1e12) {
+ return new Date(value);
+ }
+ return new Date(value * 1000);
+ }
+ if (typeof value === "object" && Number.isFinite(value.seconds)) {
+ return new Date(value.seconds * 1000);
+ }
+ return null;
+};
+
+const formatDate = (date) => {
+ if (!(date instanceof Date)) {
+ return "À déterminer";
+ }
+ try {
+ return new Intl.DateTimeFormat("fr-FR", {
+ day: "2-digit",
+ month: "long",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(date);
+ } catch (_error) {
+ return date.toString();
+ }
+};
+
+export { toDate, formatDate };