fix of the day, payment + sub + music generation

This commit is contained in:
Thomas Demirdjian
2025-11-17 14:27:15 +01:00
parent 97321cdbda
commit 93f2711574
20 changed files with 761 additions and 398 deletions
+5 -2
View File
@@ -9,7 +9,10 @@ exports.generatePicturePrompt = (project = {}) => {
const sanitizeInline = (value = "") => {
if (typeof value !== "string") return "";
return value.replace(/[\r\n]+/g, " ").replace(/[<>]/g, "").trim();
return value
.replace(/[\r\n]+/g, " ")
.replace(/[<>]/g, "")
.trim();
};
const titleForPrompt = sanitizeInline(title) || "Sans titre";
@@ -74,7 +77,7 @@ exports.generatePicturePrompt = (project = {}) => {
: "";
const prompt = `<BRIEF>
<OBJECTIF>Créer une pochette d'album en adéquation avec les paroles de la musique</OBJECTIF>
<OBJECTIF>Créer une pochette d'album en adéquation avec les paroles de la musique et qui respecte le style ${coverStyle}</OBJECTIF>
<TITRE>${titleForPrompt}</TITRE>
${artistLine} <STYLE_MUSICAL>${tagsLine}</STYLE_MUSICAL>
<EXTRAITS_PAROLES>${lyricsLine}</EXTRAITS_PAROLES>
+102 -13
View File
@@ -7,9 +7,12 @@ const axios = require("axios");
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const { logger } = require("firebase-functions/logger");
const { pipeline } = require("stream/promises");
const { randomUUID } = require("crypto");
const { ALERT_TYPE, refList } = require("../index");
const { sendNotification } = require("./notifications");
const { SUNO_API_KEY } = require("../config/keys");
const { createOrderDocument, ORDER_TYPES } = require("./helpers/orders");
const {
SUNO_MODEL,
SUNO_CALLBACK_URL,
@@ -18,6 +21,51 @@ const {
SUNO_STATUS_PATH,
} = require("../config/suno");
const MUSIC_GENERATION_CREDIT_COST = 8;
const MUSIC_REFUND_SOURCE = "music_generation_refund";
const refundMusicCredits = async ({
projectId,
userId,
reason = "music_generation_failed",
context = {},
}) => {
if (!projectId || !userId || MUSIC_GENERATION_CREDIT_COST <= 0) return null;
try {
const metadata = {
source: MUSIC_REFUND_SOURCE,
reason,
projectId,
...context,
};
const { orderId } = await createOrderDocument({
userId,
type: ORDER_TYPES.SONG,
amount: MUSIC_GENERATION_CREDIT_COST,
songId: projectId,
createdBy: "system",
metadata,
});
logger.log("💸 [Music] Crédits remboursés", {
projectId,
userId,
orderId,
});
return { orderId };
} catch (error) {
logger.error("❌ [Music] Échec remboursement crédits", {
projectId,
userId,
error: error?.message,
});
return null;
}
};
/**
* Marque un projet comme échoué suite à une erreur Suno
* @param {string} projectId - Identifiant du projet
@@ -42,21 +90,46 @@ async function markProjectMusicFailure(projectId, error) {
if (status) errorPayload.status = status;
if (error?.code) errorPayload.code = error.code;
await docRef.set(
{
const receiverId = sanitizeField(projectData?.userId);
const alreadyRefunded =
projectData?.musicCreditsRefunded === true ||
typeof projectData?.musicCreditsRefundOrderId === "string";
let refundResult = null;
if (receiverId && !alreadyRefunded) {
refundResult = await refundMusicCredits({
projectId,
userId: receiverId,
reason: sunoMessage,
context: {
status: status || null,
code: error?.code || null,
},
});
}
const updatePayload = {
musicStatus: "FAILED",
sunoTaskId: FieldValue.delete(),
generationStartAt: FieldValue.delete(),
musicError: errorPayload,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
};
if (refundResult?.orderId) {
updatePayload.musicCreditsRefunded = true;
updatePayload.musicCreditsRefundOrderId = refundResult.orderId;
updatePayload.musicCreditsRefundedAt = FieldValue.serverTimestamp();
}
await docRef.set(updatePayload, { merge: true });
const receiverId = sanitizeField(projectData?.userId);
if (receiverId) {
const projectTitle = sanitizeField(projectData?.title, "ton projet");
const message = `La génération de musique pour "${projectTitle}" a échoué.`;
const baseMessage = `La génération de musique pour "${projectTitle}" a échoué.`;
const message = refundResult?.orderId
? `${baseMessage} Tes crédits ont été remboursés.`
: baseMessage;
try {
await sendNotification({
sender: "SYSTEM",
@@ -267,12 +340,11 @@ const downloadTrackToStorage = async (
if (!url) return null;
try {
console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`);
const resp = await axios.get(url, { responseType: "arraybuffer" });
const buffer = Buffer.from(resp.data);
const resp = await axios.get(url, { responseType: "stream" });
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`;
const token = require("crypto").randomUUID();
const token = randomUUID();
const file = bucket.file(path);
await file.save(buffer, {
const writeStream = file.createWriteStream({
resumable: false,
metadata: {
contentType: "audio/mpeg",
@@ -280,6 +352,7 @@ const downloadTrackToStorage = async (
metadata: { firebaseStorageDownloadTokens: token },
},
});
await pipeline(resp.data, writeStream);
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
path,
)}?alt=media&token=${token}`;
@@ -681,7 +754,9 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
* Cette fonction est appelée par l'API Suno lorsque la génération
* de musique est terminée
*/
exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
exports.sunoCallback = onRequest(
{ methods: ["POST"], memory: "1GiB" },
async (req, res) => {
if (req.method !== "POST") {
console.warn("⚠️ [SunoCallback] Méthode non autorisée:", req.method);
return res.status(405).json({ error: "Méthode non autorisée" });
@@ -710,9 +785,12 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
return res.status(200).json({ success: true, ignored: true });
}
let projectIdForFailure = null;
try {
const { projectId, projectData, projectRef } =
await fetchProjectByTaskId(taskId);
projectIdForFailure = projectId;
const { userId, projectTitle } = formatProjectMeta(projectData);
const audioUrls = extractAudioUrlsFromTracks(tracks);
@@ -775,10 +853,21 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
} catch (error) {
const statusCode =
error?.message === "PROJECT_NOT_FOUND_FOR_TASK" ? 404 : 500;
if (statusCode !== 404 && projectIdForFailure) {
try {
await markProjectMusicFailure(projectIdForFailure, error);
} catch (markError) {
console.error(
"⚠️ [SunoCallback] Impossible de marquer le projet en échec:",
markError,
);
}
}
logger.error("❌ [SunoCallback] Erreur interne:", error);
return res.status(statusCode).json({
success: false,
error: error?.message || "Erreur interne du serveur",
});
}
});
},
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

+4 -2
View File
@@ -3,7 +3,7 @@ import React from "react";
import { Image, Pressable, Text, View } from "react-native";
import { Palette, Style } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { size } from "../styles/Style";
import { size as sizeStyle } from "../styles/Style";
import BorderGradient from "./BorderGradient/BorderGradient";
const HEIGHT_BY_SIZE = {
@@ -84,7 +84,9 @@ const BorderGradientButton = ({
gap: 11,
}}
>
{icon && <Image source={icon} style={size({ size: iconSize })} />}
{icon && (
<Image source={icon} style={sizeStyle({ size: iconSize })} />
)}
<Text
style={{
fontSize,
+16 -5
View File
@@ -8,23 +8,34 @@ const ProgressBar = ({
progress = 0,
containerStyle = {},
gradient = false,
status = "default",
}) => {
const numericProgress = Number(progress);
const normalizedProgress = Math.max(
0,
Math.min(100, Number.isFinite(numericProgress) ? numericProgress : 0),
);
const isError = status === "error";
const containerBackground = isError ? "#3C101B" : "#0F0C19";
const fillColor = isError ? "#FF6B6B" : Palette.white;
const shouldUseGradient = gradient && !isError;
return (
<View
style={{
width: "100%",
height: 5,
backgroundColor: "#0F0C19",
backgroundColor: containerBackground,
borderRadius: mainBorderRadius,
overflow: "hidden",
...containerStyle,
}}
>
{gradient ? (
{shouldUseGradient ? (
<LinearGradient
colors={["#F94697", "#7023F7"]}
style={{
width: `${progress}%`,
width: `${normalizedProgress}%`,
height: "100%",
borderRadius: mainBorderRadius,
}}
@@ -34,9 +45,9 @@ const ProgressBar = ({
) : (
<View
style={{
width: `${progress}%`,
width: `${normalizedProgress}%`,
height: "100%",
backgroundColor: Palette.white,
backgroundColor: fillColor,
borderRadius: mainBorderRadius,
}}
/>
+1 -1
View File
@@ -12,7 +12,7 @@ import { Platform } from "react-native";
const functionsInstances = {};
const emulatorConfigured = {};
const emulatorHost = Platform.OS === "web" ? "localhost" : "192.168.1.62";
const emulatorHost = Platform.OS === "web" ? "localhost" : "192.168.1.103";
const USE_FUNCTIONS_EMULATOR = false; // Toggle to route functions traffic to the local emulator.
const configureFunctionsEmulator = (instance, regionKey = "us-central1") => {
+1 -1
View File
@@ -15,7 +15,7 @@ export const GOOGLE_WEB_CLIENT_ID =
// iOS client ID (may need updating to match the iOS bundle for this project)
export const GOOGLE_IOS_CLIENT_ID =
"943006074419-h43c7p48nhn5f4td4oimv4dq7b1p3s29.apps.googleusercontent.com";
"305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk.apps.googleusercontent.com";
// Android client ID (client_type 1 from google-services.json)
export const GOOGLE_ANDROID_CLIENT_ID =
+18 -5
View File
@@ -1,5 +1,5 @@
/* eslint-disable react/display-name */
import { Image, Pressable, Text, View } from "react-native";
import { Image, Pressable, Text, View, StyleSheet } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { SafeAreaView } from "react-native-safe-area-context";
import React from "reactn";
@@ -168,6 +168,19 @@ export default ({
return <ShareBtn />;
})();
const resolvedContainerStyle = React.useMemo(
() => StyleSheet.flatten(containerStyle) || {},
[containerStyle],
);
const resolvedHeaderStyle = React.useMemo(
() => StyleSheet.flatten(headerStyle) || {},
[headerStyle],
);
const resolvedContentContainerStyle = React.useMemo(
() => StyleSheet.flatten(contentContainerStyle) || {},
[contentContainerStyle],
);
return (
<View
style={{
@@ -256,15 +269,15 @@ export default ({
width: computedWidth,
maxWidth: maxWidth ? maxWidth : null,
alignSelf: isWeb ? "center" : "auto",
...containerStyle,
...resolvedContainerStyle,
}}
>
{headerType !== "NONE" ? (
headerType === "BASE" ? (
<BaseHeader containerStyle={{ ...headerStyle }} />
<BaseHeader containerStyle={{ ...resolvedHeaderStyle }} />
) : (
<NavigateHeader
containerStyle={{ ...headerStyle }}
containerStyle={{ ...resolvedHeaderStyle }}
title={title}
rightComponent={rightComponent}
onBackPressed={onBackPressed}
@@ -277,7 +290,7 @@ export default ({
{topStickyContent?.()}
<ContentContainer
style={{ flex: 1, ...contentContainerStyle }}
style={{ flex: 1, ...resolvedContentContainerStyle }}
{...(scrollEnabled
? {
scrollEventThrottle: 80,
+8
View File
@@ -75,7 +75,15 @@ const StripeProvider = ({ children }) => {
if (isWeb) {
if (typeof window !== "undefined") {
const openedTab = window.open(
checkoutUrl,
"_blank",
"noopener,noreferrer",
);
// Fallback to same-tab navigation if the popup is blocked.
if (!openedTab) {
window.location.assign(checkoutUrl);
}
return;
}
throw new Error("Navigation Stripe impossible dans cet environnement.");
+7 -13
View File
@@ -75,7 +75,8 @@ function SubscriptionCard({ plan, selected, onSelect }) {
const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency);
const intervalLabel = getIntervalLabel(plan?.recurring);
const coinsPerMonth =
typeof plan?.coinsPerMonth === "number" && Number.isFinite(plan.coinsPerMonth)
typeof plan?.coinsPerMonth === "number" &&
Number.isFinite(plan.coinsPerMonth)
? Math.round(plan.coinsPerMonth)
: null;
const planBadgeKey = getPlanKeyForBadge(plan);
@@ -278,7 +279,7 @@ const PLAN_SEGMENTS = [
{ key: "annual", label: "Annuel" },
];
const HERO_IMAGE_WIDTH = isWeb ? 1280 : 520;
const HERO_IMAGE_WIDTH = isWeb ? 1280 : 700;
const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360;
const PAGE_BACKGROUND_COLOR = "#303438";
const SUBSCRIPTION_DISCLAIMER =
@@ -399,11 +400,7 @@ export default function Payments() {
} else if (shouldApplyPack && !isCatalogLoading) {
initialPackHandledRef.current = true;
}
}, [
normalizedPlans,
initialSubscriptionPack,
isCatalogLoading,
]);
}, [normalizedPlans, initialSubscriptionPack, isCatalogLoading]);
const currentPlans = normalizedPlans[billingPeriod] || [];
const selectedPriceId = selectedPriceIds[billingPeriod];
@@ -460,8 +457,7 @@ export default function Payments() {
}, [billingPeriod]);
const isProcessing = Boolean(processingPriceId);
const isActionDisabled =
!selectedPriceId || isProcessing || isLoadingPlans;
const isActionDisabled = !selectedPriceId || isProcessing || isLoadingPlans;
const actionButtonTitle = isProcessing
? "Redirection..."
: "Choisir cet abonnement";
@@ -621,7 +617,7 @@ export default function Payments() {
>
<View style={[styles.inner, isMobile && styles.mobileInner]}>
<ExpoImage
source={backgroundImage}
source={background.bgTrans}
contentFit="cover"
style={styles.centerImage}
/>
@@ -685,9 +681,7 @@ export default function Payments() {
/>
</View>
<Text style={styles.disclaimer}>
{SUBSCRIPTION_DISCLAIMER}
</Text>
<Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text>
</>
)}
</View>
+5 -2
View File
@@ -12,7 +12,7 @@ import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles";
const Playback = ({ route }) => {
const Playback = ({ route, navigation }) => {
const { project } = route.params || {};
const { videos } = useUser();
const [showIntro, setShowIntro] = useState(false);
@@ -50,7 +50,10 @@ const Playback = ({ route }) => {
return (
<Page headerType="NONE" backgroundImg={background.playbackBG2}>
<Image source={ai.john} style={styles.img} resizeMode="contain" />
<MusicLandHeader onPressBack={goBack} progress={25} />
<MusicLandHeader
onPressBack={() => navigation.navigate(Routes.Home)}
progress={25}
/>
<View
style={{
flex: 1,
+37 -15
View File
@@ -12,6 +12,8 @@ import { useUserData } from "../../providers/UserDataProvider";
import { gutters, Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { formatDate, toDate } from "../../utils/dateFormatting";
import { Image as ExpoImage } from "expo-image";
import { isWeb } from "../../hooks/useLayoutType";
const FUNCTIONS_REGION = "europe-west1";
@@ -183,9 +185,9 @@ const ManageSubscription = ({ navigation }) => {
setRemoteError(null);
try {
const callable = getFunctionsClient(
FUNCTIONS_REGION,
).httpsCallable("subscription-getActiveSubscription");
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
"subscription-getActiveSubscription",
);
const { data } = await callable();
if (!isMounted) {
return;
@@ -232,7 +234,9 @@ const ManageSubscription = ({ navigation }) => {
pickString(currentUserData?.stripeSubscriptionStatus) ||
pickString(remoteSubscription?.status) ||
pickString(currentUserData?.stripeSubscription?.status) ||
pickString(currentUserData?.stripeSubscription?.stripeSubscriptionStatus) ||
pickString(
currentUserData?.stripeSubscription?.stripeSubscriptionStatus,
) ||
pickString(rawSubscription?.status) ||
null;
@@ -349,8 +353,7 @@ const ManageSubscription = ({ navigation }) => {
? computeFirstAnnualGrantFromCreation(createdAtDate)
: null;
const futureFallbackGrant =
fallbackInitialGrant &&
fallbackInitialGrant.getTime() >= now.getTime()
fallbackInitialGrant && fallbackInitialGrant.getTime() >= now.getTime()
? fallbackInitialGrant
: null;
@@ -418,9 +421,9 @@ const ManageSubscription = ({ navigation }) => {
setSuccessMessage(null);
try {
const callable = getFunctionsClient(
FUNCTIONS_REGION,
).httpsCallable("subscription-cancelActiveSubscription");
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
"subscription-cancelActiveSubscription",
);
const payload = subscriptionInfo.subscriptionId
? { subscriptionId: subscriptionInfo.subscriptionId }
: {};
@@ -446,9 +449,7 @@ const ManageSubscription = ({ navigation }) => {
error?.message || error,
);
const message =
error?.message ||
error?.codeMessage ||
"Impossible d'annuler l'abonnement pour le moment.";
error?.message || "Impossible d'annuler l'abonnement pour le moment.";
setErrorMessage(message);
} finally {
setIsCancelling(false);
@@ -498,7 +499,10 @@ const ManageSubscription = ({ navigation }) => {
? Palette.grayMid
: Palette.red;
const backgroundImage = background.bgTrans;
const HOME_BACKGROUND_WIDTH = isWeb ? 1280 : 520;
const HOME_BACKGROUND_HEIGHT = isWeb ? 760 : 360;
const HOME_BACKGROUND_STYLE_WIDTH = HOME_BACKGROUND_WIDTH + 120;
const HOME_BACKGROUND_STYLE_HEIGHT = HOME_BACKGROUND_HEIGHT + 80;
return (
<Page
@@ -506,8 +510,27 @@ const ManageSubscription = ({ navigation }) => {
title="Mon abonnement"
scrollEnabled
backgroundColor={PAGE_BACKGROUND_COLOR}
backgroundImg={backgroundImage}
// backgroundImg={backgroundImage}
>
<ExpoImage
source={background.bgTrans}
contentFit="cover"
style={{
width: HOME_BACKGROUND_STYLE_WIDTH,
height: HOME_BACKGROUND_STYLE_HEIGHT,
borderRadius: 22,
overflow: "hidden",
position: "absolute",
top: isWeb ? "45%" : "50%",
left: "50%",
transform: [
{ translateX: -HOME_BACKGROUND_STYLE_WIDTH / 2 },
{ translateY: -HOME_BACKGROUND_STYLE_HEIGHT / 2 },
],
pointerEvents: "none",
zIndex: 0,
}}
/>
<View style={styles.container}>
{subscriptionInfo.hasAnySubscription ? (
<>
@@ -645,7 +668,6 @@ const ManageSubscription = ({ navigation }) => {
containerStyle={styles.cancelButton}
titleStyle={{ color: cancelTitleColor }}
/>
</>
) : (
<View style={styles.card}>
+4 -2
View File
@@ -464,16 +464,18 @@ const styles = StyleSheet.create({
bottomLoginPrompt: {
marginTop: 16,
paddingHorizontal: 24,
marginBottom: 30,
},
bottomFooterText: {
fontSize: 14,
fontSize: 15,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
textAlign: "center",
},
bottomFooterLink: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontFamily: FONT_FAMILY.InterBold,
color: Palette.white,
fontSize: 16,
},
socialWrapper: {
width: "100%",
+80 -60
View File
@@ -27,6 +27,12 @@ const { width } = Dimensions.get("window");
const MUSIC_GENERATION_COIN_COST = 8;
const CONFIRM_MODAL_MAX_WIDTH = 540;
const FUNCTIONS_REGION = "europe-west1";
const VOICE_SECTION_TITLES = ["BASE", "SENSIBILITÉ", "TECHNIQUE"];
const FIRST_VOICE_STEP_INDEX = 1;
const TOTAL_STEPS = 1 + VOICE_SECTION_TITLES.length + 2; // genre + voices + instruments + rhythm
const LAST_STEP_INDEX = TOTAL_STEPS - 1;
const INSTRUMENT_STEP_INDEX = FIRST_VOICE_STEP_INDEX + VOICE_SECTION_TITLES.length;
const RHYTHM_STEP_INDEX = LAST_STEP_INDEX;
const ComposeSong = () => {
const scrollRef = useRef(null);
@@ -69,19 +75,19 @@ const ComposeSong = () => {
}, [currentUserData?.coins]);
const isStepValid = useMemo(() => {
switch (selectedIndex) {
case 0:
if (selectedIndex === 0) {
return Array.isArray(genres) && genres.length > 0;
case 1:
// Must select at least one in base category
return !!(voice && typeof voice === "object" && voice.BASE);
case 2:
return Array.isArray(instruments) && instruments.length > 0;
case 3:
return !!rhythm;
default:
return true;
}
if (selectedIndex === FIRST_VOICE_STEP_INDEX) {
return !!(voice && typeof voice === "object" && voice.BASE);
}
if (selectedIndex === INSTRUMENT_STEP_INDEX) {
return Array.isArray(instruments) && instruments.length > 0;
}
if (selectedIndex === RHYTHM_STEP_INDEX) {
return !!rhythm;
}
return true;
}, [selectedIndex, genres, voice, instruments, rhythm]);
const musicConfig = useMemo(() => {
@@ -199,24 +205,24 @@ const ComposeSong = () => {
};
const onPressNext = () => {
if (selectedIndex === 3) {
if (selectedIndex === LAST_STEP_INDEX) {
setIsConfirmVisible(true);
return;
}
setSelectedIndex(selectedIndex + 1);
setProgress(progress + 9);
setSelectedIndex((prev) => Math.min(prev + 1, LAST_STEP_INDEX));
setProgress((prev) => prev + 9);
scrollRef.current.scrollToIndex({
index: selectedIndex + 1,
index: Math.min(selectedIndex + 1, LAST_STEP_INDEX),
animated: true,
});
};
const onPressBack = () => {
if (selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
setProgress(progress - 9);
setSelectedIndex((prev) => Math.max(prev - 1, 0));
setProgress((prev) => Math.max(18, prev - 9));
scrollRef.current.scrollToIndex({
index: selectedIndex - 1,
index: Math.max(selectedIndex - 1, 0),
animated: true,
});
} else {
@@ -229,6 +235,58 @@ const ComposeSong = () => {
navigate(Routes.SongReady);
};
const swiperSlides = React.Children.toArray([
<View
key="genre"
style={{
width: width,
height: containerLayout?.height,
paddingHorizontal: gutters,
}}
>
<ChooseGenre selected={genres} setSelected={setGenres} />
</View>,
...VOICE_SECTION_TITLES.map((section) => (
<View
key={`voice-${section}`}
style={{
width: width,
height: containerLayout?.height,
paddingHorizontal: gutters,
}}
>
<CustomizeVoice
category={section}
selected={voice}
setSelected={setVoice}
/>
</View>
)),
<View
key="instruments"
style={{
width: width,
height: containerLayout?.height,
paddingHorizontal: gutters,
}}
>
<ChooseInstruments
selected={instruments}
setSelected={setInstruments}
/>
</View>,
<View
key="rhythm"
style={{
width: width,
height: containerLayout?.height,
paddingHorizontal: gutters,
}}
>
<ChooseRhythm selected={rhythm} setSelected={setRhythm} />
</View>,
]);
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader
@@ -246,57 +304,19 @@ const ComposeSong = () => {
ref={scrollRef}
disableGesture
>
<View
style={{
width: width,
height: containerLayout?.height,
paddingHorizontal: gutters,
}}
>
<ChooseGenre selected={genres} setSelected={setGenres} />
</View>
<View
style={{
width: width,
height: containerLayout?.height,
paddingHorizontal: gutters,
}}
>
<CustomizeVoice selected={voice} setSelected={setVoice} />
</View>
<View
style={{
width: width,
height: containerLayout?.height,
paddingHorizontal: gutters,
}}
>
<ChooseInstruments
selected={instruments}
setSelected={setInstruments}
/>
</View>
<View
style={{
width: width,
height: containerLayout?.height,
paddingHorizontal: gutters,
}}
>
<ChooseRhythm selected={rhythm} setSelected={setRhythm} />
</View>
{swiperSlides}
</SwiperFlatList>
</View>
{selectedIndex !== 4 && (
{selectedIndex <= LAST_STEP_INDEX && (
<View style={{ gap: 12 }}>
{selectedIndex === 3 && isRegenerationFlow && (
{selectedIndex === LAST_STEP_INDEX && isRegenerationFlow && (
<BorderGradientButton
title="Annuler la nouvelle génération"
onPress={handleCancelGeneration}
/>
)}
<GradientButton
title={selectedIndex === 3 ? "Générer" : "Suivant"}
title={selectedIndex === LAST_STEP_INDEX ? "Générer" : "Suivant"}
onPress={onPressNext}
disabled={!isStepValid}
/>
+60 -41
View File
@@ -32,6 +32,7 @@ const { width: windowWidth } = Dimensions.get("window");
const MUSIC_GENERATION_COIN_COST = 8;
const CONFIRM_MODAL_MAX_WIDTH = 540;
const FUNCTIONS_REGION = "europe-west1";
const VOICE_SECTION_TITLES = ["BASE", "SENSIBILITÉ", "TECHNIQUE"];
const ComposeSong = () => {
const scrollRef = useRef(null);
@@ -71,20 +72,68 @@ const ComposeSong = () => {
return 0;
}, [currentUserData?.coins]);
const steps = useMemo(
() => [
{
key: "genres",
render: () => <ChooseGenre selected={genres} setSelected={setGenres} />,
},
...VOICE_SECTION_TITLES.map((section) => ({
key: `voice-${section}`,
render: () => (
<CustomizeVoice
category={section}
selected={voice}
setSelected={setVoice}
/>
),
})),
{
key: "instruments",
render: () => (
<ChooseInstruments
selected={instruments}
setSelected={setInstruments}
/>
),
},
{
key: "rhythm",
render: () => (
<ChooseRhythm selected={rhythm} setSelected={setRhythm} />
),
},
],
[genres, voice, instruments, rhythm]
);
const totalSteps = steps.length;
const lastStepIndex = totalSteps - 1;
const instrumentStepIndex = Math.max(lastStepIndex - 1, 0);
const isStepValid = useMemo(() => {
switch (selectedIndex) {
case 0:
if (selectedIndex === 0) {
return Array.isArray(genres) && genres.length > 0;
case 1:
return !!(voice && typeof voice === "object" && voice.BASE);
case 2:
return Array.isArray(instruments) && instruments.length > 0;
case 3:
return !!rhythm;
default:
return true;
}
}, [selectedIndex, genres, voice, instruments, rhythm]);
if (selectedIndex === 1) {
return !!(voice && typeof voice === "object" && voice.BASE);
}
if (selectedIndex === instrumentStepIndex) {
return Array.isArray(instruments) && instruments.length > 0;
}
if (selectedIndex === lastStepIndex) {
return !!rhythm;
}
return true;
}, [
selectedIndex,
genres,
voice,
instruments,
rhythm,
instrumentStepIndex,
lastStepIndex,
]);
const musicConfig = useMemo(() => {
let lyricsArr = [];
@@ -114,36 +163,6 @@ const ComposeSong = () => {
};
}, [selectedProject, genres, voice, instruments, rhythm, selectedProjectId]);
const steps = useMemo(
() => [
{
key: "genres",
render: () => <ChooseGenre selected={genres} setSelected={setGenres} />,
},
{
key: "voice",
render: () => (
<CustomizeVoice selected={voice} setSelected={setVoice} />
),
},
{
key: "instruments",
render: () => (
<ChooseInstruments
selected={instruments}
setSelected={setInstruments}
/>
),
},
{
key: "rhythm",
render: () => (
<ChooseRhythm selected={rhythm} setSelected={setRhythm} />
),
},
],
[genres, voice, instruments, rhythm]
);
useEffect(() => {
const nextProgress = 18 + selectedIndex * 9;
+99 -17
View File
@@ -1,30 +1,112 @@
import { View, StyleSheet } from "react-native";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import ItemContainer from "../../components/ItemContainer/ItemContainer";
import { Palette } from "../../styles";
import { VOICE } from "../../data/data";
import ListSelection from "../../components/ListSelection/ListSelection";
const CustomizeVoice = ({ selected = {}, setSelected }) => {
const [containerLayout, setContainerLayout] = useState(null);
const SECTION_INSTRUCTIONS = {
BASE: "Choisis ta base",
SENSIBILITE: "Choisis ta sensibilité",
TECHNIQUE: "Choisis ta technique",
};
// Toggle select: only 1 per category (section.title)
const onPressSelect = (category, item) => {
if (!setSelected) return;
const current = selected && typeof selected === "object" ? selected : {};
// If tapping the same item, deselect; otherwise, set new one for the category
const next = { ...current };
if (current[category] === item) next[category] = null;
else next[category] = item;
setSelected(next);
const normalizeCategory = (value) => {
if (typeof value !== "string") return "";
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toUpperCase();
};
const selectionToObject = (value) => {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value;
}
if (Array.isArray(value)) {
return value.reduce((acc, entry) => {
if (entry && typeof entry === "object") {
const cat = entry.category || entry.title || entry.type;
if (!cat) return acc;
const val =
entry.value ??
entry.label ??
entry.name ??
(typeof entry.description === "string"
? entry.description
: undefined);
if (typeof val === "string") {
acc[cat] = val;
}
return acc;
}
return acc;
}, {});
}
return {};
};
const objectToSelectionShape = (value, preferArray = false) => {
if (!preferArray) return value;
return Object.entries(value || {}).map(([category, val]) => ({
category,
value: val,
}));
};
const CustomizeVoice = ({
selected = {},
setSelected = () => {},
category = "BASE",
}) => {
const [containerLayout, setContainerLayout] = useState(null);
const normalizedCategory = normalizeCategory(category);
const sectionData = useMemo(() => {
const fallback = Array.isArray(VOICE) && VOICE.length > 0 ? VOICE[0] : null;
if (!Array.isArray(VOICE)) return fallback;
const match =
VOICE.find(
(section) => normalizeCategory(section?.title) === normalizedCategory
) || fallback;
return match;
}, [normalizedCategory]);
const subtitle =
SECTION_INSTRUCTIONS[normalizedCategory] ||
"Choisis la voix pour ta chanson";
const voiceObject = useMemo(
() => selectionToObject(selected),
[selected]
);
const sectionKey = sectionData?.title || "BASE";
const currentValue =
typeof voiceObject?.[sectionKey] === "string"
? voiceObject[sectionKey]
: null;
const handleSelectionChange = (value) => {
if (typeof setSelected !== "function") return;
setSelected((previous) => {
const wasArray = Array.isArray(previous);
const baseObject = selectionToObject(previous);
const nextObject = { ...baseObject };
if (typeof value === "string" && value.length > 0) {
nextObject[sectionKey] = value;
} else {
delete nextObject[sectionKey];
}
return objectToSelectionShape(nextObject, wasArray);
});
};
return (
<View style={{ flex: 1, gap: 10, paddingTop: 16 }}>
<CreateLyricsHeader
title="Personnalise la voix que tu veux pour ta chanson"
subTitle="Choisis maximum 1 option par catégorie."
subTitle={subtitle}
/>
<View
style={{ flex: 1 }}
@@ -33,10 +115,10 @@ const CustomizeVoice = ({ selected = {}, setSelected }) => {
<ItemContainer height={containerLayout?.height}>
<View style={{ flex: 1, gap: 10 }}>
<ListSelection
options={VOICE}
variant="sectioned"
selected={selected}
setSelected={setSelected}
options={sectionData?.data || []}
variant="simple"
selected={currentValue}
setSelected={handleSelectionChange}
contentContainerStyle={styles.contentContainer}
itemContainerStyle={styles.itemContainer}
/>
+55 -3
View File
@@ -30,6 +30,16 @@ const GeneratingSong = () => {
const askedRef = useRef(false);
const callingRef = useRef(false);
const localStartRef = useRef(null);
const errorAlertShownRef = useRef(false);
const isFailed = selectedProject?.musicStatus === "FAILED";
const musicErrorMessage =
selectedProject?.musicError?.message ||
selectedProject?.musicError?.status ||
"Une erreur est survenue lors de la génération.";
const hasRefund =
selectedProject?.musicCreditsRefunded === true ||
typeof selectedProject?.musicCreditsRefundOrderId === "string";
// project loaded from provider
@@ -249,6 +259,28 @@ const GeneratingSong = () => {
startMusicGenerationOnce,
]);
useEffect(() => {
if (isFailed) {
if (!errorAlertShownRef.current) {
errorAlertShownRef.current = true;
const refundNotice = hasRefund
? "\n\nTes crédits ont été automatiquement remboursés."
: "";
AppAlert("Génération échouée", `${musicErrorMessage}${refundNotice}`);
}
} else if (selectedProject?.musicStatus === "GENERATING") {
errorAlertShownRef.current = false;
}
}, [
hasRefund,
isFailed,
musicErrorMessage,
selectedProject?.musicStatus,
]);
const progressStatus = isFailed ? "error" : "default";
const progressLabel = isFailed ? "Erreur" : `${progress}%`;
return (
<Page headerType="NONE" backgroundImg={background.studioBG2}>
<MusicLandHeader
@@ -303,17 +335,37 @@ const GeneratingSong = () => {
Ta musique est{"\n"}en cours de création
</Text>
<View style={{ alignItems: "center", gap: 16 }}>
<ProgressBar gradient progress={progress} />
<ProgressBar
gradient
progress={progress}
status={progressStatus}
/>
<Text
style={{
fontSize: 14,
color: Palette.white,
color: isFailed ? Palette.red : Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
{progress}%
{progressLabel}
</Text>
</View>
{isFailed ? (
<Text
style={{
fontSize: 13,
color: Palette.red,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
lineHeight: 20,
}}
>
{musicErrorMessage}
{hasRefund
? "\nTes crédits ont été remboursés automatiquement."
: "\nTu peux revenir en arrière pour relancer la génération."}
</Text>
) : null}
<GradientButton
title={
selectedProject?.musicStatus === "GENERATED"
+65 -21
View File
@@ -2,11 +2,21 @@
import { useFocusEffect } from "@react-navigation/native";
import { BlurView } from "expo-blur";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Image, Modal, Pressable, Text, View } from "react-native";
import {
FlatList,
Image,
Modal,
Pressable,
ScrollView,
Text,
View,
useWindowDimensions,
} from "react-native";
import { responsiveHeight } from "react-native-responsive-dimensions";
import { background, icons } from "../../assets";
import alert from "../../components/Alert";
import BorderGradientButton from "../../components/BorderGradientButton";
import CreditAmount from "../../components/CreditAmount";
import GradientButton from "../../components/GradientButton";
import ValidateModal from "../../components/modal/ValidateModal";
import MusicLandHeader from "../../components/MusicLandHeader";
@@ -233,21 +243,26 @@ const SongReady = () => {
marginBottom: responsiveHeight(2),
}}
/>
<View style={{ gap: 16, marginTop: isWeb ? gutters * 3 : 0 }}>
{musicUrls.map((url, idx) => (
<FlatList
style={{ flex: 1 }}
data={musicUrls}
keyExtractor={(item, idx) => `${item || "song"}-${idx}`}
renderItem={({ item, index }) => (
<SongOptionCard
key={`${url || "song"}-${idx}`}
index={idx}
url={url}
isSelected={selectedIndex === idx}
index={index}
url={item}
isSelected={selectedIndex === index}
onSelect={setSelectedIndex}
registerPlayer={registerPlayer}
onTogglePlayback={handleTogglePlayback}
projectId={projectId}
selectedProject={selectedProject}
/>
))}
</View>
)}
contentContainerStyle={{ gap: 16, paddingBottom: gutters }}
showsVerticalScrollIndicator={false}
extraData={selectedIndex}
/>
</View>
<View
style={{
@@ -510,6 +525,18 @@ const SongOptionCard = ({
};
const RegenerateModal = ({ visible, onClose, onConfirm }) => {
const { width, height } = useWindowDimensions();
const isCompactWidth = width < 420;
const horizontalPadding = Math.max(isCompactWidth ? 16 : gutters, 12);
const verticalPadding = Math.max(isCompactWidth ? gutters : gutters * 1.5, 12);
const availableWidth = Math.max(width - horizontalPadding * 2, 0);
const contentWidth =
availableWidth > 0
? Math.min(REGENERATE_MODAL_MAX_WIDTH, availableWidth)
: undefined;
const contentGap = isCompactWidth ? 18 : 24;
const buttonGap = isCompactWidth ? 12 : 15;
return (
<Modal
animationType="fade"
@@ -520,29 +547,39 @@ const RegenerateModal = ({ visible, onClose, onConfirm }) => {
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: gutters,
paddingVertical: gutters * 1.5,
backgroundColor: "rgba(0, 0, 0, 0.6)",
}}
>
<ScrollView
bounces={false}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: horizontalPadding,
paddingVertical: verticalPadding,
minHeight: height,
}}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<CreateLyricsHeader
containerStyle={{
width: "100%",
width: contentWidth ?? "100%",
maxWidth: REGENERATE_MODAL_MAX_WIDTH,
alignSelf: "center",
paddingVertical: 24,
paddingHorizontal: 24,
gap: 24,
paddingVertical: contentGap,
paddingHorizontal: contentGap,
gap: contentGap,
flexShrink: 1,
}}
>
<View style={{ gap: 24, alignItems: "center" }}>
<View style={{ gap: contentGap, alignItems: "center" }}>
<View
style={{
gap: 12,
gap: isCompactWidth ? 10 : 12,
alignItems: "center",
paddingHorizontal: 12,
paddingHorizontal: isCompactWidth ? 4 : 12,
}}
>
<Text
@@ -606,7 +643,13 @@ const RegenerateModal = ({ visible, onClose, onConfirm }) => {
Les crédits seront utilisés lors de l'étape de génération.
</Text>
</View>
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
<View
style={{
width: isCompactWidth ? "100%" : "85%",
alignSelf: "center",
gap: buttonGap,
}}
>
<GradientButton
title="Oui, modifier mes choix"
onPress={onConfirm}
@@ -615,6 +658,7 @@ const RegenerateModal = ({ visible, onClose, onConfirm }) => {
</View>
</View>
</CreateLyricsHeader>
</ScrollView>
</View>
</Modal>
);
+7 -8
View File
@@ -30,10 +30,12 @@ import { getStageAction } from "../../utils/projectStages";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
const COVER_STYLE_PRESETS = [
"Néo-Néon / Cyberpunk",
"Photographie Minimaliste & Éditoriale",
"Illustration & Collage Surréaliste",
"Anti-Design / Maximalisme (Tendance Actuelle)",
"Cyberpunk",
"Dessins animé",
"Dessins animé rétro",
"Portrait théâtralisé",
"Livre de coloriage",
"Shooting",
];
const PouchReady = () => {
@@ -301,10 +303,7 @@ const PouchReady = () => {
);
return (
<Page
backgroundImg={isWeb ? background.productionBG2 : background.studioBG}
headerType="NONE"
>
<Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={goBack} progress={72} />
<View style={{ flex: 1, marginTop: 0 }}>
<CreateLyricsHeader
+38 -38
View File
@@ -1,15 +1,15 @@
import { useRouter } from 'expo-router';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useRouter } from "expo-router";
import React from "react";
import { StyleSheet, Text, View } from "react-native";
import AppleSignInButton from '../../components/buttons/AppleSignInButton';
import Button from '../../components/buttons/Button';
import GoogleSignInButton from '../../components/buttons/GoogleSignInButton';
import AppInput from '../../components/inputs/Input';
import PublicScreenLayout from '../../components/layout/PublicScreenLayout';
import useRegisterForm from '../../hooks/useRegisterForm';
import Fonts from '../../styles/Fonts';
import Palette from '../../styles/Palette';
import AppleSignInButton from "../../components/buttons/AppleSignInButton";
import Button from "../../components/buttons/Button";
import GoogleSignInButton from "../../components/buttons/GoogleSignInButton";
import AppInput from "../../components/inputs/Input";
import PublicScreenLayout from "../../components/layout/PublicScreenLayout";
import useRegisterForm from "../../hooks/useRegisterForm";
import Fonts from "../../styles/Fonts";
import Palette from "../../styles/Palette";
export default function RegisterScreen() {
const router = useRouter();
@@ -32,7 +32,7 @@ export default function RegisterScreen() {
router.back();
return;
}
router.replace('/(public)/login');
router.replace("/(public)/login");
};
return (
@@ -52,10 +52,10 @@ export default function RegisterScreen() {
type="email"
containerStyle={styles.input}
textInputProps={{
autoCapitalize: 'none',
autoComplete: 'email',
textContentType: 'emailAddress',
name: 'email',
autoCapitalize: "none",
autoComplete: "email",
textContentType: "emailAddress",
name: "email",
}}
/>
@@ -67,9 +67,9 @@ export default function RegisterScreen() {
type="password"
containerStyle={styles.input}
textInputProps={{
autoComplete: 'new-password',
textContentType: 'newPassword',
name: 'new-password',
autoComplete: "new-password",
textContentType: "newPassword",
name: "new-password",
}}
/>
@@ -81,9 +81,9 @@ export default function RegisterScreen() {
type="password"
containerStyle={styles.input}
textInputProps={{
autoComplete: 'new-password',
textContentType: 'newPassword',
name: 'confirm-password',
autoComplete: "new-password",
textContentType: "newPassword",
name: "confirm-password",
}}
/>
@@ -129,28 +129,28 @@ export default function RegisterScreen() {
const styles = StyleSheet.create({
body: {
flex: 1,
justifyContent: 'center',
justifyContent: "center",
gap: 24,
alignItems: 'center',
alignItems: "center",
},
heading: {
fontSize: 28,
fontWeight: '700',
fontWeight: "700",
color: Palette.darkPurple,
textAlign: 'center',
textAlign: "center",
},
subheading: {
...Fonts.bodySmall,
color: 'rgba(15, 12, 20, 0.6)',
textAlign: 'center',
color: "rgba(15, 12, 20, 0.6)",
textAlign: "center",
},
form: {
width: '100%',
width: "100%",
maxWidth: 420,
gap: 16,
},
input: {
width: '100%',
width: "100%",
},
primaryButton: {
marginTop: 8,
@@ -160,28 +160,28 @@ const styles = StyleSheet.create({
gap: 12,
},
socialDivider: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: 8,
},
dividerLine: {
flex: 1,
height: StyleSheet.hairlineWidth,
backgroundColor: 'rgba(15, 12, 20, 0.15)',
backgroundColor: "rgba(15, 12, 20, 0.15)",
},
dividerText: {
...Fonts.tinyBold,
textTransform: 'uppercase',
color: 'rgba(15, 12, 20, 0.45)',
textTransform: "uppercase",
color: "rgba(15, 12, 20, 0.45)",
},
socialButton: {
width: '100%',
width: "100%",
},
hint: {
marginTop: 12,
...Fonts.bodySmall,
textAlign: 'center',
color: 'rgba(15, 12, 20, 0.6)',
textAlign: "center",
color: "rgba(15, 12, 20, 0.6)",
},
secondaryButton: {
marginTop: 12,