more tickets
This commit is contained in:
@@ -4,8 +4,19 @@ exports.generatePicturePrompt = (project = {}) => {
|
|||||||
lyrics: lyricsRaw,
|
lyrics: lyricsRaw,
|
||||||
musicConfig = {},
|
musicConfig = {},
|
||||||
coverStyle: coverStyleRaw = "",
|
coverStyle: coverStyleRaw = "",
|
||||||
|
artistName: artistNameRaw = "",
|
||||||
} = project || {};
|
} = 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 {
|
const {
|
||||||
genres = [],
|
genres = [],
|
||||||
tempo = "",
|
tempo = "",
|
||||||
@@ -55,11 +66,17 @@ exports.generatePicturePrompt = (project = {}) => {
|
|||||||
const styleLine = coverStyle
|
const styleLine = coverStyle
|
||||||
? `${coverStyle}. ${paletteHint}`
|
? `${coverStyle}. ${paletteHint}`
|
||||||
: `${paletteHint}. Style libre, artistique et lumineux.`;
|
: `${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>
|
const prompt = `<BRIEF>
|
||||||
<OBJECTIF>Créer une pochette d'album en adéquation avec les paroles de la musique</OBJECTIF>
|
<OBJECTIF>Créer une pochette d'album en adéquation avec les paroles de la musique</OBJECTIF>
|
||||||
<TITRE>${title || "Sans titre"}</TITRE>
|
<TITRE>${titleForPrompt}</TITRE>
|
||||||
<STYLE_MUSICAL>${tagsLine}</STYLE_MUSICAL>
|
${artistLine} <STYLE_MUSICAL>${tagsLine}</STYLE_MUSICAL>
|
||||||
<EXTRAITS_PAROLES>${lyricsLine}</EXTRAITS_PAROLES>
|
<EXTRAITS_PAROLES>${lyricsLine}</EXTRAITS_PAROLES>
|
||||||
</BRIEF>
|
</BRIEF>
|
||||||
|
|
||||||
@@ -67,7 +84,7 @@ exports.generatePicturePrompt = (project = {}) => {
|
|||||||
<FORMAT>Image carrée 1024x1024 pixels, résolution haute.</FORMAT>
|
<FORMAT>Image carrée 1024x1024 pixels, résolution haute.</FORMAT>
|
||||||
<STYLE>${styleLine}</STYLE>
|
<STYLE>${styleLine}</STYLE>
|
||||||
<COMPOSITION>La composition doit être dynamique et remplir toute la surface, sans laisser de bordures ni de zones vides.</COMPOSITION>
|
<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>
|
<ORTHOGRAPHE>Aucune faute d'orthographe n'est tolérée. Respecte la casse et l'orthographe exactes du titre fourni.</ORTHOGRAPHE>
|
||||||
</CONTEXTES_VISUELS>
|
</CONTEXTES_VISUELS>
|
||||||
|
|
||||||
|
|||||||
+64
-1
@@ -14,6 +14,65 @@ const { sendNotification } = require("./notifications");
|
|||||||
const bucket = admin.storage().bucket();
|
const bucket = admin.storage().bucket();
|
||||||
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png");
|
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) {
|
async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
||||||
logger.info("🖼️ [Cover] Adding logo to generated background");
|
logger.info("🖼️ [Cover] Adding logo to generated background");
|
||||||
const [{ data: backgroundBuffer }, logoBuffer] = await Promise.all([
|
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
|
// Shared core for generating and saving the cover, and updating the project
|
||||||
async function performCoverGeneration(project) {
|
async function performCoverGeneration(project) {
|
||||||
const t0 = Date.now();
|
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
|
// Utiliser generateImageV2 qui renvoie directement l'URL publique
|
||||||
logger.info("🎨 [Cover] Calling model V2", {
|
logger.info("🎨 [Cover] Calling model V2", {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
@@ -202,6 +202,7 @@ export const background = {
|
|||||||
libraryBG,
|
libraryBG,
|
||||||
libraryBgWeb: require("./UI/libraryBgWeb.png"),
|
libraryBgWeb: require("./UI/libraryBgWeb.png"),
|
||||||
libraryBG2,
|
libraryBG2,
|
||||||
|
libraryBG2Web: require("./UI/libraryBG2Web.png"),
|
||||||
profileBG,
|
profileBG,
|
||||||
profileBgWeb: require("./UI/profileBgWeb.png"),
|
profileBgWeb: require("./UI/profileBgWeb.png"),
|
||||||
hitParadeBG,
|
hitParadeBG,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { responsiveHeight } from "../../actions/responsiveSizes";
|
import { responsiveHeight } from "../../actions/responsiveSizes";
|
||||||
import { ai } from "../../assets";
|
import { ai, cardsImg } from "../../assets";
|
||||||
import { gutters } from "../../styles";
|
import { gutters } from "../../styles";
|
||||||
import { getCreationStageStates } from "../../utils/projectStages";
|
import { getCreationStageStates } from "../../utils/projectStages";
|
||||||
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
|
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
|
||||||
@@ -36,15 +36,15 @@ const STAGE_CARD_CONTENT = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "director",
|
key: "director",
|
||||||
title: "John",
|
title: "Theo",
|
||||||
description: "Come back when your audio is ready!",
|
description: "Theo t'accompagne pour créer ton playback.",
|
||||||
image: ai.rightIcon,
|
image: ai.rightIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "publisher",
|
key: "publisher",
|
||||||
title: "Bena",
|
title: "Publication",
|
||||||
description: "Ta vidéo est prête ? Direction YouTube !",
|
description: "Ta vidéo est prête ? Direction YouTube !",
|
||||||
image: ai.bena,
|
image: cardsImg.production,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { ai } from "../../assets";
|
import { ai, cardsImg } from "../../assets";
|
||||||
import { getCreationStageStates } from "../../utils/projectStages";
|
import { getCreationStageStates } from "../../utils/projectStages";
|
||||||
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
|
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
|
||||||
import PersonaCard from "../cards/PersonaCard/PersonaCard";
|
import PersonaCard from "../cards/PersonaCard/PersonaCard";
|
||||||
@@ -29,21 +29,21 @@ const STAGE_CARD_CONTENT = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "beatmaker",
|
key: "beatmaker",
|
||||||
title: "Malik",
|
title: "Theo",
|
||||||
description: "Come back, when you'll have lyrics!",
|
description: "Come back, when you'll have lyrics!",
|
||||||
image: ai.rightIcon,
|
image: ai.rightIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "director",
|
key: "director",
|
||||||
title: "John",
|
title: "Theo",
|
||||||
description: "Come back when your audio is ready!",
|
description: "Theo t'accompagne pour créer ton playback.",
|
||||||
image: ai.rightIcon,
|
image: ai.rightIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "publisher",
|
key: "publisher",
|
||||||
title: "Bena",
|
title: "Publication",
|
||||||
description: "Ta vidéo est prête ? Direction YouTube !",
|
description: "Ta vidéo est prête ? Direction YouTube !",
|
||||||
image: ai.bena,
|
image: cardsImg.production,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Linking,
|
|
||||||
Modal,
|
Modal,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -15,16 +14,11 @@ import GradientButton from "../GradientButton";
|
|||||||
import CreateLyricsHeader from "../../screens/Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../../screens/Writing/components/CreateLyricsHeader";
|
||||||
import { Palette, gutters } from "../../styles";
|
import { Palette, gutters } from "../../styles";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import { getFunctionsClient } from "../../config/firebase";
|
|
||||||
import { isWeb } from "../../hooks/useLayoutType";
|
import { isWeb } from "../../hooks/useLayoutType";
|
||||||
import CreditAmount from "../CreditAmount";
|
import CreditAmount from "../CreditAmount";
|
||||||
|
import { useStripe } from "../../providers/StripeProvider";
|
||||||
|
|
||||||
const WEB_MODAL_MAX_WIDTH = 1000;
|
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") => {
|
const formatCurrency = (amount, currency = "eur") => {
|
||||||
if (typeof amount !== "number") {
|
if (typeof amount !== "number") {
|
||||||
return null;
|
return null;
|
||||||
@@ -92,49 +86,60 @@ function CoinPackCard({ pack, selected, onSelect }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CoinPackModal = ({ visible, onClose }) => {
|
const CoinPackModal = ({ visible, onClose }) => {
|
||||||
const [coinPacks, setCoinPacks] = React.useState([]);
|
|
||||||
const [selectedPackId, setSelectedPackId] = React.useState(null);
|
const [selectedPackId, setSelectedPackId] = React.useState(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
|
||||||
const [isProcessing, setIsProcessing] = React.useState(false);
|
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||||
const [errorMessage, setErrorMessage] = React.useState(null);
|
const [errorMessage, setErrorMessage] = React.useState(null);
|
||||||
const modalMaxWidth = isWeb ? WEB_MODAL_MAX_WIDTH : undefined;
|
const modalMaxWidth = isWeb ? WEB_MODAL_MAX_WIDTH : undefined;
|
||||||
|
|
||||||
const fetchCoinPacks = React.useCallback(async () => {
|
const {
|
||||||
setIsLoading(true);
|
coinPacks,
|
||||||
setErrorMessage(null);
|
isCatalogLoading,
|
||||||
try {
|
catalogError,
|
||||||
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
refreshCatalog,
|
||||||
"subscription-listCoinPacks",
|
createCoinPackCheckout,
|
||||||
);
|
} = useStripe();
|
||||||
const { data } = await callable();
|
|
||||||
const packs = Array.isArray(data?.packs) ? data.packs : [];
|
React.useEffect(() => {
|
||||||
setCoinPacks(packs);
|
if (!coinPacks.length) {
|
||||||
setSelectedPackId((current) => {
|
setSelectedPackId(null);
|
||||||
if (
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}, []);
|
setSelectedPackId((current) => {
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
coinPacks.some((pack) => pack?.productId && pack.productId === current)
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return coinPacks[0]?.productId || null;
|
||||||
|
});
|
||||||
|
}, [coinPacks]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetchCoinPacks();
|
if (!coinPacks.length && !isCatalogLoading && !catalogError) {
|
||||||
}, [visible, fetchCoinPacks]);
|
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 () => {
|
const handleCheckout = React.useCallback(async () => {
|
||||||
if (!selectedPackId) {
|
if (!selectedPackId) {
|
||||||
@@ -145,33 +150,8 @@ const CoinPackModal = ({ visible, onClose }) => {
|
|||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
await createCoinPackCheckout(selectedPackId);
|
||||||
"subscription-createCoinPackCheckoutSession",
|
closeModal({ force: true });
|
||||||
);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CoinPackModal] checkout error", error);
|
console.error("[CoinPackModal] checkout error", error);
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
@@ -181,17 +161,17 @@ const CoinPackModal = ({ visible, onClose }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
}, [selectedPackId, isWeb]);
|
}, [selectedPackId, createCoinPackCheckout, closeModal]);
|
||||||
|
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
if (isProcessing) {
|
closeModal();
|
||||||
return;
|
}, [closeModal]);
|
||||||
}
|
|
||||||
onClose?.();
|
const isLoadingCoinPacks = isCatalogLoading && !coinPacks.length;
|
||||||
}, [isProcessing, onClose]);
|
const combinedErrorMessage = errorMessage || catalogError;
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (isLoading) {
|
if (isLoadingCoinPacks) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.loaderContainer}>
|
<View style={styles.loaderContainer}>
|
||||||
<ActivityIndicator color={Palette.white} />
|
<ActivityIndicator color={Palette.white} />
|
||||||
@@ -250,8 +230,8 @@ const CoinPackModal = ({ visible, onClose }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{errorMessage ? (
|
{combinedErrorMessage ? (
|
||||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
<Text style={styles.errorText}>{combinedErrorMessage}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View style={styles.content}>{renderContent()}</View>
|
<View style={styles.content}>{renderContent()}</View>
|
||||||
@@ -263,7 +243,7 @@ const CoinPackModal = ({ visible, onClose }) => {
|
|||||||
disabled={
|
disabled={
|
||||||
!selectedPackId ||
|
!selectedPackId ||
|
||||||
isProcessing ||
|
isProcessing ||
|
||||||
isLoading ||
|
isLoadingCoinPacks ||
|
||||||
!coinPacks.length
|
!coinPacks.length
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -475,6 +475,7 @@ export const INSTRUMENTS = [
|
|||||||
"Synthétiseur",
|
"Synthétiseur",
|
||||||
"Guitare acoustique",
|
"Guitare acoustique",
|
||||||
"Guitare électrique",
|
"Guitare électrique",
|
||||||
|
"Batterie",
|
||||||
"Banjo",
|
"Banjo",
|
||||||
"Violon",
|
"Violon",
|
||||||
"Saxophone",
|
"Saxophone",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useGlobal, useContext } from "reactn";
|
import { useGlobal } from "reactn";
|
||||||
|
|
||||||
import firebase from "../config/firebase";
|
import firebase from "../config/firebase";
|
||||||
import { checkBillingDetails } from "../helpers";
|
import { checkBillingDetails } from "../helpers";
|
||||||
|
|
||||||
import { StripeEmbeddedContext } from "../providers/StripeEmbeddedProvider";
|
|
||||||
import { useWebView } from "../providers/WebViewProvider";
|
import { useWebView } from "../providers/WebViewProvider";
|
||||||
|
import { useStripe } from "../providers/StripeProvider";
|
||||||
|
|
||||||
import useLayoutType from "./useLayoutType";
|
import useLayoutType from "./useLayoutType";
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ const usePaymentSession = () => {
|
|||||||
|
|
||||||
const [currentProjectID] = useGlobal("currentProjectID");
|
const [currentProjectID] = useGlobal("currentProjectID");
|
||||||
|
|
||||||
const { setClientSecret } = useContext(StripeEmbeddedContext);
|
const { openEmbeddedCheckout } = useStripe();
|
||||||
|
|
||||||
const onCreatePaymentSession = async ({
|
const onCreatePaymentSession = async ({
|
||||||
numberOfCoin = 0,
|
numberOfCoin = 0,
|
||||||
@@ -48,7 +48,7 @@ const usePaymentSession = () => {
|
|||||||
if (isNative) {
|
if (isNative) {
|
||||||
setWebViewUrl(data);
|
setWebViewUrl(data);
|
||||||
} else {
|
} else {
|
||||||
setClientSecret(data);
|
openEmbeddedCheckout(data);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Erreur lors de la création du paiement");
|
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 NotificationProvider from "./NotificationProvider";
|
||||||
import PlayerProvider from "./PlayerProvider";
|
import PlayerProvider from "./PlayerProvider";
|
||||||
import SplashAnimationProvider from "./SplashAnimationProvider";
|
import SplashAnimationProvider from "./SplashAnimationProvider";
|
||||||
import StripeEmbeddedProvider from "./StripeEmbeddedProvider";
|
import StripeProvider from "./StripeProvider";
|
||||||
import UniversalLinkProvider from "./UniversalLinkProvider";
|
import UniversalLinkProvider from "./UniversalLinkProvider";
|
||||||
import UserDataProvider from "./UserDataProvider";
|
import UserDataProvider from "./UserDataProvider";
|
||||||
import WebViewProvider from "./WebViewProvider";
|
import WebViewProvider from "./WebViewProvider";
|
||||||
@@ -19,7 +19,7 @@ const SharedProviders = ({ children }) => {
|
|||||||
[SplashAnimationProvider, {}],
|
[SplashAnimationProvider, {}],
|
||||||
[WebViewProvider, {}],
|
[WebViewProvider, {}],
|
||||||
[UserDataProvider, {}],
|
[UserDataProvider, {}],
|
||||||
[StripeEmbeddedProvider, {}],
|
[StripeProvider, {}],
|
||||||
[UniversalLinkProvider, {}],
|
[UniversalLinkProvider, {}],
|
||||||
[BottomSheetModalProvider, {}],
|
[BottomSheetModalProvider, {}],
|
||||||
[SheetProvider, {}],
|
[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 LoadingProvider from "./LoadingProvider";
|
||||||
import TooltipProvider from "./TooltipProvider";
|
import TooltipProvider from "./TooltipProvider";
|
||||||
import StripeEmbeddedProvider from "./StripeEmbeddedProvider";
|
|
||||||
|
|
||||||
import SharedProviders from "./SharedProviders";
|
import SharedProviders from "./SharedProviders";
|
||||||
|
|
||||||
@@ -10,9 +9,7 @@ export default ({ children }) => {
|
|||||||
return (
|
return (
|
||||||
<LoadingProvider>
|
<LoadingProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<StripeEmbeddedProvider>
|
<SharedProviders>{children}</SharedProviders>
|
||||||
<SharedProviders>{children}</SharedProviders>
|
|
||||||
</StripeEmbeddedProvider>
|
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</LoadingProvider>
|
</LoadingProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -99,8 +99,10 @@ export default ({ navigation }) => {
|
|||||||
<Input
|
<Input
|
||||||
label="Adresse email"
|
label="Adresse email"
|
||||||
placeholder="Votre adresse email"
|
placeholder="Votre adresse email"
|
||||||
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
setValue={setEmail}
|
setValue={setEmail}
|
||||||
|
isBlur
|
||||||
containerStyle={{ marginBottom: 20 }}
|
containerStyle={{ marginBottom: 20 }}
|
||||||
textInputProps={{
|
textInputProps={{
|
||||||
keyboardType: "email-address",
|
keyboardType: "email-address",
|
||||||
|
|||||||
+117
-63
@@ -5,7 +5,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} 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 { Image as ExpoImage } from "expo-image";
|
||||||
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
|
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
|
||||||
import { background, cardsImg, icons } from "../../assets";
|
import { background, cardsImg, icons } from "../../assets";
|
||||||
@@ -14,7 +14,6 @@ import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
|
|||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
import MoreMenu from "../../components/MoreMenu";
|
import MoreMenu from "../../components/MoreMenu";
|
||||||
import ProjectDropDown from "../../components/ProjectDropDown/ProjectDropDown";
|
import ProjectDropDown from "../../components/ProjectDropDown/ProjectDropDown";
|
||||||
import ShareBtn from "../../components/ShareBtn/ShareBtn";
|
|
||||||
import { projectsRef, usersRef } from "../../config/firebase";
|
import { projectsRef, usersRef } from "../../config/firebase";
|
||||||
import { isWeb } from "../../hooks/useLayoutType.js";
|
import { isWeb } from "../../hooks/useLayoutType.js";
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
@@ -22,7 +21,7 @@ import LandingPage from "../LandingPage";
|
|||||||
import { navigate } from "../../navigation/NavigationService";
|
import { navigate } from "../../navigation/NavigationService";
|
||||||
import { Routes } from "../../navigation/Routes";
|
import { Routes } from "../../navigation/Routes";
|
||||||
import { useUser } from "../../providers/UserDataProvider";
|
import { useUser } from "../../providers/UserDataProvider";
|
||||||
import { Palette } from "../../styles";
|
import { Palette, gutters } from "../../styles";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import {
|
import {
|
||||||
getCreationStageStates,
|
getCreationStageStates,
|
||||||
@@ -43,6 +42,8 @@ const isProjectEmpty = (project) => {
|
|||||||
|
|
||||||
const HOME_BACKGROUND_WIDTH = isWeb ? 1280 : 520;
|
const HOME_BACKGROUND_WIDTH = isWeb ? 1280 : 520;
|
||||||
const HOME_BACKGROUND_HEIGHT = isWeb ? 760 : 360;
|
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 = [
|
const STAGE_CARD_CONTENT = [
|
||||||
{
|
{
|
||||||
@@ -59,7 +60,7 @@ const STAGE_CARD_CONTENT = [
|
|||||||
key: "beatmaker",
|
key: "beatmaker",
|
||||||
step: "ÉTAPE 2",
|
step: "ÉTAPE 2",
|
||||||
description:
|
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,
|
image: cardsImg.studio,
|
||||||
imagePosition: "right",
|
imagePosition: "right",
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
@@ -79,7 +80,7 @@ const STAGE_CARD_CONTENT = [
|
|||||||
key: "publisher",
|
key: "publisher",
|
||||||
step: "ÉTAPE 4",
|
step: "ÉTAPE 4",
|
||||||
description:
|
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,
|
image: cardsImg.production,
|
||||||
imagePosition: "right",
|
imagePosition: "right",
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
@@ -292,14 +293,14 @@ const Home = ({ navigation, route }) => {
|
|||||||
: null;
|
: null;
|
||||||
Alert(
|
Alert(
|
||||||
"Céline",
|
"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",
|
text: "Retour à l'accueil",
|
||||||
style: "cancel",
|
style: "cancel",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Continuer avec Malik",
|
text: "Continuer vers le Studio",
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
if (!currentProject?.id) {
|
if (!currentProject?.id) {
|
||||||
return;
|
return;
|
||||||
@@ -448,6 +449,68 @@ const Home = ({ navigation, route }) => {
|
|||||||
navigate(Routes.Payments);
|
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 ? (
|
return adventureStarted ? (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
<Page
|
<Page
|
||||||
@@ -466,54 +529,17 @@ const Home = ({ navigation, route }) => {
|
|||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
style={styles.centerImage}
|
style={styles.centerImage}
|
||||||
/>
|
/>
|
||||||
|
{isWeb ? (
|
||||||
<View style={styles.topBar}>
|
<View style={styles.contentOverlay}>{journeyContent}</View>
|
||||||
<ProjectDropDown
|
) : (
|
||||||
style={styles.projectDropDown}
|
<ScrollView
|
||||||
projects={projects}
|
style={[styles.mobileScrollView, styles.contentOverlay]}
|
||||||
selectedProject={currentProject}
|
contentContainerStyle={styles.mobileScrollContent}
|
||||||
allowEmptySelection
|
showsVerticalScrollIndicator={false}
|
||||||
onSelectProject={handleSelectProject}
|
>
|
||||||
onModifyProject={handleModifyProject}
|
{journeyContent}
|
||||||
onCreateProject={handleStartNew}
|
</ScrollView>
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
</View>
|
</View>
|
||||||
@@ -534,7 +560,7 @@ const Home = ({ navigation, route }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GradientButton
|
<GradientButton
|
||||||
url={videos.landing}
|
url={videoUrl}
|
||||||
title="Commencer l'aventure MusicLand"
|
title="Commencer l'aventure MusicLand"
|
||||||
onPress={handleStartVisit}
|
onPress={handleStartVisit}
|
||||||
/>
|
/>
|
||||||
@@ -572,20 +598,27 @@ const styles = StyleSheet.create({
|
|||||||
alignSelf: "stretch",
|
alignSelf: "stretch",
|
||||||
alignItems: "stretch",
|
alignItems: "stretch",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
|
position: "relative",
|
||||||
},
|
},
|
||||||
centerImage: {
|
centerImage: {
|
||||||
width: HOME_BACKGROUND_WIDTH + 120,
|
width: HOME_BACKGROUND_STYLE_WIDTH,
|
||||||
height: HOME_BACKGROUND_HEIGHT + 80,
|
height: HOME_BACKGROUND_STYLE_HEIGHT,
|
||||||
borderRadius: 22,
|
borderRadius: 22,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "45%",
|
top: isWeb ? "45%" : "50%",
|
||||||
left: "50%",
|
left: "50%",
|
||||||
transform: [
|
transform: [
|
||||||
{ translateX: -HOME_BACKGROUND_WIDTH / 2 },
|
{ translateX: -HOME_BACKGROUND_STYLE_WIDTH / 2 },
|
||||||
{ translateY: -HOME_BACKGROUND_HEIGHT / 2 },
|
{ translateY: -HOME_BACKGROUND_STYLE_HEIGHT / 2 },
|
||||||
],
|
],
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
contentOverlay: {
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
topBar: {
|
topBar: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -594,8 +627,8 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
marginTop: 24,
|
marginTop: isWeb ? 24 : 0,
|
||||||
marginBottom: 8,
|
marginBottom: isWeb ? 8 : 16,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
projectDropDown: {
|
projectDropDown: {
|
||||||
@@ -603,6 +636,15 @@ const styles = StyleSheet.create({
|
|||||||
maxWidth: 420,
|
maxWidth: 420,
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
|
mobileScrollView: {
|
||||||
|
flex: 1,
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
mobileScrollContent: {
|
||||||
|
paddingHorizontal: gutters,
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: gutters * 6,
|
||||||
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
@@ -621,4 +663,16 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
rowGap: 20,
|
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",
|
textAlign = "left",
|
||||||
isLocked,
|
isLocked,
|
||||||
onPress,
|
onPress,
|
||||||
|
containerStyle = null,
|
||||||
|
variant = "web",
|
||||||
}) => {
|
}) => {
|
||||||
const isImageOnLeft = imagePosition !== "right";
|
const isMobileVariant = variant === "mobile";
|
||||||
const isTextRight = textAlign === "right";
|
const isImageOnLeft = !isMobileVariant && imagePosition !== "right";
|
||||||
|
const isTextRight = !isMobileVariant && textAlign === "right";
|
||||||
|
|
||||||
const imageBlock = (
|
const basePressableStyle =
|
||||||
<View
|
variant === "web"
|
||||||
style={[
|
? { width: "48%" }
|
||||||
{
|
: {
|
||||||
flex: 1,
|
width: "100%",
|
||||||
alignItems: "center",
|
borderRadius: 24,
|
||||||
justifyContent: "center",
|
|
||||||
overflow: "hidden",
|
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,
|
borderTopLeftRadius: 20,
|
||||||
borderBottomLeftRadius: 20,
|
borderBottomLeftRadius: 20,
|
||||||
@@ -40,22 +60,26 @@ const StageCard = ({
|
|||||||
borderBottomRightRadius: 20,
|
borderBottomRightRadius: 20,
|
||||||
borderTopLeftRadius: 0,
|
borderTopLeftRadius: 0,
|
||||||
borderBottomLeftRadius: 0,
|
borderBottomLeftRadius: 0,
|
||||||
},
|
}),
|
||||||
]}
|
};
|
||||||
>
|
|
||||||
|
const imageBlock = (
|
||||||
|
<View style={[imageContainerBaseStyle, imageContainerVariantStyle]}>
|
||||||
<ExpoImage
|
<ExpoImage
|
||||||
source={image}
|
source={image}
|
||||||
contentFit="contain"
|
contentFit="contain"
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: isMobileVariant ? 190 : "100%",
|
||||||
minHeight: 210,
|
minHeight: isMobileVariant ? 190 : 210,
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
},
|
},
|
||||||
isImageOnLeft
|
isMobileVariant
|
||||||
? { alignSelf: "flex-end" }
|
? { alignSelf: "center" }
|
||||||
: { alignSelf: "flex-start" },
|
: isImageOnLeft
|
||||||
|
? { alignSelf: "flex-end" }
|
||||||
|
: { alignSelf: "flex-start" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -70,9 +94,11 @@ const StageCard = ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
isTextRight
|
isMobileVariant
|
||||||
? { justifyContent: "flex-end" }
|
? { justifyContent: "center" }
|
||||||
: { justifyContent: "flex-start" },
|
: isTextRight
|
||||||
|
? { justifyContent: "flex-end" }
|
||||||
|
: { justifyContent: "flex-start" },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
@@ -83,7 +109,11 @@ const StageCard = ({
|
|||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
isTextRight ? { textAlign: "right" } : { textAlign: "left" },
|
isMobileVariant
|
||||||
|
? { textAlign: "center" }
|
||||||
|
: isTextRight
|
||||||
|
? { textAlign: "right" }
|
||||||
|
: { textAlign: "left" },
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -92,21 +122,29 @@ const StageCard = ({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const textBlock = (
|
const textBlockBaseStyle = {
|
||||||
<BlurView
|
flexGrow: 1,
|
||||||
tint="dark"
|
flexShrink: 1,
|
||||||
intensity={30}
|
paddingVertical: 20,
|
||||||
style={[
|
paddingHorizontal: 20,
|
||||||
{
|
justifyContent: "center",
|
||||||
flexGrow: 1,
|
alignSelf: "center",
|
||||||
flexShrink: 1,
|
};
|
||||||
maxWidth: 380,
|
|
||||||
paddingVertical: 20,
|
const textBlockVariantStyle = isMobileVariant
|
||||||
paddingHorizontal: 20,
|
? {
|
||||||
justifyContent: "center",
|
width: "100%",
|
||||||
alignSelf: "center",
|
borderBottomLeftRadius: 24,
|
||||||
},
|
borderBottomRightRadius: 24,
|
||||||
isImageOnLeft
|
alignItems: "center",
|
||||||
|
paddingVertical: 20,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginTop: 0,
|
||||||
|
gap: 6,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
maxWidth: 380,
|
||||||
|
...(isImageOnLeft
|
||||||
? {
|
? {
|
||||||
borderTopRightRadius: 20,
|
borderTopRightRadius: 20,
|
||||||
borderBottomRightRadius: 20,
|
borderBottomRightRadius: 20,
|
||||||
@@ -114,9 +152,17 @@ const StageCard = ({
|
|||||||
: {
|
: {
|
||||||
borderTopLeftRadius: 20,
|
borderTopLeftRadius: 20,
|
||||||
borderBottomLeftRadius: 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 ? (
|
{isLocked ? (
|
||||||
<View
|
<View
|
||||||
@@ -132,7 +178,11 @@ const StageCard = ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
},
|
},
|
||||||
isTextRight ? { left: 10 } : { right: 10 },
|
isMobileVariant
|
||||||
|
? { right: 10 }
|
||||||
|
: isTextRight
|
||||||
|
? { left: 10 }
|
||||||
|
: { right: 10 },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<FontAwesome name="lock" size={18} color={Palette.primary} />
|
<FontAwesome name="lock" size={18} color={Palette.primary} />
|
||||||
@@ -141,7 +191,11 @@ const StageCard = ({
|
|||||||
{header}
|
{header}
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
isTextRight ? { textAlign: "right" } : { textAlign: "left" },
|
isMobileVariant
|
||||||
|
? { textAlign: "center" }
|
||||||
|
: isTextRight
|
||||||
|
? { textAlign: "right" }
|
||||||
|
: { textAlign: "left" },
|
||||||
{
|
{
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@@ -155,7 +209,12 @@ const StageCard = ({
|
|||||||
</BlurView>
|
</BlurView>
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = isImageOnLeft ? (
|
const content = isMobileVariant ? (
|
||||||
|
<>
|
||||||
|
{imageBlock}
|
||||||
|
{textBlock}
|
||||||
|
</>
|
||||||
|
) : isImageOnLeft ? (
|
||||||
<>
|
<>
|
||||||
{imageBlock}
|
{imageBlock}
|
||||||
{textBlock}
|
{textBlock}
|
||||||
@@ -172,15 +231,14 @@ const StageCard = ({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
disabled={isLocked}
|
disabled={isLocked}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
{
|
basePressableStyle,
|
||||||
width: "48%",
|
containerStyle,
|
||||||
},
|
|
||||||
pressed && !isLocked && { opacity: 0.85 },
|
pressed && !isLocked && { opacity: 0.85 },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: isMobileVariant ? "column" : "row",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ const MusicDetails = ({ route }) => {
|
|||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
}),
|
}),
|
||||||
[projectId, title, artist]
|
[projectId, title, artist],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleShare = useCallback(() => {
|
const handleShare = useCallback(() => {
|
||||||
@@ -488,7 +488,13 @@ const MusicDetails = ({ route }) => {
|
|||||||
console.log("MusicDetails seek error", e?.message);
|
console.log("MusicDetails seek error", e?.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[trackDescriptor, sliderDurationMs, isCurrentTrack, ensureLoaded, seekTrackTo]
|
[
|
||||||
|
trackDescriptor,
|
||||||
|
sliderDurationMs,
|
||||||
|
isCurrentTrack,
|
||||||
|
ensureLoaded,
|
||||||
|
seekTrackTo,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggleLoop = useCallback(async () => {
|
const handleToggleLoop = useCallback(async () => {
|
||||||
@@ -582,13 +588,7 @@ const MusicDetails = ({ route }) => {
|
|||||||
wasPlayingBeforeSeek.current = false;
|
wasPlayingBeforeSeek.current = false;
|
||||||
lastSeekTargetMs.current = null;
|
lastSeekTargetMs.current = null;
|
||||||
}
|
}
|
||||||
}, [
|
}, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack, seekTrackTo]);
|
||||||
ensureLoaded,
|
|
||||||
isCurrentTrack,
|
|
||||||
positionMs,
|
|
||||||
resumeTrack,
|
|
||||||
seekTrackTo,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleSeekBySeconds = useCallback(
|
const handleSeekBySeconds = useCallback(
|
||||||
async (deltaSeconds) => {
|
async (deltaSeconds) => {
|
||||||
@@ -605,7 +605,7 @@ const MusicDetails = ({ route }) => {
|
|||||||
console.log("MusicDetails seekBy error", e?.message);
|
console.log("MusicDetails seekBy error", e?.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[trackDescriptor, positionMs, isCurrentTrack, ensureLoaded, seekTrackBy]
|
[trackDescriptor, positionMs, isCurrentTrack, ensureLoaded, seekTrackBy],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLyricsSeek = useCallback(
|
const handleLyricsSeek = useCallback(
|
||||||
@@ -638,7 +638,7 @@ const MusicDetails = ({ route }) => {
|
|||||||
seekTrackTo,
|
seekTrackTo,
|
||||||
isTrackPlaying,
|
isTrackPlaying,
|
||||||
resumeTrack,
|
resumeTrack,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
const description = useMemo(() => {
|
const description = useMemo(() => {
|
||||||
// Build a readable text from lyrics with section labels
|
// Build a readable text from lyrics with section labels
|
||||||
@@ -676,12 +676,12 @@ const MusicDetails = ({ route }) => {
|
|||||||
// Current time in seconds for highlighting
|
// Current time in seconds for highlighting
|
||||||
const currentTimeS = useMemo(
|
const currentTimeS = useMemo(
|
||||||
() => Math.max(0, (positionMs || 0) / 1000),
|
() => Math.max(0, (positionMs || 0) / 1000),
|
||||||
[positionMs]
|
[positionMs],
|
||||||
);
|
);
|
||||||
// Preview lead: show words 0.5s earlier
|
// Preview lead: show words 0.5s earlier
|
||||||
const visibleTimeS = useMemo(
|
const visibleTimeS = useMemo(
|
||||||
() => Math.max(0, currentTimeS + 0.5),
|
() => Math.max(0, currentTimeS + 0.5),
|
||||||
[currentTimeS]
|
[currentTimeS],
|
||||||
);
|
);
|
||||||
// Helpers to group aligned words into sections and timed lines
|
// Helpers to group aligned words into sections and timed lines
|
||||||
const SECTION_TAG_REGEX = /^\s*\[([^\]]+)\]\s*$/i;
|
const SECTION_TAG_REGEX = /^\s*\[([^\]]+)\]\s*$/i;
|
||||||
@@ -844,11 +844,11 @@ const MusicDetails = ({ route }) => {
|
|||||||
const startS = lines.reduce(
|
const startS = lines.reduce(
|
||||||
(acc, line) =>
|
(acc, line) =>
|
||||||
Math.min(acc, Number.isFinite(line.startS) ? line.startS : acc),
|
Math.min(acc, Number.isFinite(line.startS) ? line.startS : acc),
|
||||||
Number.POSITIVE_INFINITY
|
Number.POSITIVE_INFINITY,
|
||||||
);
|
);
|
||||||
const endS = lines.reduce(
|
const endS = lines.reduce(
|
||||||
(acc, line) => Math.max(acc, Number(line.endS || 0)),
|
(acc, line) => Math.max(acc, Number(line.endS || 0)),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
grouped.push({
|
grouped.push({
|
||||||
key: current.key,
|
key: current.key,
|
||||||
@@ -910,7 +910,7 @@ const MusicDetails = ({ route }) => {
|
|||||||
|
|
||||||
const flatLines = useMemo(
|
const flatLines = useMemo(
|
||||||
() => sections.flatMap((section) => section.lines),
|
() => sections.flatMap((section) => section.lines),
|
||||||
[sections]
|
[sections],
|
||||||
);
|
);
|
||||||
const currentLineIdx = useMemo(() => {
|
const currentLineIdx = useMemo(() => {
|
||||||
if (!flatLines || flatLines.length === 0) return -1;
|
if (!flatLines || flatLines.length === 0) return -1;
|
||||||
@@ -941,14 +941,14 @@ const MusicDetails = ({ route }) => {
|
|||||||
lineRelativeYRef.current[lineIdx] = { sectionKey, relativeY };
|
lineRelativeYRef.current[lineIdx] = { sectionKey, relativeY };
|
||||||
updateLineAbsoluteOffset(lineIdx);
|
updateLineAbsoluteOffset(lineIdx);
|
||||||
},
|
},
|
||||||
[updateLineAbsoluteOffset]
|
[updateLineAbsoluteOffset],
|
||||||
);
|
);
|
||||||
const registerSectionOffset = useCallback(
|
const registerSectionOffset = useCallback(
|
||||||
(sectionKey, y, lines = []) => {
|
(sectionKey, y, lines = []) => {
|
||||||
sectionYRef.current[sectionKey] = y;
|
sectionYRef.current[sectionKey] = y;
|
||||||
lines.forEach((line) => updateLineAbsoluteOffset(line.globalIdx));
|
lines.forEach((line) => updateLineAbsoluteOffset(line.globalIdx));
|
||||||
},
|
},
|
||||||
[updateLineAbsoluteOffset]
|
[updateLineAbsoluteOffset],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const y = lineYRef.current?.[currentLineIdx];
|
const y = lineYRef.current?.[currentLineIdx];
|
||||||
@@ -966,7 +966,9 @@ const MusicDetails = ({ route }) => {
|
|||||||
topStickyContent={renderWebBackButton}
|
topStickyContent={renderWebBackButton}
|
||||||
title={action === "userProfile" ? "Mon profil" : "Détail musique"}
|
title={action === "userProfile" ? "Mon profil" : "Détail musique"}
|
||||||
backgroundImg={
|
backgroundImg={
|
||||||
action === "userProfile" ? background.profileBG : background.libraryBG2
|
action === "userProfile"
|
||||||
|
? background.profileBG
|
||||||
|
: background.libraryBG2Web
|
||||||
}
|
}
|
||||||
shareBtn={
|
shareBtn={
|
||||||
sharePayload
|
sharePayload
|
||||||
@@ -1179,7 +1181,7 @@ const MusicDetails = ({ route }) => {
|
|||||||
registerSectionOffset(
|
registerSectionOffset(
|
||||||
section.key,
|
section.key,
|
||||||
e.nativeEvent?.layout?.y,
|
e.nativeEvent?.layout?.y,
|
||||||
section.lines
|
section.lines,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
style={[
|
style={[
|
||||||
@@ -1198,7 +1200,7 @@ const MusicDetails = ({ route }) => {
|
|||||||
registerLineRelativeOffset(
|
registerLineRelativeOffset(
|
||||||
line.globalIdx,
|
line.globalIdx,
|
||||||
section.key,
|
section.key,
|
||||||
e.nativeEvent?.layout?.y ?? 0
|
e.nativeEvent?.layout?.y ?? 0,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import FontAwesome from "@expo/vector-icons/FontAwesome";
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { Image, Platform, Pressable, Text, View } from "react-native";
|
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 Page from "../layouts/Page";
|
||||||
import { navigate } from "../navigation/NavigationService";
|
import { navigate } from "../navigation/NavigationService";
|
||||||
import { useUserData } from "../providers/UserDataProvider";
|
import { useUserData } from "../providers/UserDataProvider";
|
||||||
@@ -32,15 +32,15 @@ const CREATE_DATA = [
|
|||||||
stageKey: "director",
|
stageKey: "director",
|
||||||
img: ai.john,
|
img: ai.john,
|
||||||
bg: background.playbackBG,
|
bg: background.playbackBG,
|
||||||
label: "John",
|
label: "Theo",
|
||||||
desc: "Come back when your\naudio is ready!",
|
desc: "Theo t'accompagne pour créer ton playback.",
|
||||||
type: "Director",
|
type: "Director",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stageKey: "publisher",
|
stageKey: "publisher",
|
||||||
img: ai.bena,
|
img: cardsImg.production,
|
||||||
bg: background.productionBG2,
|
bg: background.productionBG2,
|
||||||
label: "Bena",
|
label: "Publication",
|
||||||
desc: "Publions ta vidéo sur YouTube !",
|
desc: "Publions ta vidéo sur YouTube !",
|
||||||
type: "Producteur",
|
type: "Producteur",
|
||||||
},
|
},
|
||||||
|
|||||||
+86
-125
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Linking,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -17,13 +16,7 @@ import { background, subBadges } from "../assets";
|
|||||||
import { Palette, gutters } from "../styles";
|
import { Palette, gutters } from "../styles";
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
import { FONT_FAMILY } from "../styles/Fonts";
|
||||||
import { isWeb } from "../hooks/useLayoutType";
|
import { isWeb } from "../hooks/useLayoutType";
|
||||||
import { getFunctionsClient } from "../config/firebase";
|
import { useStripe } from "../providers/StripeProvider";
|
||||||
|
|
||||||
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") => {
|
const formatCurrency = (amount, currency = "eur") => {
|
||||||
if (typeof amount !== "number") {
|
if (typeof amount !== "number") {
|
||||||
@@ -298,120 +291,114 @@ export default function Payments() {
|
|||||||
initialPack && initialPack !== "packs" ? initialPack : null;
|
initialPack && initialPack !== "packs" ? initialPack : null;
|
||||||
|
|
||||||
const backgroundImage = background.bgTrans;
|
const backgroundImage = background.bgTrans;
|
||||||
const [plans, setPlans] = React.useState({ monthly: [], annual: [] });
|
const {
|
||||||
|
subscriptions,
|
||||||
|
isCatalogLoading,
|
||||||
|
catalogError,
|
||||||
|
createSubscriptionCheckout,
|
||||||
|
} = useStripe();
|
||||||
const [selectedPriceIds, setSelectedPriceIds] = React.useState({
|
const [selectedPriceIds, setSelectedPriceIds] = React.useState({
|
||||||
monthly: null,
|
monthly: null,
|
||||||
annual: null,
|
annual: null,
|
||||||
});
|
});
|
||||||
const [billingPeriod, setBillingPeriod] = React.useState("monthly");
|
const [billingPeriod, setBillingPeriod] = React.useState("monthly");
|
||||||
const [isLoadingPlans, setIsLoadingPlans] = React.useState(true);
|
|
||||||
const [processingPriceId, setProcessingPriceId] = React.useState(null);
|
const [processingPriceId, setProcessingPriceId] = React.useState(null);
|
||||||
const [errorMessage, setErrorMessage] = React.useState(null);
|
const [errorMessage, setErrorMessage] = React.useState(null);
|
||||||
const initialPackHandledRef = React.useRef(false);
|
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(() => {
|
React.useEffect(() => {
|
||||||
initialPackHandledRef.current = false;
|
initialPackHandledRef.current = false;
|
||||||
}, [initialSubscriptionPack]);
|
}, [initialSubscriptionPack]);
|
||||||
|
|
||||||
const fetchPlans = React.useCallback(async () => {
|
React.useEffect(() => {
|
||||||
setIsLoadingPlans(true);
|
const shouldApplyPack =
|
||||||
setErrorMessage(null);
|
Boolean(initialSubscriptionPack) && !initialPackHandledRef.current;
|
||||||
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;
|
||||||
|
|
||||||
let matchedPeriod = null;
|
if (shouldApplyPack && normalizedPlans) {
|
||||||
let matchedPriceId = null;
|
const desired = initialSubscriptionPack.trim().toLowerCase();
|
||||||
const shouldApplyPack =
|
for (const [periodKey, mapping] of Object.entries(
|
||||||
Boolean(initialSubscriptionPack) && !initialPackHandledRef.current;
|
PACK_PRICE_ID_BY_PERIOD,
|
||||||
|
)) {
|
||||||
if (shouldApplyPack) {
|
const candidateId = mapping[desired];
|
||||||
const desired = initialSubscriptionPack.trim().toLowerCase();
|
if (!candidateId) continue;
|
||||||
for (const [periodKey, mapping] of Object.entries(
|
const exists = (normalizedPlans[periodKey] || []).some(
|
||||||
PACK_PRICE_ID_BY_PERIOD,
|
(plan) => plan?.priceId === candidateId,
|
||||||
)) {
|
);
|
||||||
const candidateId = mapping[desired];
|
if (exists) {
|
||||||
if (!candidateId) continue;
|
matchedPeriod = periodKey;
|
||||||
const exists = (normalizedPlans[periodKey] || []).some(
|
matchedPriceId = candidateId;
|
||||||
(plan) => plan?.priceId === candidateId,
|
break;
|
||||||
);
|
|
||||||
if (exists) {
|
|
||||||
matchedPeriod = periodKey;
|
|
||||||
matchedPriceId = candidateId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setSelectedPriceIds((current) => {
|
setSelectedPriceIds((current) => {
|
||||||
const next = { ...current };
|
const next = { ...current };
|
||||||
|
|
||||||
PLAN_SEGMENTS.forEach(({ key }) => {
|
PLAN_SEGMENTS.forEach(({ key }) => {
|
||||||
const periodPlans = normalizedPlans[key] || [];
|
const periodPlans = normalizedPlans[key] || [];
|
||||||
if (!periodPlans.length) {
|
if (!periodPlans.length) {
|
||||||
next[key] = null;
|
next[key] = null;
|
||||||
return;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (matchedPeriod && matchedPriceId) {
|
||||||
setBillingPeriod(matchedPeriod);
|
next[matchedPeriod] = matchedPriceId;
|
||||||
initialPackHandledRef.current = true;
|
|
||||||
} else if (shouldApplyPack) {
|
|
||||||
initialPackHandledRef.current = true;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("[Payments] fetchPlans error", error);
|
return next;
|
||||||
setErrorMessage(
|
});
|
||||||
error?.message || "Impossible de récupérer les abonnements Stripe.",
|
|
||||||
);
|
if (matchedPeriod && matchedPriceId) {
|
||||||
} finally {
|
setBillingPeriod(matchedPeriod);
|
||||||
setIsLoadingPlans(false);
|
initialPackHandledRef.current = true;
|
||||||
|
} else if (shouldApplyPack && !isCatalogLoading) {
|
||||||
|
initialPackHandledRef.current = true;
|
||||||
}
|
}
|
||||||
}, [initialSubscriptionPack]);
|
}, [
|
||||||
|
normalizedPlans,
|
||||||
|
initialSubscriptionPack,
|
||||||
|
isCatalogLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const currentPlans = normalizedPlans[billingPeriod] || [];
|
||||||
fetchPlans();
|
|
||||||
}, [fetchPlans]);
|
|
||||||
|
|
||||||
const currentPlans = plans[billingPeriod] || [];
|
|
||||||
const selectedPriceId = selectedPriceIds[billingPeriod];
|
const selectedPriceId = selectedPriceIds[billingPeriod];
|
||||||
|
|
||||||
const handleSelect = React.useCallback(
|
const handleSelect = React.useCallback(
|
||||||
@@ -433,33 +420,7 @@ export default function Payments() {
|
|||||||
setProcessingPriceId(selectedPriceId);
|
setProcessingPriceId(selectedPriceId);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
try {
|
try {
|
||||||
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
await createSubscriptionCheckout(selectedPriceId);
|
||||||
"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);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Payments] checkout error", error);
|
console.error("[Payments] checkout error", error);
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
@@ -469,7 +430,7 @@ export default function Payments() {
|
|||||||
} finally {
|
} finally {
|
||||||
setProcessingPriceId(null);
|
setProcessingPriceId(null);
|
||||||
}
|
}
|
||||||
}, [isWeb, selectedPriceId]);
|
}, [selectedPriceId, createSubscriptionCheckout]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedPriceIds((current) => {
|
setSelectedPriceIds((current) => {
|
||||||
@@ -523,8 +484,8 @@ export default function Payments() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{errorMessage ? (
|
{combinedErrorMessage ? (
|
||||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
<Text style={styles.errorText}>{combinedErrorMessage}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isLoadingPlans && !currentPlans.length ? (
|
{isLoadingPlans && !currentPlans.length ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Image, StyleSheet, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
import { ai, background } from "../../assets";
|
import { background } from "../../assets";
|
||||||
import { gutters } from "../../styles";
|
import { gutters } from "../../styles";
|
||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||||
@@ -12,7 +12,6 @@ import { Routes } from "../../navigation";
|
|||||||
const Production = () => {
|
const Production = () => {
|
||||||
return (
|
return (
|
||||||
<Page headerType="NONE" backgroundImg={background.productionBG}>
|
<Page headerType="NONE" backgroundImg={background.productionBG}>
|
||||||
<Image source={ai.bena} style={styles.img} resizeMode="contain" />
|
|
||||||
<MusicLandHeader onPressBack={goBack} progress={25} />
|
<MusicLandHeader onPressBack={goBack} progress={25} />
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -43,13 +42,3 @@ const Production = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default 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 { useUserData } from "../../providers/UserDataProvider";
|
||||||
import { gutters, Palette } from "../../styles";
|
import { gutters, Palette } from "../../styles";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
|
import { formatDate, toDate } from "../../utils/dateFormatting";
|
||||||
|
|
||||||
const FUNCTIONS_REGION = "europe-west1";
|
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) => {
|
const capitalize = (value) => {
|
||||||
if (typeof value !== "string" || !value) {
|
if (typeof value !== "string" || !value) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useRoute } from "@react-navigation/native";
|
import { useRoute } from "@react-navigation/native";
|
||||||
import { Image } from "expo-image";
|
|
||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
import { Linking, Text, View } from "react-native";
|
import { Linking, Text, View } from "react-native";
|
||||||
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
|
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 BorderGradientButton from "../../components/BorderGradientButton";
|
||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||||
@@ -58,10 +57,10 @@ const PublishYoutube = () => {
|
|||||||
: "Publier sur YouTube";
|
: "Publier sur YouTube";
|
||||||
const statusMessage = useMemo(() => {
|
const statusMessage = useMemo(() => {
|
||||||
if (!playbackReady) {
|
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) {
|
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") {
|
if (youtubeError || youtubeStatus === "FAILED") {
|
||||||
return "La dernière tentative de publication a échoué. Tu peux réessayer ci-dessous.";
|
return "La dernière tentative de publication a échoué. Tu peux réessayer ci-dessous.";
|
||||||
@@ -69,7 +68,7 @@ const PublishYoutube = () => {
|
|||||||
if (hasYoutubePublication) {
|
if (hasYoutubePublication) {
|
||||||
return "Ta vidéo est publiée sur la chaîne YouTube MusicLand. Tu peux la partager dès maintenant.";
|
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,
|
hasYoutubePublication,
|
||||||
isPublishing,
|
isPublishing,
|
||||||
@@ -175,11 +174,6 @@ const PublishYoutube = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ alignItems: "center", gap: 18 }}>
|
<View style={{ alignItems: "center", gap: 18 }}>
|
||||||
<Image
|
|
||||||
source={ai.bena}
|
|
||||||
style={{ width: 220, height: 320 }}
|
|
||||||
contentFit="contain"
|
|
||||||
/>
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Image, StyleSheet, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { ai, background } from "../../assets";
|
import { background } from "../../assets";
|
||||||
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
|
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
|
||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||||
@@ -21,7 +21,6 @@ const Compose = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<Page backgroundImg={background.studioBG2} headerType="NONE">
|
<Page backgroundImg={background.studioBG2} headerType="NONE">
|
||||||
<Image source={ai.malik} style={styles.img} resizeMode="contain" />
|
|
||||||
<MusicLandHeader
|
<MusicLandHeader
|
||||||
onPressBack={goBack}
|
onPressBack={goBack}
|
||||||
progress={9}
|
progress={9}
|
||||||
@@ -53,13 +52,3 @@ const Compose = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default 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 React, { useEffect, useState } from "react";
|
||||||
import { Image, Platform, Pressable, Text, View } from "react-native";
|
import { Image, Platform, Pressable, Text, View } from "react-native";
|
||||||
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
|
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 AppAlert from "../../components/Alert";
|
||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
import ProgressBar from "../../components/ProgressBar";
|
import ProgressBar from "../../components/ProgressBar";
|
||||||
@@ -194,22 +194,6 @@ const CreatingSong = ({ active, config }) => {
|
|||||||
height: "50%",
|
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
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ const SongReady = () => {
|
|||||||
<View style={{ flex: 1, marginTop: 16 }}>
|
<View style={{ flex: 1, marginTop: 16 }}>
|
||||||
<CreateLyricsHeader
|
<CreateLyricsHeader
|
||||||
title="Ta chanson est prête !"
|
title="Ta chanson est prête !"
|
||||||
subTitle="Qu’en penses-tu ?"
|
subTitle="Choisis ton morceau"
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
marginBottom: responsiveHeight(2),
|
marginBottom: responsiveHeight(2),
|
||||||
}}
|
}}
|
||||||
@@ -432,7 +432,11 @@ const SongReady = () => {
|
|||||||
try {
|
try {
|
||||||
const player = idx === 0 ? player0 : player1;
|
const player = idx === 0 ? player0 : player1;
|
||||||
if (player && wasPlayingBeforeSeek.current[idx]) {
|
if (player && wasPlayingBeforeSeek.current[idx]) {
|
||||||
await player.play?.();
|
if (player.resume) {
|
||||||
|
await player.resume?.();
|
||||||
|
} else {
|
||||||
|
await player.play?.();
|
||||||
|
}
|
||||||
setIsPlaying((p) => ({ ...p, [idx]: true }));
|
setIsPlaying((p) => ({ ...p, [idx]: true }));
|
||||||
}
|
}
|
||||||
wasPlayingBeforeSeek.current[idx] = false;
|
wasPlayingBeforeSeek.current[idx] = false;
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ const Lyrics = ({ navigation }) => {
|
|||||||
await setIsLoading(false);
|
await setIsLoading(false);
|
||||||
alert(
|
alert(
|
||||||
"Céline",
|
"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",
|
text: "Retour à l'accueil",
|
||||||
@@ -300,7 +300,7 @@ const Lyrics = ({ navigation }) => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Continuer avec Malik",
|
text: "Continuer vers le Studio",
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
const targetRoute = beatmakerStage?.route || Routes.Compose;
|
const targetRoute = beatmakerStage?.route || Routes.Compose;
|
||||||
navigate(targetRoute, beatmakerStage?.params);
|
navigate(targetRoute, beatmakerStage?.params);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const SongTo = ({ audience, setAudience }) => {
|
|||||||
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
|
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
|
||||||
<CreateLyricsHeader title="À qui s’adresse ta chanson ?" />
|
<CreateLyricsHeader title="À qui s’adresse ta chanson ?" />
|
||||||
<CustomInput
|
<CustomInput
|
||||||
placeholder="Ecrire mon contexte"
|
placeholder="Écris à qui s'adresse ta chanson"
|
||||||
height={283}
|
height={283}
|
||||||
value={audience}
|
value={audience}
|
||||||
setValue={setAudience}
|
setValue={setAudience}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const SpecificityContext = ({ context, setContext }) => {
|
|||||||
subTitle="Dis-nous en un peu plus pour qu’on puisse mieux t’aider."
|
subTitle="Dis-nous en un peu plus pour qu’on puisse mieux t’aider."
|
||||||
/>
|
/>
|
||||||
<CustomInput
|
<CustomInput
|
||||||
placeholder="Ecrire mon contexte"
|
placeholder="Écris à qui s'adresse ta chanson"
|
||||||
height={283}
|
height={283}
|
||||||
value={context}
|
value={context}
|
||||||
setValue={setContext}
|
setValue={setContext}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
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 useMinuit from "react-native-minuit/src/hooks/useMinuit";
|
||||||
import { ai } from "../../assets";
|
|
||||||
import BorderGradientButton from "../../components/BorderGradientButton";
|
import BorderGradientButton from "../../components/BorderGradientButton";
|
||||||
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
|
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
|
||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
@@ -277,17 +276,6 @@ export const ChooseCoverType = () => {
|
|||||||
backgroundColor={"#2A2E33"}
|
backgroundColor={"#2A2E33"}
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
>
|
>
|
||||||
<Image
|
|
||||||
source={ai.bena}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "70%",
|
|
||||||
position: "absolute",
|
|
||||||
bottom: -40,
|
|
||||||
right: -30,
|
|
||||||
}}
|
|
||||||
resizeMode="contain"
|
|
||||||
/>
|
|
||||||
<MusicLandHeader onPressBack={goBack} progress={9} />
|
<MusicLandHeader onPressBack={goBack} progress={9} />
|
||||||
<View
|
<View
|
||||||
style={{ flex: 1, position: "relative", justifyContent: "flex-end" }}
|
style={{ flex: 1, position: "relative", justifyContent: "flex-end" }}
|
||||||
|
|||||||
@@ -252,8 +252,8 @@ const PouchReady = () => {
|
|||||||
const playbackStage = getStageAction("director", projectForStage);
|
const playbackStage = getStageAction("director", projectForStage);
|
||||||
await setLoading(false);
|
await setLoading(false);
|
||||||
alert(
|
alert(
|
||||||
"Malik",
|
"Playback prêt",
|
||||||
"Super ! Ta pochette est validée. Tu peux retourner à l'accueil ou continuer avec John pour produire ton playback.",
|
"Super ! Ta pochette est validée. Tu peux retourner à l'accueil ou continuer avec Theo pour produire ton playback.",
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: "Retour à l'accueil",
|
text: "Retour à l'accueil",
|
||||||
@@ -261,7 +261,7 @@ const PouchReady = () => {
|
|||||||
onPress: () => navigate(Routes.Home),
|
onPress: () => navigate(Routes.Home),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Continuer avec John",
|
text: "Continuer avec Theo",
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
const targetRoute = playbackStage?.route || Routes.Playback;
|
const targetRoute = playbackStage?.route || Routes.Playback;
|
||||||
const params = playbackStage?.params || {
|
const params = playbackStage?.params || {
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ const ValidateCover = () => {
|
|||||||
const playbackStage = getStageAction("director", projectForStage);
|
const playbackStage = getStageAction("director", projectForStage);
|
||||||
await setIsLoading(false);
|
await setIsLoading(false);
|
||||||
alert(
|
alert(
|
||||||
"Malik",
|
"Playback prêt",
|
||||||
"Super ! Ta pochette est validée. Tu peux retourner à l'accueil ou continuer avec John pour produire ton playback.",
|
"Super ! Ta pochette est validée. Tu peux retourner à l'accueil ou continuer avec Theo pour produire ton playback.",
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: "Retour à l'accueil",
|
text: "Retour à l'accueil",
|
||||||
@@ -126,7 +126,7 @@ const ValidateCover = () => {
|
|||||||
onPress: () => navigate(Routes.Home),
|
onPress: () => navigate(Routes.Home),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Continuer avec John",
|
text: "Continuer avec Theo",
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
const targetRoute = playbackStage?.route || Routes.Playback;
|
const targetRoute = playbackStage?.route || Routes.Playback;
|
||||||
const params =
|
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