Merge branch 'main' of gitlab.com:agenceminuit/musicland
This commit is contained in:
@@ -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 = ({
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
style={[
|
||||
{
|
||||
maxWidth: maxWidth ? maxWidth : null,
|
||||
...containerStyle,
|
||||
height: buttonHeight,
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
},
|
||||
containerStyle,
|
||||
]}
|
||||
>
|
||||
<BorderGradient
|
||||
gradientProps={{
|
||||
|
||||
@@ -29,31 +29,38 @@ const GradientButton = ({
|
||||
size = "medium",
|
||||
textStyle = {},
|
||||
gradientStyle = {},
|
||||
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];
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
...containerStyle,
|
||||
style={[
|
||||
{
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
maxWidth: maxWidth ? maxWidth : null,
|
||||
}}
|
||||
height: buttonHeight,
|
||||
},
|
||||
containerStyle,
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={colors}
|
||||
style={{
|
||||
style={[
|
||||
{
|
||||
...Style.containerCenter,
|
||||
...Style.containerRow,
|
||||
height: buttonHeight,
|
||||
borderRadius: 14,
|
||||
gap: 10,
|
||||
...gradientStyle,
|
||||
}}
|
||||
},
|
||||
gradientStyle,
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
{...props}
|
||||
|
||||
@@ -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
@@ -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 && <ConnectBtn />}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<CoinPackModal
|
||||
visible={isCoinModalVisible}
|
||||
onClose={handleCloseCoinModal}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
+46
-12
@@ -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;
|
||||
}
|
||||
}, [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 startDisabled = !songwriterAction?.route;
|
||||
|
||||
+14
-328
@@ -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 (
|
||||
<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 = {
|
||||
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 (
|
||||
<View style={styles.root}>
|
||||
<Page
|
||||
headerType="NAVIGATE"
|
||||
title={
|
||||
mode === COIN_PACK_MODE_KEY ? "Packs de pièces" : "Abonnements"
|
||||
}
|
||||
title="Abonnements"
|
||||
scrollEnabled={false}
|
||||
width={isWeb ? 960 : undefined}
|
||||
containerStyle={styles.page}
|
||||
@@ -636,38 +448,9 @@ export default function Payments() {
|
||||
/>
|
||||
|
||||
<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}>
|
||||
<Text style={styles.title}>
|
||||
{isCoinMode
|
||||
? "Recharge tes pièces MusicLand"
|
||||
: "Choisissez l’abonnement qui vous correspond"}
|
||||
Choisissez l’abonnement qui vous correspond
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -675,39 +458,6 @@ export default function Payments() {
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
) : 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 ? (
|
||||
<View style={styles.loaderContainer}>
|
||||
<ActivityIndicator color={Palette.white} />
|
||||
@@ -763,8 +513,6 @@ export default function Payments() {
|
||||
<ActivityIndicator color={Palette.white} size="small" />
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<GradientButton
|
||||
@@ -776,9 +524,9 @@ export default function Payments() {
|
||||
</View>
|
||||
|
||||
<Text style={styles.disclaimer}>
|
||||
{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).
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,20 +128,14 @@ const ComposeSong = () => {
|
||||
};
|
||||
}, [selectedProject, genres, voice, instruments, rhythm, selectedProjectId]);
|
||||
|
||||
const onPressNext = async () => {
|
||||
if (selectedIndex === 3) {
|
||||
const persistMusicConfig = async () => {
|
||||
try {
|
||||
// Persist config on the selected selectedProject so GeneratingSong can pick it up
|
||||
if (selectedProjectId) {
|
||||
if (!selectedProjectId) return;
|
||||
await updateProjectData({
|
||||
musicConfig: {
|
||||
title: musicConfig?.title || "",
|
||||
lyrics: Array.isArray(musicConfig?.lyrics)
|
||||
? musicConfig.lyrics
|
||||
: [],
|
||||
genres: Array.isArray(musicConfig?.genres)
|
||||
? musicConfig.genres
|
||||
: [],
|
||||
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
|
||||
@@ -100,10 +146,67 @@ const ComposeSong = () => {
|
||||
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 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 = () => {
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,19 +183,14 @@ const ComposeSong = () => {
|
||||
[containerWidth]
|
||||
);
|
||||
|
||||
const onPressNext = async () => {
|
||||
if (selectedIndex === steps.length - 1) {
|
||||
const persistMusicConfig = useCallback(async () => {
|
||||
try {
|
||||
if (selectedProjectId) {
|
||||
if (!selectedProjectId) return;
|
||||
await updateProjectData({
|
||||
musicConfig: {
|
||||
title: musicConfig?.title || "",
|
||||
lyrics: Array.isArray(musicConfig?.lyrics)
|
||||
? musicConfig.lyrics
|
||||
: [],
|
||||
genres: Array.isArray(musicConfig?.genres)
|
||||
? musicConfig.genres
|
||||
: [],
|
||||
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
|
||||
@@ -155,9 +201,73 @@ const ComposeSong = () => {
|
||||
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.");
|
||||
}
|
||||
} 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 });
|
||||
} 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 = () => {
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
{/* <Image source={ai.nathalie} style={styles.img} resizeMode="contain" /> */}
|
||||
<MusicLandHeader
|
||||
onPressBack={goBack}
|
||||
progress={9}
|
||||
logo={icons.musicLandWriting}
|
||||
/>
|
||||
<MusicLandHeader onPressBack={goBack} />
|
||||
<View style={styles.contentWrapper}>
|
||||
<View style={styles.heroContainer}>
|
||||
{/* <Text style={styles.heroTitle}>
|
||||
@@ -68,18 +69,12 @@ const WritingLyrics = () => {
|
||||
<View style={styles.ctaGroup}>
|
||||
<GradientButton
|
||||
size="large"
|
||||
maxWidth={520}
|
||||
containerStyle={{ alignSelf: "center", width: "100%" }}
|
||||
height={CTA_BUTTON_HEIGHT}
|
||||
maxWidth={CTA_BUTTON_MAX_WIDTH}
|
||||
containerStyle={styles.ctaButton}
|
||||
title={strings.writing.onboarding.primaryCta}
|
||||
onPress={startWriting}
|
||||
textStyle={{ fontFamily: FONT_FAMILY.InterSemiBold }}
|
||||
/>
|
||||
<BorderGradientButton
|
||||
size="small"
|
||||
title={strings.writing.onboarding.secondaryCta}
|
||||
disabled
|
||||
containerStyle={{ alignSelf: "center", width: "100%" }}
|
||||
maxWidth={360}
|
||||
textStyle={styles.ctaButtonText}
|
||||
/>
|
||||
<Text style={styles.secondaryNote}>
|
||||
{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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user