From 4a45924ee07167360693485e5d4e9c0ac15eaa99 Mon Sep 17 00:00:00 2001 From: Thomas Demirdjian Date: Tue, 4 Nov 2025 15:59:49 +0100 Subject: [PATCH] fix empty project creation and use credit to generate --- src/components/BorderGradientButton.js | 17 +- src/components/GradientButton.js | 35 +- src/components/modal/CoinPackModal.js | 438 +++++++++++++++++++++++++ src/layouts/Page.js | 32 +- src/screens/Home/Home.js | 58 +++- src/screens/Payments.js | 426 ++++-------------------- src/screens/Studio/ComposeSong.js | 253 ++++++++++++-- src/screens/Studio/ComposeSong.web.js | 256 +++++++++++++-- src/screens/Writing/WritingLyrics.js | 47 +-- src/utils/coinPackModal.js | 21 ++ 10 files changed, 1096 insertions(+), 487 deletions(-) create mode 100644 src/components/modal/CoinPackModal.js create mode 100644 src/utils/coinPackModal.js diff --git a/src/components/BorderGradientButton.js b/src/components/BorderGradientButton.js index 68f82a7..b57ade4 100644 --- a/src/components/BorderGradientButton.js +++ b/src/components/BorderGradientButton.js @@ -28,9 +28,11 @@ const BorderGradientButton = ({ disabled = false, maxWidth = null, size = "medium", + height = null, }) => { 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 iconSize = resolvedSize === "small" ? 14 : 16; @@ -38,11 +40,14 @@ const BorderGradientButton = ({ { 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]; return ( { + 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 ( + [ + styles.cardWrapper, + selected && styles.cardWrapperSelected, + pressed && styles.cardWrapperPressed, + ]} + accessibilityRole="button" + accessibilityState={{ selected }} + > + + + + + {pack?.coinAmount} pièces + + {pack?.name ? {pack.name} : null} + + {pack?.description ? ( + {pack.description} + ) : null} + {formattedPrice ? ( + {formattedPrice} + ) : null} + + + ); +} + +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 ( + + + + ); + } + + if (!coinPacks.length) { + return ( + + Aucun pack de pièces n'est disponible pour le moment. + + ); + } + + return ( + + {coinPacks.map((pack) => ( + + ))} + + ); + }; + + return ( + + + + + + Acheter des pièces + + Choisis un pack et finalise ton achat pour continuer. + + + + {errorMessage ? ( + {errorMessage} + ) : null} + + {renderContent()} + + + + + + + + Les pièces sont créditées dès que le paiement Stripe est validé. + + + + + + ); +}; + +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, + }, +}); diff --git a/src/layouts/Page.js b/src/layouts/Page.js index b83c545..de757e2 100644 --- a/src/layouts/Page.js +++ b/src/layouts/Page.js @@ -10,11 +10,11 @@ import ConnectBtn from "../components/ConnectBtn.js"; import NavigateHeader from "../components/NavigateHeader"; import ShareBtn from "../components/ShareBtn/ShareBtn"; import { isWeb } from "../hooks/useLayoutType.js"; -import { Routes } from "../navigation"; -import { push } from "../navigation/NavigationService"; import { useUserData } from "../providers/UserDataProvider"; import { gutters } from "../styles"; import { FONT_FAMILY } from "../styles/Fonts"; +import CoinPackModal from "../components/modal/CoinPackModal"; +import { subscribeCoinPackModal } from "../utils/coinPackModal"; export default ({ children, @@ -83,10 +83,31 @@ export default ({ }, [coinBalance]); const showCoinBadge = isWeb && !!currentUID; + const [isCoinModalVisible, setCoinModalVisible] = React.useState(false); const handleCoinPress = React.useCallback(() => { 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]); const PageContainer = @@ -234,6 +255,11 @@ export default ({ {connect && } )} + + ); }; diff --git a/src/screens/Home/Home.js b/src/screens/Home/Home.js index d5202b2..87573ee 100644 --- a/src/screens/Home/Home.js +++ b/src/screens/Home/Home.js @@ -31,16 +31,26 @@ import { getStageAction, } 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 isFocused = useIsFocused(); const { userProjects = [], - resetSelectedProject, selectProject, selectedProject, selectedProjectId, currentUserData, currentUID, + createNewProject, } = useUser(); const { setTooltip } = useMinuit(); @@ -310,18 +320,42 @@ const Home = ({ navigation, route }) => { [] ); - const handleStartNew = useCallback(() => { - resetSelectedProject(); - setActiveStageIndex(0); - if (songwriterAction?.route) { - console.log( - "navigating to", - songwriterAction.route, - songwriterAction.params - ); - navigate(songwriterAction.route, songwriterAction.params); + const handleStartNew = useCallback(async () => { + const targetRoute = songwriterAction?.route || Routes.WritingLyrics; + const targetParams = songwriterAction?.params; + + try { + const emptyProject = + projects.find((project) => isProjectEmpty(project)) || null; + + if (emptyProject?.id) { + selectProject(emptyProject.id); + setActiveStageIndex(findFirstUnlockedStageIndex(emptyProject)); + navigate(targetRoute, targetParams); + return; + } + + 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", + }); } - }, [resetSelectedProject, songwriterAction]); + }, [ + createNewProject, + projects, + selectProject, + setActiveStageIndex, + setTooltip, + songwriterAction, + ]); const continueDisabled = !hasActiveProject || stageLocked || !stageRoute; const startDisabled = !songwriterAction?.route; diff --git a/src/screens/Payments.js b/src/screens/Payments.js index 7a425fc..e949939 100644 --- a/src/screens/Payments.js +++ b/src/screens/Payments.js @@ -1,7 +1,6 @@ import React from "react"; import { ActivityIndicator, - Image, Linking, Pressable, StyleSheet, @@ -13,7 +12,7 @@ import { BlurView } from "expo-blur"; import { Image as ExpoImage } from "expo-image"; import GradientButton from "../components/GradientButton"; import Page from "../layouts/Page"; -import { background, icons } from "../assets"; +import { background } from "../assets"; import { Palette, gutters } from "../styles"; import { FONT_FAMILY } from "../styles/Fonts"; 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 ( - [ - styles.cardWrapper, - selected && styles.cardWrapperSelected, - pressed && styles.cardWrapperPressed, - ]} - accessibilityRole="button" - accessibilityState={{ selected }} - > - - - - - {pack?.coinAmount} pièces - - - {pack?.name ? ( - {pack.name} - ) : null} - - {pack?.description ? ( - {pack.description} - ) : null} - - {formattedPrice ? ( - {formattedPrice} - ) : null} - - - - ); -} - const PRICE_PRIORITY_BY_PERIOD = { monthly: [ "price_1SPgitCzf2o5bDRdbnhLFx6f", // Starter @@ -261,13 +216,6 @@ const PLAN_SEGMENTS = [ { 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_HEIGHT = isWeb ? 760 : 360; @@ -278,13 +226,10 @@ export default function Payments() { return typeof rawPack === "string" ? rawPack.toLowerCase() : null; }, [route?.params?.pack]); - const initialMode = - initialPack === COIN_PACK_MODE_KEY ? COIN_PACK_MODE_KEY : "subscriptions"; const initialSubscriptionPack = - initialPack && initialPack !== COIN_PACK_MODE_KEY ? initialPack : null; + initialPack && initialPack !== "packs" ? initialPack : null; const backgroundImage = background.bgTrans; - const [mode, setMode] = React.useState(initialMode); const [plans, setPlans] = React.useState({ monthly: [], annual: [] }); const [selectedPriceIds, setSelectedPriceIds] = React.useState({ monthly: null, @@ -295,21 +240,11 @@ export default function Payments() { const [processingPriceId, setProcessingPriceId] = React.useState(null); const [errorMessage, setErrorMessage] = React.useState(null); const initialPackHandledRef = React.useRef(false); - const [coinPacks, setCoinPacks] = React.useState([]); - const [selectedCoinPackId, setSelectedCoinPackId] = React.useState(null); - const [isLoadingCoinPacks, setIsLoadingCoinPacks] = React.useState(false); React.useEffect(() => { initialPackHandledRef.current = false; }, [initialSubscriptionPack]); - React.useEffect(() => { - if (initialPack === COIN_PACK_MODE_KEY) { - setMode(COIN_PACK_MODE_KEY); - } - }, [initialPack]); - - const fetchPlans = React.useCallback(async () => { setIsLoadingPlans(true); setErrorMessage(null); @@ -404,55 +339,12 @@ export default function Payments() { } }, [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(() => { fetchPlans(); }, [fetchPlans]); - React.useEffect(() => { - if (mode !== COIN_PACK_MODE_KEY) { - 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 currentPlans = plans[billingPeriod] || []; + const selectedPriceId = selectedPriceIds[billingPeriod]; const handleSelect = React.useCallback( (priceId) => { @@ -465,59 +357,7 @@ export default function Payments() { [billingPeriod], ); - const handleCoinPackSelect = React.useCallback((productId) => { - setSelectedCoinPackId(productId); - setProcessingPriceId(null); - }, []); - 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) { return; } @@ -561,17 +401,9 @@ export default function Payments() { } finally { setProcessingPriceId(null); } - }, [ - isCoinMode, - isWeb, - selectedCoinPackId, - selectedPriceId, - ]); + }, [isWeb, selectedPriceId]); React.useEffect(() => { - if (isCoinMode) { - return; - } setSelectedPriceIds((current) => { if (current[billingPeriod]) { return current; @@ -585,44 +417,24 @@ export default function Payments() { [billingPeriod]: fallback, }; }); - }, [billingPeriod, currentPlans, isCoinMode]); - - 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]); + }, [billingPeriod, currentPlans]); React.useEffect(() => { setProcessingPriceId(null); - }, [mode]); + }, [billingPeriod]); const isProcessing = Boolean(processingPriceId); - const isActionDisabled = isCoinMode - ? !selectedCoinPackId || isProcessing || isLoadingCoinPacks - : !selectedPriceId || isProcessing || isLoadingPlans; + const isActionDisabled = + !selectedPriceId || isProcessing || isLoadingPlans; const actionButtonTitle = isProcessing ? "Redirection..." - : isCoinMode - ? "Acheter ce pack" : "Choisir cet abonnement"; return ( - - {MODE_SEGMENTS.map(({ key, label }) => { - const isActive = mode === key; + + + Choisissez l’abonnement qui vous correspond + + + + {errorMessage ? ( + {errorMessage} + ) : null} + + {isLoadingPlans && !currentPlans.length ? ( + + + + ) : null} + + {!isLoadingPlans && currentPlans.length === 0 ? ( + + Aucun abonnement Stripe disponible pour le moment. + + ) : null} + + + {PLAN_SEGMENTS.map(({ key, label }) => { + const isActive = billingPeriod === key; return ( setMode(key)} + onPress={() => setBillingPeriod(key)} style={[ - styles.modeSegmentButton, - isActive && styles.modeSegmentButtonActive, + styles.segmentButton, + isActive && styles.segmentButtonActive, ]} accessibilityRole="button" accessibilityState={{ selected: isActive }} > {label} @@ -663,109 +497,23 @@ export default function Payments() { })} - - - {isCoinMode - ? "Recharge tes pièces MusicLand" - : "Choisissez l’abonnement qui vous correspond"} - + + {currentPlans.map((plan) => ( + + ))} - {errorMessage ? ( - {errorMessage} + {isLoadingPlans && currentPlans.length > 0 ? ( + + + ) : null} - {isCoinMode ? ( - <> - {isLoadingCoinPacks && !coinPacks.length ? ( - - - - ) : null} - - {!isLoadingCoinPacks && coinPacks.length === 0 ? ( - - Aucun pack de pièces disponible pour le moment. - - ) : null} - - - {coinPacks.map((pack) => ( - - ))} - - - {isLoadingCoinPacks && coinPacks.length > 0 ? ( - - - - ) : null} - - ) : ( - <> - {isLoadingPlans && !currentPlans.length ? ( - - - - ) : null} - - {!isLoadingPlans && currentPlans.length === 0 ? ( - - Aucun abonnement Stripe disponible pour le moment. - - ) : null} - - - {PLAN_SEGMENTS.map(({ key, label }) => { - const isActive = billingPeriod === key; - return ( - setBillingPeriod(key)} - style={[ - styles.segmentButton, - isActive && styles.segmentButtonActive, - ]} - accessibilityRole="button" - accessibilityState={{ selected: isActive }} - > - - {label} - - - ); - })} - - - - {currentPlans.map((plan) => ( - - ))} - - - {isLoadingPlans && currentPlans.length > 0 ? ( - - - - ) : null} - - )} - - {isCoinMode - ? "Les pièces sont créditées immédiatement après le paiement. Pense à conserver ton reçu Stripe pour référence." - : "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)."} + 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). @@ -878,30 +626,6 @@ const styles = StyleSheet.create({ marginTop: 12, 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: { paddingVertical: 8, paddingHorizontal: 18, @@ -961,44 +685,6 @@ const styles = StyleSheet.create({ cardContent: { 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: { gap: 6, }, diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js index db8a8cf..9ad4044 100644 --- a/src/screens/Studio/ComposeSong.js +++ b/src/screens/Studio/ComposeSong.js @@ -1,29 +1,42 @@ 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 { background, icons } from "../../assets"; +import BorderGradientButton from "../../components/BorderGradientButton"; import GradientButton from "../../components/GradientButton"; 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 { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; 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 { openCoinPackModal } from "../../utils/coinPackModal"; import ChooseGenre from "./ChooseGenre"; import ChooseInstruments from "./ChooseInstruments"; import ChooseRhythm from "./ChooseRhythm"; import CustomizeVoice from "./CustomizeVoice"; +import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; const { width } = Dimensions.get("window"); +const MUSIC_GENERATION_COIN_COST = 8; +const CONFIRM_MODAL_MAX_WIDTH = 540; const ComposeSong = () => { const scrollRef = useRef(null); const [selectedIndex, setSelectedIndex] = useState(0); const [progress, setProgress] = useState(18); const [containerLayout, setContainerLayout] = useState(null); - const { selectedProjectId, selectedProject, updateProjectData } = useUser(); + const { + selectedProjectId, + selectedProject, + updateProjectData, + currentUserData, + currentUID, + } = useUser(); // Selections state const [genres, setGenres] = useState([]); @@ -32,6 +45,45 @@ const ComposeSong = () => { const [instruments, setInstruments] = useState([]); 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(() => { switch (selectedIndex) { case 0: @@ -76,34 +128,85 @@ const ComposeSong = () => { }; }, [selectedProject, genres, voice, instruments, rhythm, selectedProjectId]); - const onPressNext = async () => { - if (selectedIndex === 3) { - try { - // Persist config on the selected selectedProject so GeneratingSong can pick it up - if (selectedProjectId) { - await updateProjectData({ - musicConfig: { - title: musicConfig?.title || "", - lyrics: Array.isArray(musicConfig?.lyrics) - ? musicConfig.lyrics - : [], - genres: Array.isArray(musicConfig?.genres) - ? musicConfig.genres - : [], - voice: Array.isArray(musicConfig?.voice) ? musicConfig.voice : [], - instruments: Array.isArray(musicConfig?.instruments) - ? musicConfig.instruments - : [], - tempo: musicConfig?.tempo || "", - }, - musicStatus: null, - sunoTaskId: firebase.firestore.FieldValue.delete(), - musicUrls: firebase.firestore.FieldValue.delete(), - }); - } - } catch (e) {} - // Also pass the config to the screen to avoid any race condition + const persistMusicConfig = async () => { + try { + if (!selectedProjectId) return; + await updateProjectData({ + musicConfig: { + title: musicConfig?.title || "", + lyrics: Array.isArray(musicConfig?.lyrics) ? musicConfig.lyrics : [], + genres: Array.isArray(musicConfig?.genres) ? musicConfig.genres : [], + voice: Array.isArray(musicConfig?.voice) ? musicConfig.voice : [], + instruments: Array.isArray(musicConfig?.instruments) + ? musicConfig.instruments + : [], + tempo: musicConfig?.tempo || "", + }, + musicStatus: null, + sunoTaskId: firebase.firestore.FieldValue.delete(), + musicUrls: firebase.firestore.FieldValue.delete(), + }); + } catch (e) {} + }; + + 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 }); + } 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; } setSelectedIndex(selectedIndex + 1); @@ -193,6 +296,94 @@ const ComposeSong = () => { /> )} + { + if (isProcessingConfirmation) return; + setIsConfirmVisible(false); + }} + > + + + + + + Générer la musique ? + + + Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces. + {"\n"}Souhaites-tu les utiliser pour lancer la génération ? + + + Solde disponible : {formattedCoinBalance} pièces + + + + + { + if (isProcessingConfirmation) return; + setIsConfirmVisible(false); + }} + disabled={isProcessingConfirmation} + /> + + + + + ); }; diff --git a/src/screens/Studio/ComposeSong.web.js b/src/screens/Studio/ComposeSong.web.js index 7244fae..23a039c 100644 --- a/src/screens/Studio/ComposeSong.web.js +++ b/src/screens/Studio/ComposeSong.web.js @@ -5,23 +5,30 @@ import React, { useRef, useState, } from "react"; -import { Dimensions, FlatList, View } from "react-native"; +import { Dimensions, FlatList, Modal, Text, View } from "react-native"; import { background } from "../../assets"; +import BorderGradientButton from "../../components/BorderGradientButton"; import GradientButton from "../../components/GradientButton"; 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 { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; 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 { openCoinPackModal } from "../../utils/coinPackModal"; import ChooseGenre from "./ChooseGenre"; import ChooseInstruments from "./ChooseInstruments"; import ChooseRhythm from "./ChooseRhythm"; import CustomizeVoice from "./CustomizeVoice"; +import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; const { width: windowWidth } = Dimensions.get("window"); +const MUSIC_GENERATION_COIN_COST = 8; +const CONFIRM_MODAL_MAX_WIDTH = 540; const ComposeSong = () => { const scrollRef = useRef(null); @@ -29,12 +36,56 @@ const ComposeSong = () => { const [progress, setProgress] = useState(18); const [parentLayout, setParentLayout] = useState(null); const containerWidth = parentLayout?.width || windowWidth || 1; - const { selectedProjectId, selectedProject, updateProjectData } = useUser(); + const { + selectedProjectId, + selectedProject, + updateProjectData, + currentUserData, + currentUID, + } = useUser(); const [genres, setGenres] = useState([]); const [voice, setVoice] = useState({}); const [instruments, setInstruments] = useState([]); 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(() => { switch (selectedIndex) { @@ -132,32 +183,91 @@ const ComposeSong = () => { [containerWidth] ); - const onPressNext = async () => { - if (selectedIndex === steps.length - 1) { - try { - if (selectedProjectId) { - await updateProjectData({ - musicConfig: { - title: musicConfig?.title || "", - lyrics: Array.isArray(musicConfig?.lyrics) - ? musicConfig.lyrics - : [], - genres: Array.isArray(musicConfig?.genres) - ? musicConfig.genres - : [], - voice: Array.isArray(musicConfig?.voice) ? musicConfig.voice : [], - instruments: Array.isArray(musicConfig?.instruments) - ? musicConfig.instruments - : [], - tempo: musicConfig?.tempo || "", - }, - musicStatus: null, - sunoTaskId: firebase.firestore.FieldValue.delete(), - musicUrls: firebase.firestore.FieldValue.delete(), - }); - } - } catch (e) {} + const persistMusicConfig = useCallback(async () => { + try { + if (!selectedProjectId) return; + await updateProjectData({ + musicConfig: { + title: musicConfig?.title || "", + lyrics: Array.isArray(musicConfig?.lyrics) ? musicConfig.lyrics : [], + genres: Array.isArray(musicConfig?.genres) ? musicConfig.genres : [], + voice: Array.isArray(musicConfig?.voice) ? musicConfig.voice : [], + instruments: Array.isArray(musicConfig?.instruments) + ? musicConfig.instruments + : [], + tempo: musicConfig?.tempo || "", + }, + musicStatus: null, + sunoTaskId: 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."); + } + + 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 }); + } 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; } setSelectedIndex((prev) => Math.min(prev + 1, steps.length - 1)); @@ -215,6 +325,94 @@ const ComposeSong = () => { /> )} + { + if (isProcessingConfirmation) return; + setIsConfirmVisible(false); + }} + > + + + + + + Générer la musique ? + + + Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces. + {"\n"}Souhaites-tu les utiliser pour lancer la génération ? + + + Solde disponible : {formattedCoinBalance} pièces + + + + + { + if (isProcessingConfirmation) return; + setIsConfirmVisible(false); + }} + disabled={isProcessingConfirmation} + /> + + + + + ); }; diff --git a/src/screens/Writing/WritingLyrics.js b/src/screens/Writing/WritingLyrics.js index c7dad1e..1eb9c25 100644 --- a/src/screens/Writing/WritingLyrics.js +++ b/src/screens/Writing/WritingLyrics.js @@ -1,8 +1,7 @@ import React from "react"; import { StyleSheet, Text, View } from "react-native"; import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; -import { background, icons } from "../../assets"; -import BorderGradientButton from "../../components/BorderGradientButton"; +import { background } from "../../assets"; import GradientButton from "../../components/GradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; import { strings } from "../../constants/strings"; @@ -14,16 +13,22 @@ import { useUserData } from "../../providers/UserDataProvider"; import { Palette, gutters } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; +const CTA_BUTTON_HEIGHT = 58; +const CTA_BUTTON_MAX_WIDTH = 520; + const WritingLyrics = () => { - const { createNewProject, selectedProject, updateProjectData } = - useUserData(); + const { + createNewProject, + selectedProjectId, + updateProjectData, + } = useUserData(); const { setIsLoading } = useMinuit(); const startWriting = React.useCallback(async () => { try { await setIsLoading(true); - if (selectedProject) { - updateProjectData({ hasLyrics: false }); + if (selectedProjectId) { + await updateProjectData({ hasLyrics: false }); setTimeout(() => navigate(Routes.CreateLyricsWithAi), 500); return; } @@ -40,7 +45,7 @@ const WritingLyrics = () => { }, [ createNewProject, navigate, - selectedProject, + selectedProjectId, setIsLoading, updateProjectData, ]); @@ -51,11 +56,7 @@ const WritingLyrics = () => { headerType="NONE" > {/* */} - + {/* @@ -68,18 +69,12 @@ const WritingLyrics = () => { - {strings.writing.onboarding.secondaryNote} @@ -127,6 +122,14 @@ const styles = StyleSheet.create({ ctaGroup: { gap: 12, }, + ctaButton: { + alignSelf: "center", + width: "100%", + maxWidth: CTA_BUTTON_MAX_WIDTH, + }, + ctaButtonText: { + fontFamily: FONT_FAMILY.InterSemiBold, + }, secondaryNote: { textAlign: "center", color: Palette.white, diff --git a/src/utils/coinPackModal.js b/src/utils/coinPackModal.js new file mode 100644 index 0000000..232a959 --- /dev/null +++ b/src/utils/coinPackModal.js @@ -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); + } + }); +};