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

This commit is contained in:
2025-11-05 11:24:34 +01:00
17 changed files with 1613 additions and 228 deletions
+2 -6
View File
@@ -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 dentreprise…)",
contextExamples:
"Exemples : « Anniversaire de Clara ton joyeux et pop », « Hymne d’équipe style rap énergique », « Esprit dentreprise 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
View File
@@ -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 {
+6
View File
@@ -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,
+1
View File
@@ -76,6 +76,7 @@ export const Routes = {
SingerProfile: "SingerProfile",
EditProfile: "EditProfile",
Settings: "Settings",
OrderHistory: "OrderHistory",
ChangeEmailAddress: "ChangeEmailAddress",
ChangePassword: "ChangePassword",
Notifications: "Notifications",
+351
View File
@@ -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;
+12 -13
View File
@@ -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>
)}
+6
View File
@@ -37,6 +37,12 @@ const SETTINGS = [
navigate(Routes.Language);
},
},
{
title: "Historique de commandes",
action: () => {
navigate(Routes.OrderHistory);
},
},
{
title: "Découvrir les packs",
action: () => {
+32 -38
View File
@@ -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 () => {
+33 -39
View File
@@ -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
View File
@@ -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,
},
});
+196 -27
View File
@@ -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,
},
});