more tickets
This commit is contained in:
@@ -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
@@ -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 |
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
const {
|
||||
coinPacks,
|
||||
isCatalogLoading,
|
||||
catalogError,
|
||||
refreshCatalog,
|
||||
createCoinPackCheckout,
|
||||
} = useStripe();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!coinPacks.length) {
|
||||
setSelectedPackId(null);
|
||||
return;
|
||||
}
|
||||
setSelectedPackId((current) => {
|
||||
if (
|
||||
current &&
|
||||
packs.some((pack) => pack?.productId && pack.productId === current)
|
||||
coinPacks.some((pack) => pack?.productId && pack.productId === current)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return packs[0]?.productId || null;
|
||||
return coinPacks[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);
|
||||
}
|
||||
}, []);
|
||||
}, [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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -475,6 +475,7 @@ export const INSTRUMENTS = [
|
||||
"Synthétiseur",
|
||||
"Guitare acoustique",
|
||||
"Guitare électrique",
|
||||
"Batterie",
|
||||
"Banjo",
|
||||
"Violon",
|
||||
"Saxophone",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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, {}],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</TooltipProvider>
|
||||
</LoadingProvider>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 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 = (
|
||||
<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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
const basePressableStyle =
|
||||
variant === "web"
|
||||
? { width: "48%" }
|
||||
: {
|
||||
width: "100%",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const imageContainerBaseStyle = {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
zIndex: 10,
|
||||
},
|
||||
isImageOnLeft
|
||||
};
|
||||
|
||||
const imageContainerVariantStyle = isMobileVariant
|
||||
? {
|
||||
width: "100%",
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
marginBottom: 0,
|
||||
}
|
||||
: {
|
||||
flex: 1,
|
||||
...(isImageOnLeft
|
||||
? {
|
||||
borderTopLeftRadius: 20,
|
||||
borderBottomLeftRadius: 20,
|
||||
@@ -40,20 +60,24 @@ 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
|
||||
isMobileVariant
|
||||
? { alignSelf: "center" }
|
||||
: isImageOnLeft
|
||||
? { alignSelf: "flex-end" }
|
||||
: { alignSelf: "flex-start" },
|
||||
]}
|
||||
@@ -70,7 +94,9 @@ const StageCard = ({
|
||||
alignItems: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
isTextRight
|
||||
isMobileVariant
|
||||
? { justifyContent: "center" }
|
||||
: isTextRight
|
||||
? { justifyContent: "flex-end" }
|
||||
: { justifyContent: "flex-start" },
|
||||
]}
|
||||
@@ -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={[
|
||||
{
|
||||
const textBlockBaseStyle = {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
maxWidth: 380,
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 20,
|
||||
justifyContent: "center",
|
||||
alignSelf: "center",
|
||||
},
|
||||
isImageOnLeft
|
||||
};
|
||||
|
||||
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}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
+36
-75
@@ -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,42 +291,46 @@ 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"),
|
||||
};
|
||||
|
||||
setPlans(normalizedPlans);
|
||||
|
||||
let matchedPeriod = null;
|
||||
let matchedPriceId = null;
|
||||
React.useEffect(() => {
|
||||
const shouldApplyPack =
|
||||
Boolean(initialSubscriptionPack) && !initialPackHandledRef.current;
|
||||
|
||||
if (shouldApplyPack) {
|
||||
let matchedPeriod = null;
|
||||
let matchedPriceId = null;
|
||||
|
||||
if (shouldApplyPack && normalizedPlans) {
|
||||
const desired = initialSubscriptionPack.trim().toLowerCase();
|
||||
for (const [periodKey, mapping] of Object.entries(
|
||||
PACK_PRICE_ID_BY_PERIOD,
|
||||
@@ -389,29 +386,19 @@ export default function Payments() {
|
||||
return next;
|
||||
});
|
||||
|
||||
setProcessingPriceId(null);
|
||||
|
||||
if (matchedPeriod && matchedPriceId) {
|
||||
setBillingPeriod(matchedPeriod);
|
||||
initialPackHandledRef.current = true;
|
||||
} else if (shouldApplyPack) {
|
||||
} else if (shouldApplyPack && !isCatalogLoading) {
|
||||
initialPackHandledRef.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Payments] fetchPlans error", error);
|
||||
setErrorMessage(
|
||||
error?.message || "Impossible de récupérer les abonnements Stripe.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingPlans(false);
|
||||
}
|
||||
}, [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 ? (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
}}
|
||||
>
|
||||
<View style={{ alignItems: "center", gap: 18 }}>
|
||||
<Image
|
||||
source={ai.bena}
|
||||
style={{ width: 220, height: 320 }}
|
||||
contentFit="contain"
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 22,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -338,7 +338,7 @@ const SongReady = () => {
|
||||
<View style={{ flex: 1, marginTop: 16 }}>
|
||||
<CreateLyricsHeader
|
||||
title="Ta chanson est prête !"
|
||||
subTitle="Qu’en 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]) {
|
||||
if (player.resume) {
|
||||
await player.resume?.();
|
||||
} else {
|
||||
await player.play?.();
|
||||
}
|
||||
setIsPlaying((p) => ({ ...p, [idx]: true }));
|
||||
}
|
||||
wasPlayingBeforeSeek.current[idx] = false;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -8,7 +8,7 @@ const SongTo = ({ audience, setAudience }) => {
|
||||
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
|
||||
<CreateLyricsHeader title="À qui s’adresse ta chanson ?" />
|
||||
<CustomInput
|
||||
placeholder="Ecrire mon contexte"
|
||||
placeholder="Écris à qui s'adresse ta chanson"
|
||||
height={283}
|
||||
value={audience}
|
||||
setValue={setAudience}
|
||||
|
||||
@@ -11,7 +11,7 @@ const SpecificityContext = ({ context, setContext }) => {
|
||||
subTitle="Dis-nous en un peu plus pour qu’on puisse mieux t’aider."
|
||||
/>
|
||||
<CustomInput
|
||||
placeholder="Ecrire mon contexte"
|
||||
placeholder="Écris à qui s'adresse ta chanson"
|
||||
height={283}
|
||||
value={context}
|
||||
setValue={setContext}
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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 || {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user