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