Merge branch 'main' of gitlab.com:agenceminuit/musicland
This commit is contained in:
@@ -1,12 +1,8 @@
|
||||
export const strings = {
|
||||
writing: {
|
||||
steps: {
|
||||
contextTitle: "Étape 2: Quel est le contexte ?",
|
||||
contextSubtitle:
|
||||
"Ajoute quelques détails pour nous guider (ex.: anniversaire, équipe de sport ou d’entreprise…)",
|
||||
contextExamples:
|
||||
"Exemples : « Anniversaire de Clara – ton joyeux et pop », « Hymne d’équipe – style rap énergique », « Esprit d’entreprise – ambiance motivante »",
|
||||
emotionTitle: "Étape 3: Quelle émotion veux-tu transmettre ?",
|
||||
contextTitle: "Quel est le contexte ?",
|
||||
emotionTitle: "Quelle émotion veux-tu transmettre ?",
|
||||
emotionSubtitle: "Sélectionne une seule intention émotionnelle.",
|
||||
},
|
||||
labels: {
|
||||
|
||||
+9
-20
@@ -49,29 +49,18 @@ export default ({
|
||||
const { currentUID = null, currentUserData = null } = useUserData?.() || {};
|
||||
|
||||
const coinBalance = React.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;
|
||||
}
|
||||
const value = currentUserData?.coins;
|
||||
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]);
|
||||
}, [currentUserData?.coins]);
|
||||
|
||||
const formattedCoins = React.useMemo(() => {
|
||||
try {
|
||||
|
||||
@@ -37,6 +37,7 @@ import EditProfile from "../screens/Profile/EditProfile";
|
||||
import Follows from "../screens/Profile/Follows";
|
||||
import Language from "../screens/Profile/Language";
|
||||
import Notifications from "../screens/Profile/Notifications";
|
||||
import OrderHistory from "../screens/Profile/OrderHistory";
|
||||
import Profile from "../screens/Profile/Profile";
|
||||
import Reels from "../screens/Profile/Reels";
|
||||
import Settings from "../screens/Profile/Settings";
|
||||
@@ -271,6 +272,11 @@ const baseScreens = [
|
||||
name: Routes.Settings,
|
||||
component: Settings,
|
||||
},
|
||||
{
|
||||
name: Routes.OrderHistory,
|
||||
component: OrderHistory,
|
||||
title: "Historique de commandes",
|
||||
},
|
||||
{
|
||||
name: Routes.Follows,
|
||||
component: Follows,
|
||||
|
||||
@@ -76,6 +76,7 @@ export const Routes = {
|
||||
SingerProfile: "SingerProfile",
|
||||
EditProfile: "EditProfile",
|
||||
Settings: "Settings",
|
||||
OrderHistory: "OrderHistory",
|
||||
ChangeEmailAddress: "ChangeEmailAddress",
|
||||
ChangePassword: "ChangePassword",
|
||||
Notifications: "Notifications",
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import firebase from "../../config/firebase";
|
||||
import { background } from "../../assets";
|
||||
import Page from "../../layouts/Page";
|
||||
import { useUser } from "../../providers/UserDataProvider";
|
||||
import { gutters, Palette } from "../../styles";
|
||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||
|
||||
const ORDER_TYPE_LABELS = {
|
||||
GIFT: "Crédit offert",
|
||||
COINS: "Achat de coins",
|
||||
SONG: "Génération de musique",
|
||||
};
|
||||
|
||||
const STATUS_LABELS = {
|
||||
PENDING: "En cours",
|
||||
APPLIED: "Confirmée",
|
||||
REJECTED: "Refusée",
|
||||
};
|
||||
|
||||
const formatCoins = (amount) => {
|
||||
if (typeof amount !== "number" || !Number.isFinite(amount)) {
|
||||
return "0";
|
||||
}
|
||||
try {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
} catch (_error) {
|
||||
return `${amount}`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) {
|
||||
return "En attente de confirmation";
|
||||
}
|
||||
try {
|
||||
return date.toLocaleString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch (_error) {
|
||||
return date.toString();
|
||||
}
|
||||
};
|
||||
|
||||
const mapOrderType = (type) => ORDER_TYPE_LABELS[type] || "Opération";
|
||||
|
||||
const mapStatus = (status) => STATUS_LABELS[status] || "Inconnue";
|
||||
|
||||
const OrderHistory = () => {
|
||||
const { currentUID } = useUser() || {};
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUID) {
|
||||
setOrders([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const unsubscribe = firebase
|
||||
.firestore()
|
||||
.collection("orders")
|
||||
.where("userId", "==", currentUID)
|
||||
.orderBy("createdAt", "desc")
|
||||
.onSnapshot(
|
||||
(snapshot) => {
|
||||
const nextOrders = snapshot.docs.map((doc) => {
|
||||
const rawData = doc.data() || {};
|
||||
const createdAt =
|
||||
typeof rawData.createdAt?.toDate === "function"
|
||||
? rawData.createdAt.toDate()
|
||||
: null;
|
||||
return {
|
||||
id: doc.id,
|
||||
...rawData,
|
||||
createdAt,
|
||||
};
|
||||
});
|
||||
|
||||
setOrders(nextOrders);
|
||||
setLoading(false);
|
||||
},
|
||||
(firestoreError) => {
|
||||
console.warn(
|
||||
"[OrderHistory] Unable to load orders",
|
||||
firestoreError?.message || firestoreError,
|
||||
);
|
||||
setError(
|
||||
firestoreError?.message ||
|
||||
"Impossible de récupérer l'historique des commandes.",
|
||||
);
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (typeof unsubscribe === "function") {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [currentUID]);
|
||||
|
||||
const renderOrder = useCallback(({ item }) => {
|
||||
const amountValue =
|
||||
typeof item.amount === "number" && Number.isFinite(item.amount)
|
||||
? item.amount
|
||||
: 0;
|
||||
const isPositive = amountValue > 0;
|
||||
const amountLabel = `${isPositive ? "+" : ""}${formatCoins(amountValue)} ${
|
||||
amountValue === 1 ? "coin" : "coins"
|
||||
}`;
|
||||
|
||||
const status = item.status || "PENDING";
|
||||
|
||||
const details = (() => {
|
||||
if (item.type === "GIFT" && item.metadata?.reason) {
|
||||
switch (item.metadata.reason) {
|
||||
case "WELCOME_BONUS":
|
||||
return "Crédit de bienvenue";
|
||||
default:
|
||||
return item.metadata.reason;
|
||||
}
|
||||
}
|
||||
if (item.type === "SONG" && item.songId) {
|
||||
return `Musique ${item.songId}`;
|
||||
}
|
||||
if (item.type === "COINS" && item.metadata?.paymentId) {
|
||||
return `Paiement ${item.metadata.paymentId}`;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<View style={styles.orderCard}>
|
||||
<View style={styles.orderHeader}>
|
||||
<Text style={styles.orderTitle}>{mapOrderType(item.type)}</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.orderAmount,
|
||||
isPositive ? styles.amountPositive : styles.amountNegative,
|
||||
]}
|
||||
>
|
||||
{amountLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.orderMetaRow}>
|
||||
<Text style={styles.orderDate}>{formatDate(item.createdAt)}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
status === "APPLIED"
|
||||
? styles.statusApplied
|
||||
: status === "REJECTED"
|
||||
? styles.statusRejected
|
||||
: styles.statusPending,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.statusText}>{mapStatus(status)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{details ? <Text style={styles.orderDetails}>{details}</Text> : null}
|
||||
{typeof item.balanceAfter === "number" &&
|
||||
Number.isFinite(item.balanceAfter) ? (
|
||||
<Text style={styles.orderBalance}>
|
||||
Solde après opération: {formatCoins(item.balanceAfter)} coins
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const keyExtractor = useCallback((item) => item.id, []);
|
||||
|
||||
const listEmptyComponent = useMemo(() => {
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
if (!currentUID) {
|
||||
return (
|
||||
<Text style={styles.emptyText}>
|
||||
Connecte-toi pour consulter l'historique de tes commandes.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return <Text style={styles.errorText}>{error}</Text>;
|
||||
}
|
||||
return (
|
||||
<Text style={styles.emptyText}>
|
||||
Aucune opération de coins pour le moment.
|
||||
</Text>
|
||||
);
|
||||
}, [loading, currentUID, error]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
headerType="NAVIGATION"
|
||||
title="Historique de commandes"
|
||||
backgroundImg={background.profileBG}
|
||||
contentContainerStyle={{ paddingBottom: gutters * 2 }}
|
||||
containerStyle={{
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
}}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{loading ? (
|
||||
<View style={styles.loaderContainer}>
|
||||
<ActivityIndicator color={Palette.white} size="large" />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={orders}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderOrder}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
orders.length === 0 && styles.emptyListContent,
|
||||
]}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
|
||||
ListEmptyComponent={listEmptyComponent}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingHorizontal: gutters,
|
||||
paddingTop: gutters,
|
||||
},
|
||||
loaderContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: gutters * 2,
|
||||
},
|
||||
emptyListContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
},
|
||||
orderCard: {
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
backgroundColor: Palette.ultraLightWhite,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.08)",
|
||||
},
|
||||
orderHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 8,
|
||||
gap: 12,
|
||||
},
|
||||
orderTitle: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
color: Palette.white,
|
||||
},
|
||||
orderAmount: {
|
||||
fontSize: 16,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
},
|
||||
amountPositive: {
|
||||
color: Palette.green,
|
||||
},
|
||||
amountNegative: {
|
||||
color: Palette.red,
|
||||
},
|
||||
orderMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 6,
|
||||
gap: 12,
|
||||
},
|
||||
orderDate: {
|
||||
fontSize: 13,
|
||||
color: Palette.grayMid,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
},
|
||||
statusApplied: {
|
||||
backgroundColor: Palette.transparentGreen,
|
||||
},
|
||||
statusPending: {
|
||||
backgroundColor: Palette.transparentOrange,
|
||||
},
|
||||
statusRejected: {
|
||||
backgroundColor: Palette.transparentRed,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 12,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
color: Palette.white,
|
||||
},
|
||||
orderDetails: {
|
||||
marginBottom: 6,
|
||||
fontSize: 14,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
color: Palette.white,
|
||||
},
|
||||
orderBalance: {
|
||||
fontSize: 13,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
color: Palette.grayMid,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: Palette.grayMid,
|
||||
fontSize: 15,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
},
|
||||
errorText: {
|
||||
textAlign: "center",
|
||||
color: Palette.red,
|
||||
fontSize: 15,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
},
|
||||
});
|
||||
|
||||
export default OrderHistory;
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||
import { useGlobal } from "reactn";
|
||||
import { background, icons } from "../../assets";
|
||||
@@ -33,7 +34,7 @@ import useLayoutType from "../../hooks/useLayoutType";
|
||||
import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails";
|
||||
import Page from "../../layouts/Page";
|
||||
import { Routes } from "../../navigation";
|
||||
import { push } from "../../navigation/NavigationService";
|
||||
import { navigate, push } from "../../navigation/NavigationService";
|
||||
import { useUser } from "../../providers/UserDataProvider";
|
||||
import { Palette } from "../../styles";
|
||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||
@@ -83,6 +84,9 @@ const Profile = () => {
|
||||
const onPressMenu = (item) => {
|
||||
setSelected(item);
|
||||
};
|
||||
const handleOpenSettings = () => {
|
||||
navigate(Routes.Settings);
|
||||
};
|
||||
|
||||
// Chargement des données utilisateur si on consulte un autre profil
|
||||
useEffect(() => {
|
||||
@@ -340,8 +344,8 @@ const Profile = () => {
|
||||
}}
|
||||
rightComponent={() =>
|
||||
!isWeb && isSelf ? (
|
||||
<PressableScale onPress={() => SheetManager.show("ProfileSettings")}>
|
||||
<Image source={icons.more} style={{ ...size({ size: 24 }) }} />
|
||||
<PressableScale onPress={handleOpenSettings}>
|
||||
<Feather name="settings" size={24} color={Palette.white} />
|
||||
</PressableScale>
|
||||
) : null
|
||||
}
|
||||
@@ -363,18 +367,13 @@ const Profile = () => {
|
||||
<PressableScale
|
||||
style={{
|
||||
zIndex: 2,
|
||||
position: "absolute",
|
||||
right: 10,
|
||||
top: 10,
|
||||
}}
|
||||
onPress={() => SheetManager.show("ProfileSettings")}
|
||||
onPress={handleOpenSettings}
|
||||
>
|
||||
<Image
|
||||
source={icons.more}
|
||||
style={{
|
||||
...size({ size: 24 }),
|
||||
position: "absolute",
|
||||
right: 10,
|
||||
top: 10,
|
||||
}}
|
||||
/>
|
||||
<Feather name="settings" size={24} color={Palette.white} />
|
||||
</PressableScale>
|
||||
)}
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ const SETTINGS = [
|
||||
navigate(Routes.Language);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Historique de commandes",
|
||||
action: () => {
|
||||
navigate(Routes.OrderHistory);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Découvrir les packs",
|
||||
action: () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import BorderGradientButton from "../../components/BorderGradientButton";
|
||||
import GradientButton from "../../components/GradientButton";
|
||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||
import AppAlert from "../../components/Alert";
|
||||
import firebase, { usersRef } from "../../config/firebase";
|
||||
import firebase, { getFunctionsClient } from "../../config/firebase";
|
||||
import Page from "../../layouts/Page";
|
||||
import { Routes } from "../../navigation";
|
||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
||||
@@ -50,29 +50,18 @@ const ComposeSong = () => {
|
||||
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;
|
||||
}
|
||||
const value = currentUserData?.coins;
|
||||
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]);
|
||||
}, [currentUserData?.coins]);
|
||||
|
||||
const formattedCoinBalance = useMemo(() => {
|
||||
try {
|
||||
@@ -154,24 +143,29 @@ const ComposeSong = () => {
|
||||
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();
|
||||
try {
|
||||
const functionsClient = getFunctionsClient();
|
||||
const createSongOrder =
|
||||
functionsClient.httpsCallable("orders-createSongOrder");
|
||||
|
||||
await usersRef.doc(currentUID).set(
|
||||
{
|
||||
coins: decrement,
|
||||
coinBalance: decrement,
|
||||
wallet: {
|
||||
coins: decrement,
|
||||
coinBalance: decrement,
|
||||
},
|
||||
lastCoinSpendAt: timestamp,
|
||||
lastCoinSpendAmount: MUSIC_GENERATION_COIN_COST,
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
await createSongOrder({
|
||||
amount: -MUSIC_GENERATION_COIN_COST,
|
||||
songId: selectedProjectId || null,
|
||||
source: "music_generation",
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
typeof error?.message === "string"
|
||||
? error.message.replace(
|
||||
/^functions\.https\.HttpsError:\s*/iu,
|
||||
"",
|
||||
)
|
||||
: null;
|
||||
throw new Error(
|
||||
message ||
|
||||
"Une erreur est survenue lors de la création de la commande de crédits.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmGeneration = async () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import BorderGradientButton from "../../components/BorderGradientButton";
|
||||
import GradientButton from "../../components/GradientButton";
|
||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||
import AppAlert from "../../components/Alert";
|
||||
import firebase, { usersRef } from "../../config/firebase";
|
||||
import firebase, { getFunctionsClient } from "../../config/firebase";
|
||||
import Page from "../../layouts/Page";
|
||||
import { Routes } from "../../navigation";
|
||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
||||
@@ -53,29 +53,18 @@ const ComposeSong = () => {
|
||||
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;
|
||||
}
|
||||
const value = currentUserData?.coins;
|
||||
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]);
|
||||
}, [currentUserData?.coins]);
|
||||
|
||||
const formattedCoinBalance = useMemo(() => {
|
||||
try {
|
||||
@@ -209,25 +198,30 @@ const ComposeSong = () => {
|
||||
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();
|
||||
try {
|
||||
const functionsClient = getFunctionsClient();
|
||||
const createSongOrder =
|
||||
functionsClient.httpsCallable("orders-createSongOrder");
|
||||
|
||||
await usersRef.doc(currentUID).set(
|
||||
{
|
||||
coins: decrement,
|
||||
coinBalance: decrement,
|
||||
wallet: {
|
||||
coins: decrement,
|
||||
coinBalance: decrement,
|
||||
},
|
||||
lastCoinSpendAt: timestamp,
|
||||
lastCoinSpendAmount: MUSIC_GENERATION_COIN_COST,
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
}, [currentUID]);
|
||||
await createSongOrder({
|
||||
amount: -MUSIC_GENERATION_COIN_COST,
|
||||
songId: selectedProjectId || null,
|
||||
source: "music_generation",
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
typeof error?.message === "string"
|
||||
? error.message.replace(
|
||||
/^functions\.https\.HttpsError:\s*/iu,
|
||||
"",
|
||||
)
|
||||
: null;
|
||||
throw new Error(
|
||||
message ||
|
||||
"Une erreur est survenue lors de la création de la commande de crédits.",
|
||||
);
|
||||
}
|
||||
}, [currentUID, selectedProjectId]);
|
||||
|
||||
const handleConfirmGeneration = useCallback(async () => {
|
||||
if (isProcessingConfirmation) return;
|
||||
|
||||
+212
-35
@@ -1,7 +1,16 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import {
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { BaseButton } from "../../components/Button";
|
||||
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
||||
import ListSelection from "../../components/ListSelection/ListSelection";
|
||||
import Overlay from "../../components/Overlay";
|
||||
import { GOALS, OTHER_OBJECTIVE_OPTION } from "../../data/data";
|
||||
import { strings } from "../../constants/strings";
|
||||
import { Palette } from "../../styles";
|
||||
@@ -15,10 +24,22 @@ const Goals = ({
|
||||
otherObjective,
|
||||
setOtherObjective,
|
||||
}) => {
|
||||
const normalizedOtherObjective =
|
||||
typeof otherObjective === "string" ? otherObjective : "";
|
||||
|
||||
const [internalSelected, setInternalSelected] = useState(null);
|
||||
const [otherObjectiveModalVisible, setOtherObjectiveModalVisible] =
|
||||
useState(false);
|
||||
const [previousSelection, setPreviousSelection] = useState(null);
|
||||
const [previousOtherObjective, setPreviousOtherObjective] = useState(
|
||||
normalizedOtherObjective
|
||||
);
|
||||
|
||||
const selected = selectedProp ?? internalSelected;
|
||||
const setSelected = setSelectedProp ?? setInternalSelected;
|
||||
const setSelectedBase = setSelectedProp ?? setInternalSelected;
|
||||
|
||||
const trimmedOtherObjective = normalizedOtherObjective.trim();
|
||||
const hasOtherObjectiveValue = trimmedOtherObjective.length > 0;
|
||||
|
||||
const goalsOptions = useMemo(() => GOALS ?? [], []);
|
||||
|
||||
@@ -27,47 +48,145 @@ const Goals = ({
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (trimmed.length) {
|
||||
if (selected !== OTHER_OBJECTIVE_OPTION) {
|
||||
setSelected(OTHER_OBJECTIVE_OPTION);
|
||||
setSelectedBase(OTHER_OBJECTIVE_OPTION);
|
||||
}
|
||||
} else if (selected === OTHER_OBJECTIVE_OPTION) {
|
||||
setSelected(null);
|
||||
setSelectedBase(null);
|
||||
setPreviousSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Selection handled by ListSelection
|
||||
const handleGoalSelect = (value) => {
|
||||
if (value === OTHER_OBJECTIVE_OPTION) {
|
||||
setPreviousSelection(selected ?? null);
|
||||
setPreviousOtherObjective(normalizedOtherObjective);
|
||||
setSelectedBase(value);
|
||||
setOtherObjectiveModalVisible(true);
|
||||
return;
|
||||
}
|
||||
setSelectedBase(value);
|
||||
setOtherObjectiveModalVisible(false);
|
||||
setPreviousSelection(null);
|
||||
};
|
||||
|
||||
const handleOpenOtherObjectiveModal = () => {
|
||||
setPreviousSelection(selected ?? null);
|
||||
setPreviousOtherObjective(normalizedOtherObjective);
|
||||
setOtherObjectiveModalVisible(true);
|
||||
};
|
||||
|
||||
const handleCancelOtherObjective = () => {
|
||||
setOtherObjectiveModalVisible(false);
|
||||
setOtherObjective?.(previousOtherObjective);
|
||||
if (selected === OTHER_OBJECTIVE_OPTION) {
|
||||
setSelectedBase(previousSelection ?? null);
|
||||
}
|
||||
setPreviousSelection(null);
|
||||
};
|
||||
|
||||
const handleConfirmOtherObjective = () => {
|
||||
if (!hasOtherObjectiveValue) {
|
||||
const fallback =
|
||||
previousSelection && previousSelection !== OTHER_OBJECTIVE_OPTION
|
||||
? previousSelection
|
||||
: null;
|
||||
setOtherObjective?.(previousOtherObjective);
|
||||
setSelectedBase(fallback);
|
||||
setOtherObjectiveModalVisible(false);
|
||||
setPreviousSelection(null);
|
||||
return;
|
||||
}
|
||||
if (selected !== OTHER_OBJECTIVE_OPTION) {
|
||||
setSelectedBase(OTHER_OBJECTIVE_OPTION);
|
||||
}
|
||||
setOtherObjectiveModalVisible(false);
|
||||
setPreviousSelection(OTHER_OBJECTIVE_OPTION);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ flex: 1, gap: 16, marginTop: 16 }}>
|
||||
<View style={{ gap: 10 }}>
|
||||
<CreateLyricsHeader
|
||||
title={strings.writing.steps.contextTitle}
|
||||
subTitle={strings.writing.steps.contextSubtitle}
|
||||
/>
|
||||
<View style={styles.examplesContainer}>
|
||||
<Text style={styles.examplesText}>
|
||||
{strings.writing.steps.contextExamples}
|
||||
</Text>
|
||||
</View>
|
||||
<ItemContainer>
|
||||
<ListSelection
|
||||
options={goalsOptions}
|
||||
variant="simple"
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
itemContainerStyle={styles.itemContainer}
|
||||
itemTextStyle={styles.itemText}
|
||||
<>
|
||||
<ScrollView contentContainerStyle={{ flex: 1, gap: 16, marginTop: 16 }}>
|
||||
<View style={{ gap: 10 }}>
|
||||
<CreateLyricsHeader
|
||||
title={strings.writing.steps.contextTitle}
|
||||
subTitle={strings.writing.steps.contextSubtitle}
|
||||
/>
|
||||
</ItemContainer>
|
||||
</View>
|
||||
<CustomInput
|
||||
label={strings.writing.labels.otherObjective}
|
||||
placeholder={strings.writing.labels.otherObjectivePlaceholder}
|
||||
value={otherObjective}
|
||||
setValue={handleOtherObjectiveChange}
|
||||
height={Platform.OS === "web" ? 160 : undefined}
|
||||
/>
|
||||
</ScrollView>
|
||||
<View style={styles.examplesContainer}>
|
||||
<Text style={styles.examplesText}>
|
||||
{strings.writing.steps.contextExamples}
|
||||
</Text>
|
||||
</View>
|
||||
<ItemContainer>
|
||||
<ListSelection
|
||||
options={goalsOptions}
|
||||
variant="simple"
|
||||
selected={selected}
|
||||
setSelected={handleGoalSelect}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
itemContainerStyle={styles.itemContainer}
|
||||
itemTextStyle={styles.itemText}
|
||||
/>
|
||||
</ItemContainer>
|
||||
</View>
|
||||
{selected === OTHER_OBJECTIVE_OPTION ? (
|
||||
<View style={styles.otherObjectiveSummary}>
|
||||
<View style={{ flex: 1, gap: 4 }}>
|
||||
<Text style={styles.summaryLabel}>
|
||||
{strings.writing.labels.otherObjective}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.summaryValue,
|
||||
!hasOtherObjectiveValue && styles.summaryPlaceholder,
|
||||
]}
|
||||
numberOfLines={3}
|
||||
>
|
||||
{hasOtherObjectiveValue
|
||||
? trimmedOtherObjective
|
||||
: strings.writing.labels.otherObjectivePlaceholder}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.summaryButton}
|
||||
onPress={handleOpenOtherObjectiveModal}
|
||||
>
|
||||
<Text style={styles.summaryButtonText}>
|
||||
{hasOtherObjectiveValue ? "Modifier" : "Ajouter"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
<Overlay
|
||||
isVisible={otherObjectiveModalVisible}
|
||||
setIsVisible={handleCancelOtherObjective}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{strings.writing.labels.otherObjective}
|
||||
</Text>
|
||||
<CustomInput
|
||||
placeholder={strings.writing.labels.otherObjectivePlaceholder}
|
||||
value={otherObjective}
|
||||
setValue={handleOtherObjectiveChange}
|
||||
height={Platform.OS === "web" ? 160 : undefined}
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<BaseButton
|
||||
type="secondary"
|
||||
text="Annuler"
|
||||
onPress={handleCancelOtherObjective}
|
||||
containerStyle={styles.modalActionButton}
|
||||
/>
|
||||
<BaseButton
|
||||
text="Valider"
|
||||
onPress={handleConfirmOtherObjective}
|
||||
containerStyle={styles.modalActionButton}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -112,4 +231,62 @@ const styles = StyleSheet.create({
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
lineHeight: 20,
|
||||
},
|
||||
otherObjectiveSummary: {
|
||||
backgroundColor: Palette.ultraLightWhite,
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
lineHeight: 20,
|
||||
},
|
||||
summaryPlaceholder: {
|
||||
color: Palette.grayMid,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
summaryButton: {
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.white,
|
||||
backgroundColor: "#FFFFFF10",
|
||||
},
|
||||
summaryButtonText: {
|
||||
fontSize: 12,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
},
|
||||
modalContent: {
|
||||
width: "90%",
|
||||
maxWidth: 420,
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
backgroundColor: Palette.darkPurple,
|
||||
gap: 16,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
textAlign: "center",
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
},
|
||||
modalActionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import CreateLyricsHeader from "./components/CreateLyricsHeader";
|
||||
import { OTHER_STYLE_OPTION, SONG_STYLE } from "../../data/data";
|
||||
@@ -7,6 +7,8 @@ import CustomInput from "./components/CustomInput";
|
||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
||||
import ListSelection from "../../components/ListSelection/ListSelection";
|
||||
import { BaseButton } from "../../components/Button";
|
||||
import Overlay from "../../components/Overlay";
|
||||
|
||||
const SongStyle = ({
|
||||
selected: selectedProp,
|
||||
@@ -14,9 +16,21 @@ const SongStyle = ({
|
||||
otherStyle,
|
||||
setOtherStyle,
|
||||
}) => {
|
||||
const normalizedOtherStyle =
|
||||
typeof otherStyle === "string" ? otherStyle : "";
|
||||
|
||||
const [internalSelected, setInternalSelected] = useState(null);
|
||||
const [otherStyleModalVisible, setOtherStyleModalVisible] = useState(false);
|
||||
const [previousSelection, setPreviousSelection] = useState(null);
|
||||
const [previousOtherStyle, setPreviousOtherStyle] = useState(
|
||||
normalizedOtherStyle
|
||||
);
|
||||
|
||||
const selected = selectedProp ?? internalSelected;
|
||||
const setSelected = setSelectedProp ?? setInternalSelected;
|
||||
const setSelectedBase = setSelectedProp ?? setInternalSelected;
|
||||
|
||||
const trimmedOtherStyle = normalizedOtherStyle.trim();
|
||||
const hasOtherStyleValue = trimmedOtherStyle.length > 0;
|
||||
|
||||
const songStyleOptions = useMemo(() => SONG_STYLE ?? [], []);
|
||||
|
||||
@@ -25,40 +39,137 @@ const SongStyle = ({
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (trimmed.length) {
|
||||
if (selected !== OTHER_STYLE_OPTION) {
|
||||
setSelected(OTHER_STYLE_OPTION);
|
||||
setSelectedBase(OTHER_STYLE_OPTION);
|
||||
}
|
||||
} else if (selected === OTHER_STYLE_OPTION) {
|
||||
setSelected(null);
|
||||
setSelectedBase(null);
|
||||
setPreviousSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Selection handled by ListSelection
|
||||
const handleStyleSelect = (value) => {
|
||||
if (value === OTHER_STYLE_OPTION) {
|
||||
setPreviousSelection(selected ?? null);
|
||||
setPreviousOtherStyle(normalizedOtherStyle);
|
||||
setSelectedBase(value);
|
||||
setOtherStyleModalVisible(true);
|
||||
return;
|
||||
}
|
||||
setSelectedBase(value);
|
||||
setOtherStyleModalVisible(false);
|
||||
setPreviousSelection(null);
|
||||
};
|
||||
|
||||
const handleOpenOtherStyleModal = () => {
|
||||
setPreviousSelection(selected ?? null);
|
||||
setPreviousOtherStyle(normalizedOtherStyle);
|
||||
setOtherStyleModalVisible(true);
|
||||
};
|
||||
|
||||
const handleCancelOtherStyle = () => {
|
||||
setOtherStyleModalVisible(false);
|
||||
setOtherStyle?.(previousOtherStyle);
|
||||
if (selected === OTHER_STYLE_OPTION) {
|
||||
setSelectedBase(previousSelection ?? null);
|
||||
}
|
||||
setPreviousSelection(null);
|
||||
};
|
||||
|
||||
const handleConfirmOtherStyle = () => {
|
||||
if (!hasOtherStyleValue) {
|
||||
const fallback =
|
||||
previousSelection && previousSelection !== OTHER_STYLE_OPTION
|
||||
? previousSelection
|
||||
: null;
|
||||
setOtherStyle?.(previousOtherStyle);
|
||||
setSelectedBase(fallback);
|
||||
setOtherStyleModalVisible(false);
|
||||
setPreviousSelection(null);
|
||||
return;
|
||||
}
|
||||
if (selected !== OTHER_STYLE_OPTION) {
|
||||
setSelectedBase(OTHER_STYLE_OPTION);
|
||||
}
|
||||
setOtherStyleModalVisible(false);
|
||||
setPreviousSelection(OTHER_STYLE_OPTION);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, gap: 16, marginTop: 16 }}>
|
||||
<View style={{ gap: 10 }}>
|
||||
<CreateLyricsHeader
|
||||
title={`Quel est le style de ta chanson ?`}
|
||||
subTitle="Choisis l'ambiance émotions que tu veux faire passer."
|
||||
/>
|
||||
<ItemContainer>
|
||||
<ListSelection
|
||||
options={songStyleOptions}
|
||||
variant="titleDescription"
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
itemContainerStyle={styles.itemContainer}
|
||||
<>
|
||||
<View style={{ flex: 1, gap: 16, marginTop: 16 }}>
|
||||
<View style={{ gap: 10 }}>
|
||||
<CreateLyricsHeader
|
||||
title={`Quel est le style de ta chanson ?`}
|
||||
subTitle="Choisis l'ambiance émotions que tu veux faire passer."
|
||||
/>
|
||||
</ItemContainer>
|
||||
<ItemContainer>
|
||||
<ListSelection
|
||||
options={songStyleOptions}
|
||||
variant="titleDescription"
|
||||
selected={selected}
|
||||
setSelected={handleStyleSelect}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
itemContainerStyle={styles.itemContainer}
|
||||
/>
|
||||
</ItemContainer>
|
||||
</View>
|
||||
{selected === OTHER_STYLE_OPTION ? (
|
||||
<View style={styles.otherStyleSummary}>
|
||||
<View style={{ flex: 1, gap: 4 }}>
|
||||
<Text style={styles.summaryLabel}>
|
||||
Tu as un autre style de chanson ?
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.summaryValue,
|
||||
!hasOtherStyleValue && styles.summaryPlaceholder,
|
||||
]}
|
||||
numberOfLines={3}
|
||||
>
|
||||
{hasOtherStyleValue ? trimmedOtherStyle : "Décrire le style"}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.summaryButton}
|
||||
onPress={handleOpenOtherStyleModal}
|
||||
>
|
||||
<Text style={styles.summaryButtonText}>
|
||||
{hasOtherStyleValue ? "Modifier" : "Ajouter"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<CustomInput
|
||||
label="Tu as un autre style de chanson ?"
|
||||
placeholder="Décrire le style"
|
||||
value={otherStyle}
|
||||
setValue={handleOtherStyleChange}
|
||||
/>
|
||||
</View>
|
||||
<Overlay
|
||||
isVisible={otherStyleModalVisible}
|
||||
setIsVisible={handleCancelOtherStyle}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>
|
||||
Tu as un autre style de chanson ?
|
||||
</Text>
|
||||
<CustomInput
|
||||
placeholder="Décrire le style"
|
||||
value={otherStyle}
|
||||
setValue={handleOtherStyleChange}
|
||||
height={Platform.OS === "web" ? 160 : undefined}
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<BaseButton
|
||||
type="secondary"
|
||||
text="Annuler"
|
||||
onPress={handleCancelOtherStyle}
|
||||
containerStyle={styles.modalActionButton}
|
||||
/>
|
||||
<BaseButton
|
||||
text="Valider"
|
||||
onPress={handleConfirmOtherStyle}
|
||||
containerStyle={styles.modalActionButton}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -90,4 +201,62 @@ const styles = StyleSheet.create({
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
textAlign: "center",
|
||||
},
|
||||
otherStyleSummary: {
|
||||
backgroundColor: Palette.ultraLightWhite,
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
lineHeight: 20,
|
||||
},
|
||||
summaryPlaceholder: {
|
||||
color: Palette.grayMid,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
summaryButton: {
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.white,
|
||||
backgroundColor: "#FFFFFF10",
|
||||
},
|
||||
summaryButtonText: {
|
||||
fontSize: 12,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
},
|
||||
modalContent: {
|
||||
width: "90%",
|
||||
maxWidth: 420,
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
backgroundColor: Palette.darkPurple,
|
||||
gap: 16,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
textAlign: "center",
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
},
|
||||
modalActionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user