more tickets

This commit is contained in:
Thomas Demirdjian
2025-11-13 18:31:38 +01:00
parent dd4cf9b4d4
commit 5457030d4a
32 changed files with 913 additions and 558 deletions
+20 -3
View File
@@ -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
? ` <ARTISTE>${artistName}</ARTISTE>\n`
: "";
const prompt = `<BRIEF>
<OBJECTIF>Créer une pochette d'album en adéquation avec les paroles de la musique</OBJECTIF>
<TITRE>${title || "Sans titre"}</TITRE>
<STYLE_MUSICAL>${tagsLine}</STYLE_MUSICAL>
<TITRE>${titleForPrompt}</TITRE>
${artistLine} <STYLE_MUSICAL>${tagsLine}</STYLE_MUSICAL>
<EXTRAITS_PAROLES>${lyricsLine}</EXTRAITS_PAROLES>
</BRIEF>
@@ -67,7 +84,7 @@ exports.generatePicturePrompt = (project = {}) => {
<FORMAT>Image carrée 1024x1024 pixels, résolution haute.</FORMAT>
<STYLE>${styleLine}</STYLE>
<COMPOSITION>La composition doit être dynamique et remplir toute la surface, sans laisser de bordures ni de zones vides.</COMPOSITION>
<TYPOGRAPHIE>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.</TYPOGRAPHIE>
<TYPOGRAPHIE>${typographyLine}</TYPOGRAPHIE>
<ORTHOGRAPHE>Aucune faute d'orthographe n'est tolérée. Respecte la casse et l'orthographe exactes du titre fourni.</ORTHOGRAPHE>
</CONTEXTES_VISUELS>
+64 -1
View File
@@ -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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+1
View File
@@ -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,
@@ -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,
},
];
@@ -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,
},
];
+56 -76
View File
@@ -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 (
<View style={styles.loaderContainer}>
<ActivityIndicator color={Palette.white} />
@@ -250,8 +230,8 @@ const CoinPackModal = ({ visible, onClose }) => {
</Text>
</View>
{errorMessage ? (
<Text style={styles.errorText}>{errorMessage}</Text>
{combinedErrorMessage ? (
<Text style={styles.errorText}>{combinedErrorMessage}</Text>
) : null}
<View style={styles.content}>{renderContent()}</View>
@@ -263,7 +243,7 @@ const CoinPackModal = ({ visible, onClose }) => {
disabled={
!selectedPackId ||
isProcessing ||
isLoading ||
isLoadingCoinPacks ||
!coinPacks.length
}
/>
+1
View File
@@ -475,6 +475,7 @@ export const INSTRUMENTS = [
"Synthétiseur",
"Guitare acoustique",
"Guitare électrique",
"Batterie",
"Banjo",
"Violon",
"Saxophone",
+4 -4
View File
@@ -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");
+2 -2
View File
@@ -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, {}],
-73
View File
@@ -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 (
<StripeEmbeddedContext.Provider value={{ setClientSecret }}>
{children}
<Overlay
isVisible={clientSecret !== null}
setIsVisible={() => setClientSecret(null)}
>
<View
style={{
position: "absolute",
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
alignSelf: "center",
width: isMobile ? "90%" : "80%",
height: "70vh",
borderRadius: mainBorderRadius,
overflow: "scroll",
}}
>
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ clientSecret }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</View>
<Button
type="secondary"
isAbsoluteBottom
text="Fermer"
onPress={() => setClientSecret(null)}
/>
</View>
</Overlay>
</StripeEmbeddedContext.Provider>
);
};
+342
View File
@@ -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 (
<StripeContext.Provider value={providerValue}>
{children}
<Overlay
isVisible={clientSecret !== null}
setIsVisible={closeEmbeddedCheckout}
>
<View
style={[StyleSheet.absoluteFillObject, styles.overlayContent]}
>
<View
style={[
styles.embeddedWrapper,
{
width: isMobile ? "90%" : "80%",
},
]}
>
{clientSecret ? (
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ clientSecret }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
) : null}
</View>
<Button
type="secondary"
isAbsoluteBottom
text="Fermer"
onPress={closeEmbeddedCheckout}
/>
</View>
</Overlay>
</StripeContext.Provider>
);
};
const styles = StyleSheet.create({
overlayContent: {
position: "absolute",
justifyContent: "center",
alignItems: "center",
},
embeddedWrapper: {
alignSelf: "center",
height: "70vh",
borderRadius: mainBorderRadius,
overflow: "scroll",
},
});
export default StripeProvider;
+1 -4
View File
@@ -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 (
<LoadingProvider>
<TooltipProvider>
<StripeEmbeddedProvider>
<SharedProviders>{children}</SharedProviders>
</StripeEmbeddedProvider>
<SharedProviders>{children}</SharedProviders>
</TooltipProvider>
</LoadingProvider>
);
+2
View File
@@ -99,8 +99,10 @@ export default ({ navigation }) => {
<Input
label="Adresse email"
placeholder="Votre adresse email"
type="email"
value={email}
setValue={setEmail}
isBlur
containerStyle={{ marginBottom: 20 }}
textInputProps={{
keyboardType: "email-address",
+117 -63
View File
@@ -5,7 +5,7 @@ import React, {
useRef,
useState,
} from "react";
import { StyleSheet, Text, View } from "react-native";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { Image as ExpoImage } from "expo-image";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { background, cardsImg, icons } from "../../assets";
@@ -14,7 +14,6 @@ import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
import GradientButton from "../../components/GradientButton";
import MoreMenu from "../../components/MoreMenu";
import ProjectDropDown from "../../components/ProjectDropDown/ProjectDropDown";
import ShareBtn from "../../components/ShareBtn/ShareBtn";
import { projectsRef, usersRef } from "../../config/firebase";
import { isWeb } from "../../hooks/useLayoutType.js";
import Page from "../../layouts/Page";
@@ -22,7 +21,7 @@ import LandingPage from "../LandingPage";
import { navigate } from "../../navigation/NavigationService";
import { Routes } from "../../navigation/Routes";
import { useUser } from "../../providers/UserDataProvider";
import { Palette } from "../../styles";
import { Palette, gutters } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import {
getCreationStageStates,
@@ -43,6 +42,8 @@ const isProjectEmpty = (project) => {
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 tinté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 = (
<View style={stageCardContainerStyle}>
{stageCards.map((card) => (
<StageCard
key={card.key}
step={card.step}
description={card.description}
image={card.image}
imagePosition={card.imagePosition}
textAlign={card.textAlign}
isLocked={card.isLocked}
lockSide={card.lockSide}
onPress={() => handleStagePress(card.key, card.isLocked)}
containerStyle={stageCardItemStyle}
variant={isWeb ? "web" : "mobile"}
/>
))}
</View>
);
const journeyContent = (
<>
<View style={styles.topBar}>
<ProjectDropDown
style={styles.projectDropDown}
projects={projects}
selectedProject={currentProject}
allowEmptySelection
onSelectProject={handleSelectProject}
onModifyProject={handleModifyProject}
onCreateProject={handleStartNew}
formatDate={formatDate}
/>
</View>
<MoreMenu
visible={menuVisible}
top={menuAnchor?.top ?? 0}
position={menuAnchor}
onClose={handleCloseMenu}
inPlaylist={false}
projectId={menuProject?.id || null}
extraItems={moreMenuItems}
/>
<Text style={styles.subtitle}>5 espaces à découvrir</Text>
{stageCardsList}
<ClubCard
image={CLUB_CARD_IMAGE}
onPress={handleClubPress}
hasActiveSubscription={hasActiveSubscription}
/>
</>
);
return adventureStarted ? (
<View style={styles.root}>
<Page
@@ -466,54 +529,17 @@ const Home = ({ navigation, route }) => {
contentFit="cover"
style={styles.centerImage}
/>
<View style={styles.topBar}>
<ProjectDropDown
style={styles.projectDropDown}
projects={projects}
selectedProject={currentProject}
allowEmptySelection
onSelectProject={handleSelectProject}
onModifyProject={handleModifyProject}
onCreateProject={handleStartNew}
formatDate={formatDate}
/>
{!isWeb && <ShareBtn />}
</View>
<MoreMenu
visible={menuVisible}
top={menuAnchor?.top ?? 0}
position={menuAnchor}
onClose={handleCloseMenu}
inPlaylist={false}
projectId={menuProject?.id || null}
extraItems={moreMenuItems}
/>
<Text style={styles.subtitle}>5 espaces à découvrir</Text>
<View style={styles.cardsGrid}>
{stageCards.map((card) => (
<StageCard
key={card.key}
step={card.step}
description={card.description}
image={card.image}
imagePosition={card.imagePosition}
textAlign={card.textAlign}
isLocked={card.isLocked}
lockSide={card.lockSide}
onPress={() => handleStagePress(card.key, card.isLocked)}
/>
))}
</View>
<ClubCard
image={CLUB_CARD_IMAGE}
onPress={handleClubPress}
hasActiveSubscription={hasActiveSubscription}
/>
{isWeb ? (
<View style={styles.contentOverlay}>{journeyContent}</View>
) : (
<ScrollView
style={[styles.mobileScrollView, styles.contentOverlay]}
contentContainerStyle={styles.mobileScrollContent}
showsVerticalScrollIndicator={false}
>
{journeyContent}
</ScrollView>
)}
</View>
</Page>
</View>
@@ -534,7 +560,7 @@ const Home = ({ navigation, route }) => {
}}
>
<GradientButton
url={videos.landing}
url={videoUrl}
title="Commencer l'aventure MusicLand"
onPress={handleStartVisit}
/>
@@ -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,
},
});
+107 -49
View File
@@ -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 = (
<View
style={[
{
flex: 1,
alignItems: "center",
justifyContent: "center",
const basePressableStyle =
variant === "web"
? { width: "48%" }
: {
width: "100%",
borderRadius: 24,
overflow: "hidden",
zIndex: 10,
},
isImageOnLeft
};
const imageContainerBaseStyle = {
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
zIndex: 10,
};
const imageContainerVariantStyle = isMobileVariant
? {
width: "100%",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
marginBottom: 0,
}
: {
flex: 1,
...(isImageOnLeft
? {
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
@@ -40,22 +60,26 @@ const StageCard = ({
borderBottomRightRadius: 20,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
]}
>
}),
};
const imageBlock = (
<View style={[imageContainerBaseStyle, imageContainerVariantStyle]}>
<ExpoImage
source={image}
contentFit="contain"
style={[
{
width: "100%",
height: "100%",
minHeight: 210,
height: isMobileVariant ? 190 : "100%",
minHeight: isMobileVariant ? 190 : 210,
borderRadius: 0,
},
isImageOnLeft
? { alignSelf: "flex-end" }
: { alignSelf: "flex-start" },
isMobileVariant
? { alignSelf: "center" }
: isImageOnLeft
? { alignSelf: "flex-end" }
: { alignSelf: "flex-start" },
]}
/>
</View>
@@ -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" },
]}
>
<Text
@@ -83,7 +109,11 @@ const StageCard = ({
color: Palette.white,
flexShrink: 1,
},
isTextRight ? { textAlign: "right" } : { textAlign: "left" },
isMobileVariant
? { textAlign: "center" }
: isTextRight
? { textAlign: "right" }
: { textAlign: "left" },
]}
numberOfLines={1}
>
@@ -92,21 +122,29 @@ const StageCard = ({
</View>
);
const textBlock = (
<BlurView
tint="dark"
intensity={30}
style={[
{
flexGrow: 1,
flexShrink: 1,
maxWidth: 380,
paddingVertical: 20,
paddingHorizontal: 20,
justifyContent: "center",
alignSelf: "center",
},
isImageOnLeft
const textBlockBaseStyle = {
flexGrow: 1,
flexShrink: 1,
paddingVertical: 20,
paddingHorizontal: 20,
justifyContent: "center",
alignSelf: "center",
};
const textBlockVariantStyle = isMobileVariant
? {
width: "100%",
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
alignItems: "center",
paddingVertical: 20,
paddingHorizontal: 20,
marginTop: 0,
gap: 6,
}
: {
maxWidth: 380,
...(isImageOnLeft
? {
borderTopRightRadius: 20,
borderBottomRightRadius: 20,
@@ -114,9 +152,17 @@ const StageCard = ({
: {
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
},
isTextRight ? { alignItems: "flex-end" } : { alignItems: "flex-start" },
]}
}),
...(isTextRight
? { alignItems: "flex-end" }
: { alignItems: "flex-start" }),
};
const textBlock = (
<BlurView
tint="dark"
intensity={30}
style={[textBlockBaseStyle, textBlockVariantStyle]}
>
{isLocked ? (
<View
@@ -132,7 +178,11 @@ const StageCard = ({
justifyContent: "center",
zIndex: 2,
},
isTextRight ? { left: 10 } : { right: 10 },
isMobileVariant
? { right: 10 }
: isTextRight
? { left: 10 }
: { right: 10 },
]}
>
<FontAwesome name="lock" size={18} color={Palette.primary} />
@@ -141,7 +191,11 @@ const StageCard = ({
{header}
<Text
style={[
isTextRight ? { textAlign: "right" } : { textAlign: "left" },
isMobileVariant
? { textAlign: "center" }
: isTextRight
? { textAlign: "right" }
: { textAlign: "left" },
{
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13,
@@ -155,7 +209,12 @@ const StageCard = ({
</BlurView>
);
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 },
]}
>
<View
style={{
flexDirection: "row",
flexDirection: isMobileVariant ? "column" : "row",
}}
>
{content}
+23 -21
View File
@@ -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={{
+5 -5
View File
@@ -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",
},
+86 -125
View File
@@ -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() {
</Text>
</View>
{errorMessage ? (
<Text style={styles.errorText}>{errorMessage}</Text>
{combinedErrorMessage ? (
<Text style={styles.errorText}>{combinedErrorMessage}</Text>
) : null}
{isLoadingPlans && !currentPlans.length ? (
+2 -13
View File
@@ -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 (
<Page headerType="NONE" backgroundImg={background.productionBG}>
<Image source={ai.bena} style={styles.img} resizeMode="contain" />
<MusicLandHeader onPressBack={goBack} progress={25} />
<View
style={{
@@ -43,13 +42,3 @@ const Production = () => {
};
export default Production;
const styles = StyleSheet.create({
img: {
width: "100%",
height: "70%",
position: "absolute",
bottom: -40,
right: -30,
},
});
+1 -43
View File
@@ -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;
+4 -10
View File
@@ -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 taccompagne 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 = () => {
}}
>
<View style={{ alignItems: "center", gap: 18 }}>
<Image
source={ai.bena}
style={{ width: 220, height: 320 }}
contentFit="contain"
/>
<Text
style={{
fontSize: 22,
+2 -13
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { Image, StyleSheet, View } from "react-native";
import { ai, background } from "../../assets";
import { View } from "react-native";
import { background } from "../../assets";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
@@ -21,7 +21,6 @@ const Compose = () => {
}, []);
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<Image source={ai.malik} style={styles.img} resizeMode="contain" />
<MusicLandHeader
onPressBack={goBack}
progress={9}
@@ -53,13 +52,3 @@ const Compose = () => {
};
export default Compose;
const styles = StyleSheet.create({
img: {
width: "100%",
height: "70%",
position: "absolute",
bottom: -40,
right: -30,
},
});
+1 -17
View File
@@ -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%",
}}
>
<View
style={{
zIndex: 1,
position: "absolute",
top: -150,
width: "50%",
height: "80%",
alignSelf: "center",
}}
>
<Image
source={ai.malik}
style={{ width: "100%", height: "100%", right: -10 }}
resizeMode="contain"
/>
</View>
<View
style={{
flex: 1,
+6 -2
View File
@@ -338,7 +338,7 @@ const SongReady = () => {
<View style={{ flex: 1, marginTop: 16 }}>
<CreateLyricsHeader
title="Ta chanson est prête !"
subTitle="Quen penses-tu ?"
subTitle="Choisis ton morceau"
containerStyle={{
marginBottom: responsiveHeight(2),
}}
@@ -432,7 +432,11 @@ const SongReady = () => {
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;
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -8,7 +8,7 @@ const SongTo = ({ audience, setAudience }) => {
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
<CreateLyricsHeader title="À qui sadresse ta chanson ?" />
<CustomInput
placeholder="Ecrire mon contexte"
placeholder="Écris à qui s'adresse ta chanson"
height={283}
value={audience}
setValue={setAudience}
+1 -1
View File
@@ -11,7 +11,7 @@ const SpecificityContext = ({ context, setContext }) => {
subTitle="Dis-nous en un peu plus pour quon puisse mieux taider."
/>
<CustomInput
placeholder="Ecrire mon contexte"
placeholder="Écris à qui s'adresse ta chanson"
height={283}
value={context}
setValue={setContext}
+1 -13
View File
@@ -1,8 +1,7 @@
import { BlurView } from "expo-blur";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Image, Modal, Pressable, StyleSheet, Text, View } from "react-native";
import { Modal, Pressable, StyleSheet, Text, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit";
import { ai } from "../../assets";
import BorderGradientButton from "../../components/BorderGradientButton";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
import GradientButton from "../../components/GradientButton";
@@ -277,17 +276,6 @@ export const ChooseCoverType = () => {
backgroundColor={"#2A2E33"}
headerType="NONE"
>
<Image
source={ai.bena}
style={{
width: "100%",
height: "70%",
position: "absolute",
bottom: -40,
right: -30,
}}
resizeMode="contain"
/>
<MusicLandHeader onPressBack={goBack} progress={9} />
<View
style={{ flex: 1, position: "relative", justifyContent: "flex-end" }}
+3 -3
View File
@@ -252,8 +252,8 @@ const PouchReady = () => {
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 || {
+3 -3
View File
@@ -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 =
+44
View File
@@ -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 };