Merge branch 'main' of gitlab.com:agenceminuit/musicland

This commit is contained in:
2025-11-04 16:30:35 +01:00
10 changed files with 1096 additions and 487 deletions
+9 -4
View File
@@ -28,9 +28,11 @@ const BorderGradientButton = ({
disabled = false, disabled = false,
maxWidth = null, maxWidth = null,
size = "medium", size = "medium",
height = null,
}) => { }) => {
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium"; const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
const buttonHeight = HEIGHT_BY_SIZE[resolvedSize]; const buttonHeight =
typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize];
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]; const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
const iconSize = resolvedSize === "small" ? 14 : 16; const iconSize = resolvedSize === "small" ? 14 : 16;
@@ -38,11 +40,14 @@ const BorderGradientButton = ({
<Pressable <Pressable
onPress={onPress} onPress={onPress}
disabled={disabled} disabled={disabled}
style={{ style={[
{
maxWidth: maxWidth ? maxWidth : null, maxWidth: maxWidth ? maxWidth : null,
...containerStyle, height: buttonHeight,
opacity: disabled ? 0.6 : 1, opacity: disabled ? 0.6 : 1,
}} },
containerStyle,
]}
> >
<BorderGradient <BorderGradient
gradientProps={{ gradientProps={{
+14 -7
View File
@@ -29,31 +29,38 @@ const GradientButton = ({
size = "medium", size = "medium",
textStyle = {}, textStyle = {},
gradientStyle = {}, gradientStyle = {},
height = null,
}) => { }) => {
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium"; const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
const buttonHeight = HEIGHT_BY_SIZE[resolvedSize]; const buttonHeight =
typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize];
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]; const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
disabled={disabled} disabled={disabled}
style={{ style={[
...containerStyle, {
opacity: disabled ? 0.6 : 1, opacity: disabled ? 0.6 : 1,
maxWidth: maxWidth ? maxWidth : null, maxWidth: maxWidth ? maxWidth : null,
}} height: buttonHeight,
},
containerStyle,
]}
> >
<LinearGradient <LinearGradient
colors={colors} colors={colors}
style={{ style={[
{
...Style.containerCenter, ...Style.containerCenter,
...Style.containerRow, ...Style.containerRow,
height: buttonHeight, height: buttonHeight,
borderRadius: 14, borderRadius: 14,
gap: 10, gap: 10,
...gradientStyle, },
}} gradientStyle,
]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
{...props} {...props}
+438
View File
@@ -0,0 +1,438 @@
import React from "react";
import {
ActivityIndicator,
Image,
Linking,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { BlurView } from "expo-blur";
import { icons } from "../../assets";
import BorderGradientButton from "../BorderGradientButton";
import GradientButton from "../GradientButton";
import CreateLyricsHeader from "../../screens/Writing/components/CreateLyricsHeader";
import { Palette, gutters } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { getFunctionsClient } from "../../config/firebase";
import { isWeb } from "../../hooks/useLayoutType";
const WEB_MODAL_MAX_WIDTH = 1000;
const FUNCTIONS_REGION = "europe-west1";
const STRIPE_SUCCESS_URL =
"https://dashboard.stripe.com/test/billing/starter-guide/checkout-success";
const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL;
const formatCurrency = (amount, currency = "eur") => {
if (typeof amount !== "number") {
return null;
}
const normalized = amount / 100;
const upperCurrency =
typeof currency === "string" && currency.trim()
? currency.trim().toUpperCase()
: "EUR";
if (typeof Intl !== "undefined" && Intl.NumberFormat) {
try {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: upperCurrency,
minimumFractionDigits: 2,
}).format(normalized);
} catch (_error) {}
}
return `${normalized.toFixed(2)} ${upperCurrency}`;
};
function CoinPackCard({ pack, selected, onSelect }) {
const handleSelect = React.useCallback(() => {
if (typeof onSelect !== "function" || !pack?.productId) {
return;
}
onSelect(pack.productId);
}, [onSelect, pack?.productId]);
const formattedPrice = formatCurrency(pack?.unitAmount, pack?.currency);
return (
<Pressable
onPress={handleSelect}
style={({ pressed }) => [
styles.cardWrapper,
selected && styles.cardWrapperSelected,
pressed && styles.cardWrapperPressed,
]}
accessibilityRole="button"
accessibilityState={{ selected }}
>
<BlurView intensity={20} tint="dark" style={styles.cardBlur}>
<View style={styles.cardHeader}>
<View style={styles.coinRow}>
<Image source={icons.coin} style={styles.coinIcon} />
<Text style={styles.coinAmount}>{pack?.coinAmount} pièces</Text>
</View>
{pack?.name ? <Text style={styles.packName}>{pack.name}</Text> : null}
</View>
{pack?.description ? (
<Text style={styles.packDescription}>{pack.description}</Text>
) : null}
{formattedPrice ? (
<Text style={styles.packPrice}>{formattedPrice}</Text>
) : null}
</BlurView>
</Pressable>
);
}
const CoinPackModal = ({ visible, onClose }) => {
const [coinPacks, setCoinPacks] = React.useState([]);
const [selectedPackId, setSelectedPackId] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(false);
const [isProcessing, setIsProcessing] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState(null);
const modalMaxWidth = isWeb ? WEB_MODAL_MAX_WIDTH : undefined;
const fetchCoinPacks = React.useCallback(async () => {
setIsLoading(true);
setErrorMessage(null);
try {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
"subscription-listCoinPacks",
);
const { data } = await callable();
const packs = Array.isArray(data?.packs) ? data.packs : [];
setCoinPacks(packs);
setSelectedPackId((current) => {
if (
current &&
packs.some((pack) => pack?.productId && pack.productId === current)
) {
return current;
}
return packs[0]?.productId || null;
});
} catch (error) {
console.error("[CoinPackModal] fetchCoinPacks error", error);
setErrorMessage(
error?.message ||
"Impossible de charger les packs de pièces. Réessaie plus tard.",
);
} finally {
setIsLoading(false);
}
}, []);
React.useEffect(() => {
if (!visible) {
return;
}
fetchCoinPacks();
}, [visible, fetchCoinPacks]);
const handleCheckout = React.useCallback(async () => {
if (!selectedPackId) {
return;
}
setIsProcessing(true);
setErrorMessage(null);
try {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
"subscription-createCoinPackCheckoutSession",
);
const { data } = await callable({
productId: selectedPackId,
returnUrls: {
successUrl: STRIPE_SUCCESS_URL,
cancelUrl: STRIPE_CANCEL_URL,
},
});
const checkoutUrl = data?.url;
if (!checkoutUrl) {
throw new Error("Session Stripe introuvable.");
}
if (isWeb) {
if (typeof window !== "undefined") {
window.location.assign(checkoutUrl);
}
} else {
const canOpen = await Linking.canOpenURL(checkoutUrl);
if (!canOpen) {
throw new Error("Impossible d'ouvrir l'URL de paiement.");
}
await Linking.openURL(checkoutUrl);
}
} catch (error) {
console.error("[CoinPackModal] checkout error", error);
setErrorMessage(
error?.message ||
"Une erreur est survenue lors de la création de la session Stripe.",
);
} finally {
setIsProcessing(false);
}
}, [selectedPackId, isWeb]);
const handleClose = React.useCallback(() => {
if (isProcessing) {
return;
}
onClose?.();
}, [isProcessing, onClose]);
const renderContent = () => {
if (isLoading) {
return (
<View style={styles.loaderContainer}>
<ActivityIndicator color={Palette.white} />
</View>
);
}
if (!coinPacks.length) {
return (
<Text style={styles.emptyState}>
Aucun pack de pièces n'est disponible pour le moment.
</Text>
);
}
return (
<ScrollView
contentContainerStyle={[
styles.packList,
isWeb ? styles.packListWeb : styles.packListMobile,
]}
showsVerticalScrollIndicator={false}
>
{coinPacks.map((pack) => (
<CoinPackCard
key={pack.productId}
pack={pack}
selected={selectedPackId === pack.productId}
onSelect={setSelectedPackId}
/>
))}
</ScrollView>
);
};
return (
<Modal
animationType="slide"
transparent
visible={visible}
onRequestClose={handleClose}
>
<View style={styles.overlay}>
<CreateLyricsHeader
containerStyle={{
width: "100%",
maxWidth: modalMaxWidth,
alignSelf: "center",
}}
>
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Acheter des pièces</Text>
<Text style={styles.subtitle}>
Choisis un pack et finalise ton achat pour continuer.
</Text>
</View>
{errorMessage ? (
<Text style={styles.errorText}>{errorMessage}</Text>
) : null}
<View style={styles.content}>{renderContent()}</View>
<View style={styles.actions}>
<GradientButton
title={isProcessing ? "Redirection..." : "Acheter ce pack"}
onPress={handleCheckout}
disabled={
!selectedPackId ||
isProcessing ||
isLoading ||
!coinPacks.length
}
/>
<BorderGradientButton
title="Fermer"
onPress={handleClose}
disabled={isProcessing}
/>
</View>
<Text style={styles.disclaimer}>
Les pièces sont créditées dès que le paiement Stripe est validé.
</Text>
</View>
</CreateLyricsHeader>
</View>
</Modal>
);
};
export default CoinPackModal;
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: gutters,
paddingVertical: gutters * 1.5,
},
container: {
gap: 24,
alignItems: "center",
paddingBottom: gutters,
width: "100%",
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : undefined,
},
header: {
gap: 8,
alignItems: "center",
paddingHorizontal: 16,
},
title: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 22,
color: Palette.white,
textAlign: "center",
},
subtitle: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 16,
color: Palette.white,
textAlign: "center",
},
errorText: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: Palette.red,
textAlign: "center",
paddingHorizontal: 16,
},
content: {
width: "100%",
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : "100%",
alignSelf: "center",
maxHeight: isWeb ? 420 : 360,
},
loaderContainer: {
width: "100%",
alignItems: "center",
justifyContent: "center",
paddingVertical: 32,
},
emptyState: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: "rgba(255, 255, 255, 0.72)",
textAlign: "center",
paddingHorizontal: 24,
},
packList: {
width: "100%",
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : "100%",
alignSelf: "center",
gap: gutters,
paddingHorizontal: isWeb ? 8 : 0,
},
packListWeb: {
flexDirection: "row",
justifyContent: "center",
flexWrap: "wrap",
},
packListMobile: {
paddingBottom: 12,
},
cardWrapper: {
flex: 1,
borderRadius: 20,
overflow: "hidden",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
backgroundColor: "rgba(12, 14, 18, 0.45)",
minWidth: isWeb ? 220 : undefined,
},
cardWrapperSelected: {
borderColor: Palette.primary,
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.3,
shadowRadius: 18,
elevation: 6,
},
cardWrapperPressed: {
transform: [{ scale: 0.97 }],
},
cardBlur: {
flex: 1,
gap: 16,
paddingVertical: 20,
paddingHorizontal: 24,
justifyContent: "space-between",
backgroundColor: "rgba(48, 52, 56, 0.55)",
},
cardHeader: {
gap: 12,
alignItems: "center",
},
coinRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
},
coinIcon: {
width: 26,
height: 26,
resizeMode: "contain",
},
coinAmount: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 20,
color: Palette.white,
},
packName: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 16,
color: Palette.white,
textAlign: "center",
},
packDescription: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
color: "rgba(255, 255, 255, 0.72)",
textAlign: "center",
},
packPrice: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
color: Palette.white,
textAlign: "center",
},
actions: {
width: "85%",
alignSelf: "center",
gap: 16,
},
disclaimer: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: "rgba(255, 255, 255, 0.7)",
textAlign: "center",
paddingHorizontal: 16,
},
});
+29 -3
View File
@@ -10,11 +10,11 @@ import ConnectBtn from "../components/ConnectBtn.js";
import NavigateHeader from "../components/NavigateHeader"; import NavigateHeader from "../components/NavigateHeader";
import ShareBtn from "../components/ShareBtn/ShareBtn"; import ShareBtn from "../components/ShareBtn/ShareBtn";
import { isWeb } from "../hooks/useLayoutType.js"; import { isWeb } from "../hooks/useLayoutType.js";
import { Routes } from "../navigation";
import { push } from "../navigation/NavigationService";
import { useUserData } from "../providers/UserDataProvider"; import { useUserData } from "../providers/UserDataProvider";
import { gutters } from "../styles"; import { gutters } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from "../styles/Fonts";
import CoinPackModal from "../components/modal/CoinPackModal";
import { subscribeCoinPackModal } from "../utils/coinPackModal";
export default ({ export default ({
children, children,
@@ -83,10 +83,31 @@ export default ({
}, [coinBalance]); }, [coinBalance]);
const showCoinBadge = isWeb && !!currentUID; const showCoinBadge = isWeb && !!currentUID;
const [isCoinModalVisible, setCoinModalVisible] = React.useState(false);
const handleCoinPress = React.useCallback(() => { const handleCoinPress = React.useCallback(() => {
if (!showCoinBadge) return; if (!showCoinBadge) return;
push?.(Routes?.Payments || "Payments", { pack: "packs" }); setCoinModalVisible(true);
}, [showCoinBadge]);
const handleCloseCoinModal = React.useCallback(() => {
setCoinModalVisible(false);
}, []);
React.useEffect(() => {
if (!showCoinBadge && isCoinModalVisible) {
setCoinModalVisible(false);
}
}, [showCoinBadge, isCoinModalVisible]);
React.useEffect(() => {
const unsubscribe = subscribeCoinPackModal(() => {
if (!showCoinBadge) {
return;
}
setCoinModalVisible(true);
});
return unsubscribe;
}, [showCoinBadge]); }, [showCoinBadge]);
const PageContainer = const PageContainer =
@@ -234,6 +255,11 @@ export default ({
{connect && <ConnectBtn />} {connect && <ConnectBtn />}
</View> </View>
)} )}
<CoinPackModal
visible={isCoinModalVisible}
onClose={handleCloseCoinModal}
/>
</View> </View>
); );
}; };
+46 -12
View File
@@ -31,16 +31,26 @@ import {
getStageAction, getStageAction,
} from "../../utils/projectStages"; } from "../../utils/projectStages";
const isProjectEmpty = (project) => {
if (!project) {
return false;
}
return !(
typeof project?.title === "string" && project.title.trim().length > 0
);
};
const Home = ({ navigation, route }) => { const Home = ({ navigation, route }) => {
const isFocused = useIsFocused(); const isFocused = useIsFocused();
const { const {
userProjects = [], userProjects = [],
resetSelectedProject,
selectProject, selectProject,
selectedProject, selectedProject,
selectedProjectId, selectedProjectId,
currentUserData, currentUserData,
currentUID, currentUID,
createNewProject,
} = useUser(); } = useUser();
const { setTooltip } = useMinuit(); const { setTooltip } = useMinuit();
@@ -310,18 +320,42 @@ const Home = ({ navigation, route }) => {
[] []
); );
const handleStartNew = useCallback(() => { const handleStartNew = useCallback(async () => {
resetSelectedProject(); const targetRoute = songwriterAction?.route || Routes.WritingLyrics;
setActiveStageIndex(0); const targetParams = songwriterAction?.params;
if (songwriterAction?.route) {
console.log( try {
"navigating to", const emptyProject =
songwriterAction.route, projects.find((project) => isProjectEmpty(project)) || null;
songwriterAction.params
); if (emptyProject?.id) {
navigate(songwriterAction.route, songwriterAction.params); selectProject(emptyProject.id);
setActiveStageIndex(findFirstUnlockedStageIndex(emptyProject));
navigate(targetRoute, targetParams);
return;
} }
}, [resetSelectedProject, songwriterAction]);
const newProjectId = await createNewProject({ hasLyrics: false });
if (!newProjectId) {
return;
}
setActiveStageIndex(0);
navigate(targetRoute, targetParams);
} catch (error) {
console.warn("Home: unable to start new project", error);
setTooltip?.({
type: "error",
text: "Impossible de démarrer un nouveau projet",
});
}
}, [
createNewProject,
projects,
selectProject,
setActiveStageIndex,
setTooltip,
songwriterAction,
]);
const continueDisabled = !hasActiveProject || stageLocked || !stageRoute; const continueDisabled = !hasActiveProject || stageLocked || !stageRoute;
const startDisabled = !songwriterAction?.route; const startDisabled = !songwriterAction?.route;
+14 -328
View File
@@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Image,
Linking, Linking,
Pressable, Pressable,
StyleSheet, StyleSheet,
@@ -13,7 +12,7 @@ import { BlurView } from "expo-blur";
import { Image as ExpoImage } from "expo-image"; import { Image as ExpoImage } from "expo-image";
import GradientButton from "../components/GradientButton"; import GradientButton from "../components/GradientButton";
import Page from "../layouts/Page"; import Page from "../layouts/Page";
import { background, icons } from "../assets"; import { background } 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";
@@ -133,50 +132,6 @@ function SubscriptionCard({ plan, selected, onSelect }) {
); );
} }
function CoinPackCard({ pack, selected, onSelect }) {
const handleSelect = React.useCallback(() => {
if (typeof onSelect === "function" && pack?.productId) {
onSelect(pack.productId);
}
}, [onSelect, pack?.productId]);
const formattedPrice = formatCurrency(pack?.unitAmount, pack?.currency);
return (
<Pressable
onPress={handleSelect}
style={({ pressed }) => [
styles.cardWrapper,
selected && styles.cardWrapperSelected,
pressed && styles.cardWrapperPressed,
]}
accessibilityRole="button"
accessibilityState={{ selected }}
>
<BlurView intensity={18} tint="dark" style={styles.cardBlur}>
<View style={styles.coinCardBody}>
<View style={styles.coinCardHeader}>
<Image source={icons.coin} style={styles.coinIcon} />
<Text style={styles.coinAmount}>{pack?.coinAmount} pièces</Text>
</View>
{pack?.name ? (
<Text style={styles.coinPackName}>{pack.name}</Text>
) : null}
{pack?.description ? (
<Text style={styles.coinPackDescription}>{pack.description}</Text>
) : null}
{formattedPrice ? (
<Text style={styles.coinPrice}>{formattedPrice}</Text>
) : null}
</View>
</BlurView>
</Pressable>
);
}
const PRICE_PRIORITY_BY_PERIOD = { const PRICE_PRIORITY_BY_PERIOD = {
monthly: [ monthly: [
"price_1SPgitCzf2o5bDRdbnhLFx6f", // Starter "price_1SPgitCzf2o5bDRdbnhLFx6f", // Starter
@@ -261,13 +216,6 @@ const PLAN_SEGMENTS = [
{ key: "annual", label: "Annuel" }, { key: "annual", label: "Annuel" },
]; ];
const COIN_PACK_MODE_KEY = "packs";
const MODE_SEGMENTS = [
{ key: "subscriptions", label: "Abonnements" },
{ key: COIN_PACK_MODE_KEY, label: "Packs de pièces" },
];
const HERO_IMAGE_WIDTH = isWeb ? 1280 : 520; const HERO_IMAGE_WIDTH = isWeb ? 1280 : 520;
const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360; const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360;
@@ -278,13 +226,10 @@ export default function Payments() {
return typeof rawPack === "string" ? rawPack.toLowerCase() : null; return typeof rawPack === "string" ? rawPack.toLowerCase() : null;
}, [route?.params?.pack]); }, [route?.params?.pack]);
const initialMode =
initialPack === COIN_PACK_MODE_KEY ? COIN_PACK_MODE_KEY : "subscriptions";
const initialSubscriptionPack = const initialSubscriptionPack =
initialPack && initialPack !== COIN_PACK_MODE_KEY ? initialPack : null; initialPack && initialPack !== "packs" ? initialPack : null;
const backgroundImage = background.bgTrans; const backgroundImage = background.bgTrans;
const [mode, setMode] = React.useState(initialMode);
const [plans, setPlans] = React.useState({ monthly: [], annual: [] }); const [plans, setPlans] = React.useState({ monthly: [], annual: [] });
const [selectedPriceIds, setSelectedPriceIds] = React.useState({ const [selectedPriceIds, setSelectedPriceIds] = React.useState({
monthly: null, monthly: null,
@@ -295,21 +240,11 @@ export default function Payments() {
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 [coinPacks, setCoinPacks] = React.useState([]);
const [selectedCoinPackId, setSelectedCoinPackId] = React.useState(null);
const [isLoadingCoinPacks, setIsLoadingCoinPacks] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
initialPackHandledRef.current = false; initialPackHandledRef.current = false;
}, [initialSubscriptionPack]); }, [initialSubscriptionPack]);
React.useEffect(() => {
if (initialPack === COIN_PACK_MODE_KEY) {
setMode(COIN_PACK_MODE_KEY);
}
}, [initialPack]);
const fetchPlans = React.useCallback(async () => { const fetchPlans = React.useCallback(async () => {
setIsLoadingPlans(true); setIsLoadingPlans(true);
setErrorMessage(null); setErrorMessage(null);
@@ -404,55 +339,12 @@ export default function Payments() {
} }
}, [initialSubscriptionPack]); }, [initialSubscriptionPack]);
const fetchCoinPacks = React.useCallback(async () => {
setIsLoadingCoinPacks(true);
setErrorMessage(null);
try {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
"subscription-listCoinPacks",
);
const { data } = await callable();
const fetchedPacks = Array.isArray(data?.packs) ? data.packs : [];
setCoinPacks(fetchedPacks);
setSelectedCoinPackId((current) => {
if (current) {
const exists = fetchedPacks.some(
(pack) => pack.productId === current,
);
if (exists) {
return current;
}
}
return fetchedPacks[0]?.productId || null;
});
} catch (error) {
console.error("[Payments] fetchCoinPacks error", error);
setErrorMessage(
error?.message || "Impossible de récupérer les packs de pièces.",
);
} finally {
setIsLoadingCoinPacks(false);
}
}, []);
React.useEffect(() => { React.useEffect(() => {
fetchPlans(); fetchPlans();
}, [fetchPlans]); }, [fetchPlans]);
React.useEffect(() => { const currentPlans = plans[billingPeriod] || [];
if (mode !== COIN_PACK_MODE_KEY) { const selectedPriceId = selectedPriceIds[billingPeriod];
return;
}
if (coinPacks.length === 0 && !isLoadingCoinPacks) {
fetchCoinPacks();
}
}, [mode, coinPacks.length, isLoadingCoinPacks, fetchCoinPacks]);
const isCoinMode = mode === COIN_PACK_MODE_KEY;
const currentPlans = isCoinMode ? [] : plans[billingPeriod] || [];
const selectedPriceId = isCoinMode
? null
: selectedPriceIds[billingPeriod];
const handleSelect = React.useCallback( const handleSelect = React.useCallback(
(priceId) => { (priceId) => {
@@ -465,59 +357,7 @@ export default function Payments() {
[billingPeriod], [billingPeriod],
); );
const handleCoinPackSelect = React.useCallback((productId) => {
setSelectedCoinPackId(productId);
setProcessingPriceId(null);
}, []);
const handleCheckout = React.useCallback(async () => { const handleCheckout = React.useCallback(async () => {
if (isCoinMode) {
if (!selectedCoinPackId) {
return;
}
setProcessingPriceId(selectedCoinPackId);
setErrorMessage(null);
try {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
"subscription-createCoinPackCheckoutSession",
);
const { data } = await callable({
productId: selectedCoinPackId,
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) {
console.error("[Payments] checkout error", error);
setErrorMessage(
error?.message ||
"Une erreur est survenue lors de la création de la session Stripe.",
);
} finally {
setProcessingPriceId(null);
}
return;
}
if (!selectedPriceId) { if (!selectedPriceId) {
return; return;
} }
@@ -561,17 +401,9 @@ export default function Payments() {
} finally { } finally {
setProcessingPriceId(null); setProcessingPriceId(null);
} }
}, [ }, [isWeb, selectedPriceId]);
isCoinMode,
isWeb,
selectedCoinPackId,
selectedPriceId,
]);
React.useEffect(() => { React.useEffect(() => {
if (isCoinMode) {
return;
}
setSelectedPriceIds((current) => { setSelectedPriceIds((current) => {
if (current[billingPeriod]) { if (current[billingPeriod]) {
return current; return current;
@@ -585,44 +417,24 @@ export default function Payments() {
[billingPeriod]: fallback, [billingPeriod]: fallback,
}; };
}); });
}, [billingPeriod, currentPlans, isCoinMode]); }, [billingPeriod, currentPlans]);
React.useEffect(() => {
if (isCoinMode) {
return;
}
setProcessingPriceId(null);
}, [billingPeriod, isCoinMode]);
React.useEffect(() => {
if (mode === COIN_PACK_MODE_KEY) {
if (!selectedCoinPackId && coinPacks.length) {
setSelectedCoinPackId(coinPacks[0].productId);
}
}
}, [mode, coinPacks, selectedCoinPackId]);
React.useEffect(() => { React.useEffect(() => {
setProcessingPriceId(null); setProcessingPriceId(null);
}, [mode]); }, [billingPeriod]);
const isProcessing = Boolean(processingPriceId); const isProcessing = Boolean(processingPriceId);
const isActionDisabled = isCoinMode const isActionDisabled =
? !selectedCoinPackId || isProcessing || isLoadingCoinPacks !selectedPriceId || isProcessing || isLoadingPlans;
: !selectedPriceId || isProcessing || isLoadingPlans;
const actionButtonTitle = isProcessing const actionButtonTitle = isProcessing
? "Redirection..." ? "Redirection..."
: isCoinMode
? "Acheter ce pack"
: "Choisir cet abonnement"; : "Choisir cet abonnement";
return ( return (
<View style={styles.root}> <View style={styles.root}>
<Page <Page
headerType="NAVIGATE" headerType="NAVIGATE"
title={ title="Abonnements"
mode === COIN_PACK_MODE_KEY ? "Packs de pièces" : "Abonnements"
}
scrollEnabled={false} scrollEnabled={false}
width={isWeb ? 960 : undefined} width={isWeb ? 960 : undefined}
containerStyle={styles.page} containerStyle={styles.page}
@@ -636,38 +448,9 @@ export default function Payments() {
/> />
<View style={styles.content}> <View style={styles.content}>
<View style={styles.modeSegmentedControl}>
{MODE_SEGMENTS.map(({ key, label }) => {
const isActive = mode === key;
return (
<Pressable
key={key}
onPress={() => setMode(key)}
style={[
styles.modeSegmentButton,
isActive && styles.modeSegmentButtonActive,
]}
accessibilityRole="button"
accessibilityState={{ selected: isActive }}
>
<Text
style={[
styles.modeSegmentLabel,
isActive && styles.modeSegmentLabelActive,
]}
>
{label}
</Text>
</Pressable>
);
})}
</View>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}> <Text style={styles.title}>
{isCoinMode Choisissez labonnement qui vous correspond
? "Recharge tes pièces MusicLand"
: "Choisissez labonnement qui vous correspond"}
</Text> </Text>
</View> </View>
@@ -675,39 +458,6 @@ export default function Payments() {
<Text style={styles.errorText}>{errorMessage}</Text> <Text style={styles.errorText}>{errorMessage}</Text>
) : null} ) : null}
{isCoinMode ? (
<>
{isLoadingCoinPacks && !coinPacks.length ? (
<View style={styles.loaderContainer}>
<ActivityIndicator color={Palette.white} />
</View>
) : null}
{!isLoadingCoinPacks && coinPacks.length === 0 ? (
<Text style={styles.emptyState}>
Aucun pack de pièces disponible pour le moment.
</Text>
) : null}
<View style={[styles.packs, isWeb && styles.packsWeb]}>
{coinPacks.map((pack) => (
<CoinPackCard
key={pack.productId}
pack={pack}
selected={selectedCoinPackId === pack.productId}
onSelect={handleCoinPackSelect}
/>
))}
</View>
{isLoadingCoinPacks && coinPacks.length > 0 ? (
<View style={styles.inlineLoader}>
<ActivityIndicator color={Palette.white} size="small" />
</View>
) : null}
</>
) : (
<>
{isLoadingPlans && !currentPlans.length ? ( {isLoadingPlans && !currentPlans.length ? (
<View style={styles.loaderContainer}> <View style={styles.loaderContainer}>
<ActivityIndicator color={Palette.white} /> <ActivityIndicator color={Palette.white} />
@@ -763,8 +513,6 @@ export default function Payments() {
<ActivityIndicator color={Palette.white} size="small" /> <ActivityIndicator color={Palette.white} size="small" />
</View> </View>
) : null} ) : null}
</>
)}
<View style={styles.actions}> <View style={styles.actions}>
<GradientButton <GradientButton
@@ -776,9 +524,9 @@ export default function Payments() {
</View> </View>
<Text style={styles.disclaimer}> <Text style={styles.disclaimer}>
{isCoinMode Résiliable en un clic à tout moment. Les prix sont indiqués TTC.
? "Les pièces sont créditées immédiatement après le paiement. Pense à conserver ton reçu Stripe pour référence." Contacte-nous pour des besoins spécifiques (facturation annuelle,
: "Résiliable en un clic à tout moment. Les prix sont indiqués TTC. Contacte-nous pour des besoins spécifiques (facturation annuelle, volume, offres éducation)."} volume, offres éducation).
</Text> </Text>
</View> </View>
</View> </View>
@@ -878,30 +626,6 @@ const styles = StyleSheet.create({
marginTop: 12, marginTop: 12,
marginBottom: 8, marginBottom: 8,
}, },
modeSegmentedControl: {
flexDirection: "row",
alignSelf: "center",
padding: 4,
borderRadius: 999,
backgroundColor: "rgba(255, 255, 255, 0.12)",
marginBottom: 16,
},
modeSegmentButton: {
paddingVertical: 8,
paddingHorizontal: 18,
borderRadius: 999,
},
modeSegmentButtonActive: {
backgroundColor: "rgba(255, 255, 255, 0.2)",
},
modeSegmentLabel: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: "rgba(255, 255, 255, 0.7)",
},
modeSegmentLabelActive: {
color: Palette.white,
},
segmentButton: { segmentButton: {
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 18, paddingHorizontal: 18,
@@ -961,44 +685,6 @@ const styles = StyleSheet.create({
cardContent: { cardContent: {
gap: 16, gap: 16,
}, },
coinCardHeader: {
flexDirection: "row",
alignItems: "center",
gap: 10,
},
coinCardBody: {
gap: 16,
alignItems: "center",
},
coinIcon: {
width: 26,
height: 26,
resizeMode: "contain",
},
coinAmount: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 22,
color: Palette.white,
textAlign: "center",
},
coinPackName: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 16,
color: Palette.white,
textAlign: "center",
},
coinPackDescription: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
color: "rgba(255, 255, 255, 0.7)",
textAlign: "center",
},
coinPrice: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
color: Palette.white,
textAlign: "center",
},
cardHeader: { cardHeader: {
gap: 6, gap: 6,
}, },
+207 -16
View File
@@ -1,29 +1,42 @@
import React, { useMemo, useRef, useState } from "react"; import React, { useMemo, useRef, useState } from "react";
import { Dimensions, View } from "react-native"; import { Dimensions, Modal, Text, View } from "react-native";
import SwiperFlatList from "react-native-swiper-flatlist"; import SwiperFlatList from "react-native-swiper-flatlist";
import { background, icons } from "../../assets"; import { background, icons } from "../../assets";
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";
import firebase from "../../config/firebase"; import AppAlert from "../../components/Alert";
import firebase, { usersRef } from "../../config/firebase";
import Page from "../../layouts/Page"; import Page from "../../layouts/Page";
import { Routes } from "../../navigation"; import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService"; import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles"; import { gutters, Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { normalizeStructureType } from "../../utils/songStructure"; import { normalizeStructureType } from "../../utils/songStructure";
import { openCoinPackModal } from "../../utils/coinPackModal";
import ChooseGenre from "./ChooseGenre"; import ChooseGenre from "./ChooseGenre";
import ChooseInstruments from "./ChooseInstruments"; import ChooseInstruments from "./ChooseInstruments";
import ChooseRhythm from "./ChooseRhythm"; import ChooseRhythm from "./ChooseRhythm";
import CustomizeVoice from "./CustomizeVoice"; import CustomizeVoice from "./CustomizeVoice";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
const { width } = Dimensions.get("window"); const { width } = Dimensions.get("window");
const MUSIC_GENERATION_COIN_COST = 8;
const CONFIRM_MODAL_MAX_WIDTH = 540;
const ComposeSong = () => { const ComposeSong = () => {
const scrollRef = useRef(null); const scrollRef = useRef(null);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [progress, setProgress] = useState(18); const [progress, setProgress] = useState(18);
const [containerLayout, setContainerLayout] = useState(null); const [containerLayout, setContainerLayout] = useState(null);
const { selectedProjectId, selectedProject, updateProjectData } = useUser(); const {
selectedProjectId,
selectedProject,
updateProjectData,
currentUserData,
currentUID,
} = useUser();
// Selections state // Selections state
const [genres, setGenres] = useState([]); const [genres, setGenres] = useState([]);
@@ -32,6 +45,45 @@ const ComposeSong = () => {
const [instruments, setInstruments] = useState([]); const [instruments, setInstruments] = useState([]);
const [rhythm, setRhythm] = useState(null); const [rhythm, setRhythm] = useState(null);
const [isConfirmVisible, setIsConfirmVisible] = useState(false);
const [isProcessingConfirmation, setIsProcessingConfirmation] =
useState(false);
const coinBalance = useMemo(() => {
const data = currentUserData || {};
const candidates = [
data?.coins,
data?.coinBalance,
data?.coin,
data?.wallet?.coins,
data?.wallet?.coinBalance,
];
for (const value of candidates) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
}
return 0;
}, [currentUserData]);
const formattedCoinBalance = useMemo(() => {
try {
return new Intl.NumberFormat("fr-FR", {
maximumFractionDigits: 0,
}).format(coinBalance);
} catch (_error) {
return `${coinBalance}`;
}
}, [coinBalance]);
const isStepValid = useMemo(() => { const isStepValid = useMemo(() => {
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
@@ -76,20 +128,14 @@ const ComposeSong = () => {
}; };
}, [selectedProject, genres, voice, instruments, rhythm, selectedProjectId]); }, [selectedProject, genres, voice, instruments, rhythm, selectedProjectId]);
const onPressNext = async () => { const persistMusicConfig = async () => {
if (selectedIndex === 3) {
try { try {
// Persist config on the selected selectedProject so GeneratingSong can pick it up if (!selectedProjectId) return;
if (selectedProjectId) {
await updateProjectData({ await updateProjectData({
musicConfig: { musicConfig: {
title: musicConfig?.title || "", title: musicConfig?.title || "",
lyrics: Array.isArray(musicConfig?.lyrics) lyrics: Array.isArray(musicConfig?.lyrics) ? musicConfig.lyrics : [],
? musicConfig.lyrics genres: Array.isArray(musicConfig?.genres) ? musicConfig.genres : [],
: [],
genres: Array.isArray(musicConfig?.genres)
? musicConfig.genres
: [],
voice: Array.isArray(musicConfig?.voice) ? musicConfig.voice : [], voice: Array.isArray(musicConfig?.voice) ? musicConfig.voice : [],
instruments: Array.isArray(musicConfig?.instruments) instruments: Array.isArray(musicConfig?.instruments)
? musicConfig.instruments ? musicConfig.instruments
@@ -100,10 +146,67 @@ const ComposeSong = () => {
sunoTaskId: firebase.firestore.FieldValue.delete(), sunoTaskId: firebase.firestore.FieldValue.delete(),
musicUrls: firebase.firestore.FieldValue.delete(), musicUrls: firebase.firestore.FieldValue.delete(),
}); });
}
} catch (e) {} } catch (e) {}
// Also pass the config to the screen to avoid any race condition };
const spendCoinsForGeneration = async () => {
if (!currentUID) {
throw new Error("Utilisateur introuvable. Merci de réessayer.");
}
const decrement = firebase.firestore.FieldValue.increment(
-MUSIC_GENERATION_COIN_COST,
);
const timestamp = firebase.firestore.FieldValue.serverTimestamp();
await usersRef.doc(currentUID).set(
{
coins: decrement,
coinBalance: decrement,
wallet: {
coins: decrement,
coinBalance: decrement,
},
lastCoinSpendAt: timestamp,
lastCoinSpendAmount: MUSIC_GENERATION_COIN_COST,
},
{ merge: true },
);
};
const handleConfirmGeneration = async () => {
if (isProcessingConfirmation) return;
setIsProcessingConfirmation(true);
try {
const availableCoins = Number.isFinite(coinBalance) ? coinBalance : 0;
if (availableCoins < MUSIC_GENERATION_COIN_COST) {
setIsConfirmVisible(false);
AppAlert(
"Crédits insuffisants",
"Tu n'as pas assez de pièces pour générer une musique. Recharge ton compte pour continuer.",
);
openCoinPackModal();
return;
}
await spendCoinsForGeneration();
await persistMusicConfig();
setIsConfirmVisible(false);
navigate(Routes.GeneratingSong, { config: musicConfig }); navigate(Routes.GeneratingSong, { config: musicConfig });
} catch (error) {
setIsConfirmVisible(false);
const message =
error?.message ||
"Une erreur est survenue lors du lancement de la génération.";
AppAlert("Impossible de lancer la génération", message);
} finally {
setIsProcessingConfirmation(false);
}
};
const onPressNext = () => {
if (selectedIndex === 3) {
setIsConfirmVisible(true);
return; return;
} }
setSelectedIndex(selectedIndex + 1); setSelectedIndex(selectedIndex + 1);
@@ -193,6 +296,94 @@ const ComposeSong = () => {
/> />
)} )}
</View> </View>
<Modal
animationType="slide"
transparent
visible={isConfirmVisible}
onRequestClose={() => {
if (isProcessingConfirmation) return;
setIsConfirmVisible(false);
}}
>
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: gutters,
paddingVertical: gutters * 1.5,
backgroundColor: "rgba(0, 0, 0, 0.6)",
}}
>
<CreateLyricsHeader
containerStyle={{
width: "100%",
maxWidth: CONFIRM_MODAL_MAX_WIDTH,
alignSelf: "center",
paddingVertical: 24,
paddingHorizontal: 24,
gap: 24,
}}
>
<View style={{ gap: 30, alignItems: "center" }}>
<View
style={{
paddingHorizontal: 15,
gap: 12,
alignItems: "center",
}}
>
<Text
style={{
fontSize: 22,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
textAlign: "center",
}}
>
Générer la musique ?
</Text>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
}}
>
Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces.
{"\n"}Souhaites-tu les utiliser pour lancer la génération ?
</Text>
<Text
style={{
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
>
Solde disponible : {formattedCoinBalance} pièces
</Text>
</View>
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
<GradientButton
title="Confirmer"
onPress={handleConfirmGeneration}
disabled={isProcessingConfirmation}
/>
<BorderGradientButton
title="Annuler"
onPress={() => {
if (isProcessingConfirmation) return;
setIsConfirmVisible(false);
}}
disabled={isProcessingConfirmation}
/>
</View>
</View>
</CreateLyricsHeader>
</View>
</Modal>
</Page> </Page>
); );
}; };
+212 -14
View File
@@ -5,23 +5,30 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { Dimensions, FlatList, View } from "react-native"; import { Dimensions, FlatList, Modal, Text, View } from "react-native";
import { background } from "../../assets"; import { background } from "../../assets";
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";
import firebase from "../../config/firebase"; import AppAlert from "../../components/Alert";
import firebase, { usersRef } from "../../config/firebase";
import Page from "../../layouts/Page"; import Page from "../../layouts/Page";
import { Routes } from "../../navigation"; import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService"; import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles"; import { gutters, Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { normalizeStructureType } from "../../utils/songStructure"; import { normalizeStructureType } from "../../utils/songStructure";
import { openCoinPackModal } from "../../utils/coinPackModal";
import ChooseGenre from "./ChooseGenre"; import ChooseGenre from "./ChooseGenre";
import ChooseInstruments from "./ChooseInstruments"; import ChooseInstruments from "./ChooseInstruments";
import ChooseRhythm from "./ChooseRhythm"; import ChooseRhythm from "./ChooseRhythm";
import CustomizeVoice from "./CustomizeVoice"; import CustomizeVoice from "./CustomizeVoice";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
const { width: windowWidth } = Dimensions.get("window"); const { width: windowWidth } = Dimensions.get("window");
const MUSIC_GENERATION_COIN_COST = 8;
const CONFIRM_MODAL_MAX_WIDTH = 540;
const ComposeSong = () => { const ComposeSong = () => {
const scrollRef = useRef(null); const scrollRef = useRef(null);
@@ -29,12 +36,56 @@ const ComposeSong = () => {
const [progress, setProgress] = useState(18); const [progress, setProgress] = useState(18);
const [parentLayout, setParentLayout] = useState(null); const [parentLayout, setParentLayout] = useState(null);
const containerWidth = parentLayout?.width || windowWidth || 1; const containerWidth = parentLayout?.width || windowWidth || 1;
const { selectedProjectId, selectedProject, updateProjectData } = useUser(); const {
selectedProjectId,
selectedProject,
updateProjectData,
currentUserData,
currentUID,
} = useUser();
const [genres, setGenres] = useState([]); const [genres, setGenres] = useState([]);
const [voice, setVoice] = useState({}); const [voice, setVoice] = useState({});
const [instruments, setInstruments] = useState([]); const [instruments, setInstruments] = useState([]);
const [rhythm, setRhythm] = useState(null); const [rhythm, setRhythm] = useState(null);
const [isConfirmVisible, setIsConfirmVisible] = useState(false);
const [isProcessingConfirmation, setIsProcessingConfirmation] =
useState(false);
const coinBalance = useMemo(() => {
const data = currentUserData || {};
const candidates = [
data?.coins,
data?.coinBalance,
data?.coin,
data?.wallet?.coins,
data?.wallet?.coinBalance,
];
for (const value of candidates) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
}
return 0;
}, [currentUserData]);
const formattedCoinBalance = useMemo(() => {
try {
return new Intl.NumberFormat("fr-FR", {
maximumFractionDigits: 0,
}).format(coinBalance);
} catch (_error) {
return `${coinBalance}`;
}
}, [coinBalance]);
const isStepValid = useMemo(() => { const isStepValid = useMemo(() => {
switch (selectedIndex) { switch (selectedIndex) {
@@ -132,19 +183,14 @@ const ComposeSong = () => {
[containerWidth] [containerWidth]
); );
const onPressNext = async () => { const persistMusicConfig = useCallback(async () => {
if (selectedIndex === steps.length - 1) {
try { try {
if (selectedProjectId) { if (!selectedProjectId) return;
await updateProjectData({ await updateProjectData({
musicConfig: { musicConfig: {
title: musicConfig?.title || "", title: musicConfig?.title || "",
lyrics: Array.isArray(musicConfig?.lyrics) lyrics: Array.isArray(musicConfig?.lyrics) ? musicConfig.lyrics : [],
? musicConfig.lyrics genres: Array.isArray(musicConfig?.genres) ? musicConfig.genres : [],
: [],
genres: Array.isArray(musicConfig?.genres)
? musicConfig.genres
: [],
voice: Array.isArray(musicConfig?.voice) ? musicConfig.voice : [], voice: Array.isArray(musicConfig?.voice) ? musicConfig.voice : [],
instruments: Array.isArray(musicConfig?.instruments) instruments: Array.isArray(musicConfig?.instruments)
? musicConfig.instruments ? musicConfig.instruments
@@ -155,9 +201,73 @@ const ComposeSong = () => {
sunoTaskId: firebase.firestore.FieldValue.delete(), sunoTaskId: firebase.firestore.FieldValue.delete(),
musicUrls: firebase.firestore.FieldValue.delete(), musicUrls: firebase.firestore.FieldValue.delete(),
}); });
} catch (_error) {}
}, [musicConfig, selectedProjectId, updateProjectData]);
const spendCoinsForGeneration = useCallback(async () => {
if (!currentUID) {
throw new Error("Utilisateur introuvable. Merci de réessayer.");
} }
} catch (e) {}
const decrement = firebase.firestore.FieldValue.increment(
-MUSIC_GENERATION_COIN_COST,
);
const timestamp = firebase.firestore.FieldValue.serverTimestamp();
await usersRef.doc(currentUID).set(
{
coins: decrement,
coinBalance: decrement,
wallet: {
coins: decrement,
coinBalance: decrement,
},
lastCoinSpendAt: timestamp,
lastCoinSpendAmount: MUSIC_GENERATION_COIN_COST,
},
{ merge: true },
);
}, [currentUID]);
const handleConfirmGeneration = useCallback(async () => {
if (isProcessingConfirmation) return;
setIsProcessingConfirmation(true);
try {
const availableCoins = Number.isFinite(coinBalance) ? coinBalance : 0;
if (availableCoins < MUSIC_GENERATION_COIN_COST) {
setIsConfirmVisible(false);
AppAlert(
"Crédits insuffisants",
"Tu n'as pas assez de pièces pour générer une musique. Recharge ton compte pour continuer.",
);
openCoinPackModal();
return;
}
await spendCoinsForGeneration();
await persistMusicConfig();
setIsConfirmVisible(false);
navigate(Routes.GeneratingSong, { config: musicConfig }); navigate(Routes.GeneratingSong, { config: musicConfig });
} catch (error) {
setIsConfirmVisible(false);
const message =
error?.message ||
"Une erreur est survenue lors du lancement de la génération.";
AppAlert("Impossible de lancer la génération", message);
} finally {
setIsProcessingConfirmation(false);
}
}, [
coinBalance,
isProcessingConfirmation,
musicConfig,
persistMusicConfig,
spendCoinsForGeneration,
]);
const onPressNext = () => {
if (selectedIndex === steps.length - 1) {
setIsConfirmVisible(true);
return; return;
} }
setSelectedIndex((prev) => Math.min(prev + 1, steps.length - 1)); setSelectedIndex((prev) => Math.min(prev + 1, steps.length - 1));
@@ -215,6 +325,94 @@ const ComposeSong = () => {
/> />
)} )}
</View> </View>
<Modal
animationType="slide"
transparent
visible={isConfirmVisible}
onRequestClose={() => {
if (isProcessingConfirmation) return;
setIsConfirmVisible(false);
}}
>
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: gutters,
paddingVertical: gutters * 1.5,
backgroundColor: "rgba(0, 0, 0, 0.6)",
}}
>
<CreateLyricsHeader
containerStyle={{
width: "100%",
maxWidth: CONFIRM_MODAL_MAX_WIDTH,
alignSelf: "center",
paddingVertical: 24,
paddingHorizontal: 24,
gap: 24,
}}
>
<View style={{ gap: 30, alignItems: "center" }}>
<View
style={{
paddingHorizontal: 15,
gap: 12,
alignItems: "center",
}}
>
<Text
style={{
fontSize: 22,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
textAlign: "center",
}}
>
Générer la musique ?
</Text>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
}}
>
Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces.
{"\n"}Souhaites-tu les utiliser pour lancer la génération ?
</Text>
<Text
style={{
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
>
Solde disponible : {formattedCoinBalance} pièces
</Text>
</View>
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
<GradientButton
title="Confirmer"
onPress={handleConfirmGeneration}
disabled={isProcessingConfirmation}
/>
<BorderGradientButton
title="Annuler"
onPress={() => {
if (isProcessingConfirmation) return;
setIsConfirmVisible(false);
}}
disabled={isProcessingConfirmation}
/>
</View>
</View>
</CreateLyricsHeader>
</View>
</Modal>
</Page> </Page>
); );
}; };
+25 -22
View File
@@ -1,8 +1,7 @@
import React from "react"; import React from "react";
import { StyleSheet, Text, View } from "react-native"; import { StyleSheet, 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 { background, icons } from "../../assets"; import { background } from "../../assets";
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";
import { strings } from "../../constants/strings"; import { strings } from "../../constants/strings";
@@ -14,16 +13,22 @@ import { useUserData } from "../../providers/UserDataProvider";
import { Palette, gutters } from "../../styles"; import { Palette, gutters } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
const CTA_BUTTON_HEIGHT = 58;
const CTA_BUTTON_MAX_WIDTH = 520;
const WritingLyrics = () => { const WritingLyrics = () => {
const { createNewProject, selectedProject, updateProjectData } = const {
useUserData(); createNewProject,
selectedProjectId,
updateProjectData,
} = useUserData();
const { setIsLoading } = useMinuit(); const { setIsLoading } = useMinuit();
const startWriting = React.useCallback(async () => { const startWriting = React.useCallback(async () => {
try { try {
await setIsLoading(true); await setIsLoading(true);
if (selectedProject) { if (selectedProjectId) {
updateProjectData({ hasLyrics: false }); await updateProjectData({ hasLyrics: false });
setTimeout(() => navigate(Routes.CreateLyricsWithAi), 500); setTimeout(() => navigate(Routes.CreateLyricsWithAi), 500);
return; return;
} }
@@ -40,7 +45,7 @@ const WritingLyrics = () => {
}, [ }, [
createNewProject, createNewProject,
navigate, navigate,
selectedProject, selectedProjectId,
setIsLoading, setIsLoading,
updateProjectData, updateProjectData,
]); ]);
@@ -51,11 +56,7 @@ const WritingLyrics = () => {
headerType="NONE" headerType="NONE"
> >
{/* <Image source={ai.nathalie} style={styles.img} resizeMode="contain" /> */} {/* <Image source={ai.nathalie} style={styles.img} resizeMode="contain" /> */}
<MusicLandHeader <MusicLandHeader onPressBack={goBack} />
onPressBack={goBack}
progress={9}
logo={icons.musicLandWriting}
/>
<View style={styles.contentWrapper}> <View style={styles.contentWrapper}>
<View style={styles.heroContainer}> <View style={styles.heroContainer}>
{/* <Text style={styles.heroTitle}> {/* <Text style={styles.heroTitle}>
@@ -68,18 +69,12 @@ const WritingLyrics = () => {
<View style={styles.ctaGroup}> <View style={styles.ctaGroup}>
<GradientButton <GradientButton
size="large" size="large"
maxWidth={520} height={CTA_BUTTON_HEIGHT}
containerStyle={{ alignSelf: "center", width: "100%" }} maxWidth={CTA_BUTTON_MAX_WIDTH}
containerStyle={styles.ctaButton}
title={strings.writing.onboarding.primaryCta} title={strings.writing.onboarding.primaryCta}
onPress={startWriting} onPress={startWriting}
textStyle={{ fontFamily: FONT_FAMILY.InterSemiBold }} textStyle={styles.ctaButtonText}
/>
<BorderGradientButton
size="small"
title={strings.writing.onboarding.secondaryCta}
disabled
containerStyle={{ alignSelf: "center", width: "100%" }}
maxWidth={360}
/> />
<Text style={styles.secondaryNote}> <Text style={styles.secondaryNote}>
{strings.writing.onboarding.secondaryNote} {strings.writing.onboarding.secondaryNote}
@@ -127,6 +122,14 @@ const styles = StyleSheet.create({
ctaGroup: { ctaGroup: {
gap: 12, gap: 12,
}, },
ctaButton: {
alignSelf: "center",
width: "100%",
maxWidth: CTA_BUTTON_MAX_WIDTH,
},
ctaButtonText: {
fontFamily: FONT_FAMILY.InterSemiBold,
},
secondaryNote: { secondaryNote: {
textAlign: "center", textAlign: "center",
color: Palette.white, color: Palette.white,
+21
View File
@@ -0,0 +1,21 @@
const listeners = new Set();
export const subscribeCoinPackModal = (listener) => {
if (typeof listener !== "function") {
return () => {};
}
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const openCoinPackModal = () => {
listeners.forEach((listener) => {
try {
listener();
} catch (error) {
console.error("[coinPackModal] open listener error", error);
}
});
};