fix of the day, payment + sub + music generation
This commit is contained in:
@@ -9,7 +9,10 @@ exports.generatePicturePrompt = (project = {}) => {
|
|||||||
|
|
||||||
const sanitizeInline = (value = "") => {
|
const sanitizeInline = (value = "") => {
|
||||||
if (typeof value !== "string") return "";
|
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";
|
const titleForPrompt = sanitizeInline(title) || "Sans titre";
|
||||||
@@ -74,7 +77,7 @@ exports.generatePicturePrompt = (project = {}) => {
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
const prompt = `<BRIEF>
|
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>
|
<TITRE>${titleForPrompt}</TITRE>
|
||||||
${artistLine} <STYLE_MUSICAL>${tagsLine}</STYLE_MUSICAL>
|
${artistLine} <STYLE_MUSICAL>${tagsLine}</STYLE_MUSICAL>
|
||||||
<EXTRAITS_PAROLES>${lyricsLine}</EXTRAITS_PAROLES>
|
<EXTRAITS_PAROLES>${lyricsLine}</EXTRAITS_PAROLES>
|
||||||
|
|||||||
+191
-102
@@ -7,9 +7,12 @@ const axios = require("axios");
|
|||||||
const admin = require("firebase-admin");
|
const admin = require("firebase-admin");
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require("firebase-admin/firestore");
|
||||||
const { logger } = require("firebase-functions/logger");
|
const { logger } = require("firebase-functions/logger");
|
||||||
|
const { pipeline } = require("stream/promises");
|
||||||
|
const { randomUUID } = require("crypto");
|
||||||
const { ALERT_TYPE, refList } = require("../index");
|
const { ALERT_TYPE, refList } = require("../index");
|
||||||
const { sendNotification } = require("./notifications");
|
const { sendNotification } = require("./notifications");
|
||||||
const { SUNO_API_KEY } = require("../config/keys");
|
const { SUNO_API_KEY } = require("../config/keys");
|
||||||
|
const { createOrderDocument, ORDER_TYPES } = require("./helpers/orders");
|
||||||
const {
|
const {
|
||||||
SUNO_MODEL,
|
SUNO_MODEL,
|
||||||
SUNO_CALLBACK_URL,
|
SUNO_CALLBACK_URL,
|
||||||
@@ -18,6 +21,51 @@ const {
|
|||||||
SUNO_STATUS_PATH,
|
SUNO_STATUS_PATH,
|
||||||
} = require("../config/suno");
|
} = 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
|
* Marque un projet comme échoué suite à une erreur Suno
|
||||||
* @param {string} projectId - Identifiant du projet
|
* @param {string} projectId - Identifiant du projet
|
||||||
@@ -42,21 +90,46 @@ async function markProjectMusicFailure(projectId, error) {
|
|||||||
if (status) errorPayload.status = status;
|
if (status) errorPayload.status = status;
|
||||||
if (error?.code) errorPayload.code = error.code;
|
if (error?.code) errorPayload.code = error.code;
|
||||||
|
|
||||||
await docRef.set(
|
|
||||||
{
|
|
||||||
musicStatus: "FAILED",
|
|
||||||
sunoTaskId: FieldValue.delete(),
|
|
||||||
generationStartAt: FieldValue.delete(),
|
|
||||||
musicError: errorPayload,
|
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
|
||||||
},
|
|
||||||
{ merge: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const receiverId = sanitizeField(projectData?.userId);
|
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(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (refundResult?.orderId) {
|
||||||
|
updatePayload.musicCreditsRefunded = true;
|
||||||
|
updatePayload.musicCreditsRefundOrderId = refundResult.orderId;
|
||||||
|
updatePayload.musicCreditsRefundedAt = FieldValue.serverTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
await docRef.set(updatePayload, { merge: true });
|
||||||
|
|
||||||
if (receiverId) {
|
if (receiverId) {
|
||||||
const projectTitle = sanitizeField(projectData?.title, "ton projet");
|
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 {
|
try {
|
||||||
await sendNotification({
|
await sendNotification({
|
||||||
sender: "SYSTEM",
|
sender: "SYSTEM",
|
||||||
@@ -267,12 +340,11 @@ const downloadTrackToStorage = async (
|
|||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
try {
|
try {
|
||||||
console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`);
|
console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`);
|
||||||
const resp = await axios.get(url, { responseType: "arraybuffer" });
|
const resp = await axios.get(url, { responseType: "stream" });
|
||||||
const buffer = Buffer.from(resp.data);
|
|
||||||
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`;
|
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`;
|
||||||
const token = require("crypto").randomUUID();
|
const token = randomUUID();
|
||||||
const file = bucket.file(path);
|
const file = bucket.file(path);
|
||||||
await file.save(buffer, {
|
const writeStream = file.createWriteStream({
|
||||||
resumable: false,
|
resumable: false,
|
||||||
metadata: {
|
metadata: {
|
||||||
contentType: "audio/mpeg",
|
contentType: "audio/mpeg",
|
||||||
@@ -280,6 +352,7 @@ const downloadTrackToStorage = async (
|
|||||||
metadata: { firebaseStorageDownloadTokens: token },
|
metadata: { firebaseStorageDownloadTokens: token },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await pipeline(resp.data, writeStream);
|
||||||
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
||||||
path,
|
path,
|
||||||
)}?alt=media&token=${token}`;
|
)}?alt=media&token=${token}`;
|
||||||
@@ -681,104 +754,120 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
|
|||||||
* Cette fonction est appelée par l'API Suno lorsque la génération
|
* Cette fonction est appelée par l'API Suno lorsque la génération
|
||||||
* de musique est terminée
|
* de musique est terminée
|
||||||
*/
|
*/
|
||||||
exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
exports.sunoCallback = onRequest(
|
||||||
if (req.method !== "POST") {
|
{ methods: ["POST"], memory: "1GiB" },
|
||||||
console.warn("⚠️ [SunoCallback] Méthode non autorisée:", req.method);
|
async (req, res) => {
|
||||||
return res.status(405).json({ error: "Méthode non autorisée" });
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body));
|
console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body));
|
||||||
|
|
||||||
const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body);
|
const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body);
|
||||||
console.log("🎯 [SunoCallback] Détails:", {
|
console.log("🎯 [SunoCallback] Détails:", {
|
||||||
code,
|
|
||||||
status,
|
|
||||||
taskId,
|
|
||||||
count: tracks.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (code !== 200 || status !== "complete") {
|
|
||||||
console.log("ℹ️ [SunoCallback] Callback ignoré (code/status)", {
|
|
||||||
code,
|
code,
|
||||||
status,
|
status,
|
||||||
|
taskId,
|
||||||
|
count: tracks.length,
|
||||||
});
|
});
|
||||||
return res.status(200).json({ success: true, ignored: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!taskId) {
|
if (code !== 200 || status !== "complete") {
|
||||||
console.warn("⚠️ [SunoCallback] taskId manquant dans le callback");
|
console.log("ℹ️ [SunoCallback] Callback ignoré (code/status)", {
|
||||||
return res.status(200).json({ success: true, ignored: true });
|
code,
|
||||||
}
|
status,
|
||||||
|
});
|
||||||
try {
|
return res.status(200).json({ success: true, ignored: true });
|
||||||
const { projectId, projectData, projectRef } =
|
|
||||||
await fetchProjectByTaskId(taskId);
|
|
||||||
const { userId, projectTitle } = formatProjectMeta(projectData);
|
|
||||||
|
|
||||||
const audioUrls = extractAudioUrlsFromTracks(tracks);
|
|
||||||
if (audioUrls.length < 2) {
|
|
||||||
console.warn(
|
|
||||||
"⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback",
|
|
||||||
{
|
|
||||||
found: audioUrls.length,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedUrls = await saveTracksToStorage(audioUrls, {
|
if (!taskId) {
|
||||||
userId,
|
console.warn("⚠️ [SunoCallback] taskId manquant dans le callback");
|
||||||
projectId,
|
return res.status(200).json({ success: true, ignored: true });
|
||||||
taskId,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const musicUrls = await mergeMusicUrls(projectRef, storedUrls);
|
let projectIdForFailure = null;
|
||||||
|
|
||||||
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
|
try {
|
||||||
projectId,
|
const { projectId, projectData, projectRef } =
|
||||||
musicUrlsCount: musicUrls.length,
|
await fetchProjectByTaskId(taskId);
|
||||||
});
|
projectIdForFailure = projectId;
|
||||||
|
const { userId, projectTitle } = formatProjectMeta(projectData);
|
||||||
|
|
||||||
if (userId) {
|
const audioUrls = extractAudioUrlsFromTracks(tracks);
|
||||||
const successMessage =
|
if (audioUrls.length < 2) {
|
||||||
musicUrls.length > 0
|
console.warn(
|
||||||
? `Ta musique pour "${projectTitle}" est prête.`
|
"⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback",
|
||||||
: `La génération de musique pour "${projectTitle}" est terminée.`;
|
{
|
||||||
try {
|
found: audioUrls.length,
|
||||||
await sendNotification({
|
|
||||||
sender: "SYSTEM",
|
|
||||||
receiver: userId,
|
|
||||||
receiverCollection: "users",
|
|
||||||
title: "Musique prête",
|
|
||||||
message: successMessage,
|
|
||||||
data: {
|
|
||||||
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
|
||||||
projectId,
|
|
||||||
projectTitle,
|
|
||||||
musicUrls,
|
|
||||||
taskId,
|
|
||||||
},
|
},
|
||||||
});
|
|
||||||
} catch (notifError) {
|
|
||||||
console.error(
|
|
||||||
"[sunoCallback] Failed to send success notification:",
|
|
||||||
notifError,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({
|
const storedUrls = await saveTracksToStorage(audioUrls, {
|
||||||
success: true,
|
userId,
|
||||||
projectId,
|
projectId,
|
||||||
savedCount: storedUrls.length,
|
taskId,
|
||||||
musicUrlsCount: musicUrls.length,
|
});
|
||||||
});
|
|
||||||
} catch (error) {
|
const musicUrls = await mergeMusicUrls(projectRef, storedUrls);
|
||||||
const statusCode =
|
|
||||||
error?.message === "PROJECT_NOT_FOUND_FOR_TASK" ? 404 : 500;
|
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
|
||||||
logger.error("❌ [SunoCallback] Erreur interne:", error);
|
projectId,
|
||||||
return res.status(statusCode).json({
|
musicUrlsCount: musicUrls.length,
|
||||||
success: false,
|
});
|
||||||
error: error?.message || "Erreur interne du serveur",
|
|
||||||
});
|
if (userId) {
|
||||||
}
|
const successMessage =
|
||||||
});
|
musicUrls.length > 0
|
||||||
|
? `Ta musique pour "${projectTitle}" est prête.`
|
||||||
|
: `La génération de musique pour "${projectTitle}" est terminée.`;
|
||||||
|
try {
|
||||||
|
await sendNotification({
|
||||||
|
sender: "SYSTEM",
|
||||||
|
receiver: userId,
|
||||||
|
receiverCollection: "users",
|
||||||
|
title: "Musique prête",
|
||||||
|
message: successMessage,
|
||||||
|
data: {
|
||||||
|
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
||||||
|
projectId,
|
||||||
|
projectTitle,
|
||||||
|
musicUrls,
|
||||||
|
taskId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (notifError) {
|
||||||
|
console.error(
|
||||||
|
"[sunoCallback] Failed to send success notification:",
|
||||||
|
notifError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
projectId,
|
||||||
|
savedCount: storedUrls.length,
|
||||||
|
musicUrlsCount: musicUrls.length,
|
||||||
|
});
|
||||||
|
} 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 |
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { Image, Pressable, Text, View } from "react-native";
|
import { Image, Pressable, Text, View } from "react-native";
|
||||||
import { Palette, Style } from "../styles";
|
import { Palette, Style } from "../styles";
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
import { FONT_FAMILY } from "../styles/Fonts";
|
||||||
import { size } from "../styles/Style";
|
import { size as sizeStyle } from "../styles/Style";
|
||||||
import BorderGradient from "./BorderGradient/BorderGradient";
|
import BorderGradient from "./BorderGradient/BorderGradient";
|
||||||
|
|
||||||
const HEIGHT_BY_SIZE = {
|
const HEIGHT_BY_SIZE = {
|
||||||
@@ -84,7 +84,9 @@ const BorderGradientButton = ({
|
|||||||
gap: 11,
|
gap: 11,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{icon && <Image source={icon} style={size({ size: iconSize })} />}
|
{icon && (
|
||||||
|
<Image source={icon} style={sizeStyle({ size: iconSize })} />
|
||||||
|
)}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize,
|
fontSize,
|
||||||
|
|||||||
@@ -8,23 +8,34 @@ const ProgressBar = ({
|
|||||||
progress = 0,
|
progress = 0,
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
gradient = false,
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: 5,
|
height: 5,
|
||||||
backgroundColor: "#0F0C19",
|
backgroundColor: containerBackground,
|
||||||
borderRadius: mainBorderRadius,
|
borderRadius: mainBorderRadius,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
...containerStyle,
|
...containerStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{gradient ? (
|
{shouldUseGradient ? (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={["#F94697", "#7023F7"]}
|
colors={["#F94697", "#7023F7"]}
|
||||||
style={{
|
style={{
|
||||||
width: `${progress}%`,
|
width: `${normalizedProgress}%`,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
borderRadius: mainBorderRadius,
|
borderRadius: mainBorderRadius,
|
||||||
}}
|
}}
|
||||||
@@ -34,9 +45,9 @@ const ProgressBar = ({
|
|||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: `${progress}%`,
|
width: `${normalizedProgress}%`,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
backgroundColor: Palette.white,
|
backgroundColor: fillColor,
|
||||||
borderRadius: mainBorderRadius,
|
borderRadius: mainBorderRadius,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Platform } from "react-native";
|
|||||||
|
|
||||||
const functionsInstances = {};
|
const functionsInstances = {};
|
||||||
const emulatorConfigured = {};
|
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 USE_FUNCTIONS_EMULATOR = false; // Toggle to route functions traffic to the local emulator.
|
||||||
|
|
||||||
const configureFunctionsEmulator = (instance, regionKey = "us-central1") => {
|
const configureFunctionsEmulator = (instance, regionKey = "us-central1") => {
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ export const GOOGLE_WEB_CLIENT_ID =
|
|||||||
|
|
||||||
// iOS client ID (may need updating to match the iOS bundle for this project)
|
// iOS client ID (may need updating to match the iOS bundle for this project)
|
||||||
export const GOOGLE_IOS_CLIENT_ID =
|
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)
|
// Android client ID (client_type 1 from google-services.json)
|
||||||
export const GOOGLE_ANDROID_CLIENT_ID =
|
export const GOOGLE_ANDROID_CLIENT_ID =
|
||||||
|
|||||||
+18
-5
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable react/display-name */
|
/* 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 { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import React from "reactn";
|
import React from "reactn";
|
||||||
@@ -168,6 +168,19 @@ export default ({
|
|||||||
return <ShareBtn />;
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -256,15 +269,15 @@ export default ({
|
|||||||
width: computedWidth,
|
width: computedWidth,
|
||||||
maxWidth: maxWidth ? maxWidth : null,
|
maxWidth: maxWidth ? maxWidth : null,
|
||||||
alignSelf: isWeb ? "center" : "auto",
|
alignSelf: isWeb ? "center" : "auto",
|
||||||
...containerStyle,
|
...resolvedContainerStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{headerType !== "NONE" ? (
|
{headerType !== "NONE" ? (
|
||||||
headerType === "BASE" ? (
|
headerType === "BASE" ? (
|
||||||
<BaseHeader containerStyle={{ ...headerStyle }} />
|
<BaseHeader containerStyle={{ ...resolvedHeaderStyle }} />
|
||||||
) : (
|
) : (
|
||||||
<NavigateHeader
|
<NavigateHeader
|
||||||
containerStyle={{ ...headerStyle }}
|
containerStyle={{ ...resolvedHeaderStyle }}
|
||||||
title={title}
|
title={title}
|
||||||
rightComponent={rightComponent}
|
rightComponent={rightComponent}
|
||||||
onBackPressed={onBackPressed}
|
onBackPressed={onBackPressed}
|
||||||
@@ -277,7 +290,7 @@ export default ({
|
|||||||
{topStickyContent?.()}
|
{topStickyContent?.()}
|
||||||
|
|
||||||
<ContentContainer
|
<ContentContainer
|
||||||
style={{ flex: 1, ...contentContainerStyle }}
|
style={{ flex: 1, ...resolvedContentContainerStyle }}
|
||||||
{...(scrollEnabled
|
{...(scrollEnabled
|
||||||
? {
|
? {
|
||||||
scrollEventThrottle: 80,
|
scrollEventThrottle: 80,
|
||||||
|
|||||||
@@ -75,7 +75,15 @@ const StripeProvider = ({ children }) => {
|
|||||||
|
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.assign(checkoutUrl);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error("Navigation Stripe impossible dans cet environnement.");
|
throw new Error("Navigation Stripe impossible dans cet environnement.");
|
||||||
|
|||||||
+7
-13
@@ -75,7 +75,8 @@ function SubscriptionCard({ plan, selected, onSelect }) {
|
|||||||
const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency);
|
const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency);
|
||||||
const intervalLabel = getIntervalLabel(plan?.recurring);
|
const intervalLabel = getIntervalLabel(plan?.recurring);
|
||||||
const coinsPerMonth =
|
const coinsPerMonth =
|
||||||
typeof plan?.coinsPerMonth === "number" && Number.isFinite(plan.coinsPerMonth)
|
typeof plan?.coinsPerMonth === "number" &&
|
||||||
|
Number.isFinite(plan.coinsPerMonth)
|
||||||
? Math.round(plan.coinsPerMonth)
|
? Math.round(plan.coinsPerMonth)
|
||||||
: null;
|
: null;
|
||||||
const planBadgeKey = getPlanKeyForBadge(plan);
|
const planBadgeKey = getPlanKeyForBadge(plan);
|
||||||
@@ -278,7 +279,7 @@ const PLAN_SEGMENTS = [
|
|||||||
{ key: "annual", label: "Annuel" },
|
{ 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 HERO_IMAGE_HEIGHT = isWeb ? 760 : 360;
|
||||||
const PAGE_BACKGROUND_COLOR = "#303438";
|
const PAGE_BACKGROUND_COLOR = "#303438";
|
||||||
const SUBSCRIPTION_DISCLAIMER =
|
const SUBSCRIPTION_DISCLAIMER =
|
||||||
@@ -399,11 +400,7 @@ export default function Payments() {
|
|||||||
} else if (shouldApplyPack && !isCatalogLoading) {
|
} else if (shouldApplyPack && !isCatalogLoading) {
|
||||||
initialPackHandledRef.current = true;
|
initialPackHandledRef.current = true;
|
||||||
}
|
}
|
||||||
}, [
|
}, [normalizedPlans, initialSubscriptionPack, isCatalogLoading]);
|
||||||
normalizedPlans,
|
|
||||||
initialSubscriptionPack,
|
|
||||||
isCatalogLoading,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const currentPlans = normalizedPlans[billingPeriod] || [];
|
const currentPlans = normalizedPlans[billingPeriod] || [];
|
||||||
const selectedPriceId = selectedPriceIds[billingPeriod];
|
const selectedPriceId = selectedPriceIds[billingPeriod];
|
||||||
@@ -460,8 +457,7 @@ export default function Payments() {
|
|||||||
}, [billingPeriod]);
|
}, [billingPeriod]);
|
||||||
|
|
||||||
const isProcessing = Boolean(processingPriceId);
|
const isProcessing = Boolean(processingPriceId);
|
||||||
const isActionDisabled =
|
const isActionDisabled = !selectedPriceId || isProcessing || isLoadingPlans;
|
||||||
!selectedPriceId || isProcessing || isLoadingPlans;
|
|
||||||
const actionButtonTitle = isProcessing
|
const actionButtonTitle = isProcessing
|
||||||
? "Redirection..."
|
? "Redirection..."
|
||||||
: "Choisir cet abonnement";
|
: "Choisir cet abonnement";
|
||||||
@@ -621,7 +617,7 @@ export default function Payments() {
|
|||||||
>
|
>
|
||||||
<View style={[styles.inner, isMobile && styles.mobileInner]}>
|
<View style={[styles.inner, isMobile && styles.mobileInner]}>
|
||||||
<ExpoImage
|
<ExpoImage
|
||||||
source={backgroundImage}
|
source={background.bgTrans}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
style={styles.centerImage}
|
style={styles.centerImage}
|
||||||
/>
|
/>
|
||||||
@@ -685,9 +681,7 @@ export default function Payments() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.disclaimer}>
|
<Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text>
|
||||||
{SUBSCRIPTION_DISCLAIMER}
|
|
||||||
</Text>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { goBack, navigate } from "../../navigation/NavigationService";
|
|||||||
import { useUser } from "../../providers/UserDataProvider";
|
import { useUser } from "../../providers/UserDataProvider";
|
||||||
import { gutters } from "../../styles";
|
import { gutters } from "../../styles";
|
||||||
|
|
||||||
const Playback = ({ route }) => {
|
const Playback = ({ route, navigation }) => {
|
||||||
const { project } = route.params || {};
|
const { project } = route.params || {};
|
||||||
const { videos } = useUser();
|
const { videos } = useUser();
|
||||||
const [showIntro, setShowIntro] = useState(false);
|
const [showIntro, setShowIntro] = useState(false);
|
||||||
@@ -50,7 +50,10 @@ const Playback = ({ route }) => {
|
|||||||
return (
|
return (
|
||||||
<Page headerType="NONE" backgroundImg={background.playbackBG2}>
|
<Page headerType="NONE" backgroundImg={background.playbackBG2}>
|
||||||
<Image source={ai.john} style={styles.img} resizeMode="contain" />
|
<Image source={ai.john} style={styles.img} resizeMode="contain" />
|
||||||
<MusicLandHeader onPressBack={goBack} progress={25} />
|
<MusicLandHeader
|
||||||
|
onPressBack={() => navigation.navigate(Routes.Home)}
|
||||||
|
progress={25}
|
||||||
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { useUserData } from "../../providers/UserDataProvider";
|
|||||||
import { gutters, Palette } from "../../styles";
|
import { gutters, Palette } from "../../styles";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import { formatDate, toDate } from "../../utils/dateFormatting";
|
import { formatDate, toDate } from "../../utils/dateFormatting";
|
||||||
|
import { Image as ExpoImage } from "expo-image";
|
||||||
|
import { isWeb } from "../../hooks/useLayoutType";
|
||||||
|
|
||||||
const FUNCTIONS_REGION = "europe-west1";
|
const FUNCTIONS_REGION = "europe-west1";
|
||||||
|
|
||||||
@@ -183,9 +185,9 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
setRemoteError(null);
|
setRemoteError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callable = getFunctionsClient(
|
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
||||||
FUNCTIONS_REGION,
|
"subscription-getActiveSubscription",
|
||||||
).httpsCallable("subscription-getActiveSubscription");
|
);
|
||||||
const { data } = await callable();
|
const { data } = await callable();
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return;
|
return;
|
||||||
@@ -232,7 +234,9 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
pickString(currentUserData?.stripeSubscriptionStatus) ||
|
pickString(currentUserData?.stripeSubscriptionStatus) ||
|
||||||
pickString(remoteSubscription?.status) ||
|
pickString(remoteSubscription?.status) ||
|
||||||
pickString(currentUserData?.stripeSubscription?.status) ||
|
pickString(currentUserData?.stripeSubscription?.status) ||
|
||||||
pickString(currentUserData?.stripeSubscription?.stripeSubscriptionStatus) ||
|
pickString(
|
||||||
|
currentUserData?.stripeSubscription?.stripeSubscriptionStatus,
|
||||||
|
) ||
|
||||||
pickString(rawSubscription?.status) ||
|
pickString(rawSubscription?.status) ||
|
||||||
null;
|
null;
|
||||||
|
|
||||||
@@ -349,8 +353,7 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
? computeFirstAnnualGrantFromCreation(createdAtDate)
|
? computeFirstAnnualGrantFromCreation(createdAtDate)
|
||||||
: null;
|
: null;
|
||||||
const futureFallbackGrant =
|
const futureFallbackGrant =
|
||||||
fallbackInitialGrant &&
|
fallbackInitialGrant && fallbackInitialGrant.getTime() >= now.getTime()
|
||||||
fallbackInitialGrant.getTime() >= now.getTime()
|
|
||||||
? fallbackInitialGrant
|
? fallbackInitialGrant
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -418,9 +421,9 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
setSuccessMessage(null);
|
setSuccessMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callable = getFunctionsClient(
|
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
||||||
FUNCTIONS_REGION,
|
"subscription-cancelActiveSubscription",
|
||||||
).httpsCallable("subscription-cancelActiveSubscription");
|
);
|
||||||
const payload = subscriptionInfo.subscriptionId
|
const payload = subscriptionInfo.subscriptionId
|
||||||
? { subscriptionId: subscriptionInfo.subscriptionId }
|
? { subscriptionId: subscriptionInfo.subscriptionId }
|
||||||
: {};
|
: {};
|
||||||
@@ -446,9 +449,7 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
error?.message || error,
|
error?.message || error,
|
||||||
);
|
);
|
||||||
const message =
|
const message =
|
||||||
error?.message ||
|
error?.message || "Impossible d'annuler l'abonnement pour le moment.";
|
||||||
error?.codeMessage ||
|
|
||||||
"Impossible d'annuler l'abonnement pour le moment.";
|
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCancelling(false);
|
setIsCancelling(false);
|
||||||
@@ -491,14 +492,17 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
const cancelButtonTitle = subscriptionInfo.cancelAtPeriodEnd
|
const cancelButtonTitle = subscriptionInfo.cancelAtPeriodEnd
|
||||||
? "Annulation programmée"
|
? "Annulation programmée"
|
||||||
: isCancelling
|
: isCancelling
|
||||||
? "Annulation..."
|
? "Annulation..."
|
||||||
: "Annuler l'abonnement";
|
: "Annuler l'abonnement";
|
||||||
|
|
||||||
const cancelTitleColor = subscriptionInfo.cancelAtPeriodEnd
|
const cancelTitleColor = subscriptionInfo.cancelAtPeriodEnd
|
||||||
? Palette.grayMid
|
? Palette.grayMid
|
||||||
: Palette.red;
|
: 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 (
|
return (
|
||||||
<Page
|
<Page
|
||||||
@@ -506,8 +510,27 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
title="Mon abonnement"
|
title="Mon abonnement"
|
||||||
scrollEnabled
|
scrollEnabled
|
||||||
backgroundColor={PAGE_BACKGROUND_COLOR}
|
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}>
|
<View style={styles.container}>
|
||||||
{subscriptionInfo.hasAnySubscription ? (
|
{subscriptionInfo.hasAnySubscription ? (
|
||||||
<>
|
<>
|
||||||
@@ -645,7 +668,6 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
containerStyle={styles.cancelButton}
|
containerStyle={styles.cancelButton}
|
||||||
titleStyle={{ color: cancelTitleColor }}
|
titleStyle={{ color: cancelTitleColor }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
|
|||||||
@@ -464,16 +464,18 @@ const styles = StyleSheet.create({
|
|||||||
bottomLoginPrompt: {
|
bottomLoginPrompt: {
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
|
marginBottom: 30,
|
||||||
},
|
},
|
||||||
bottomFooterText: {
|
bottomFooterText: {
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
|
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
bottomFooterLink: {
|
bottomFooterLink: {
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
fontFamily: FONT_FAMILY.InterBold,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
|
fontSize: 16,
|
||||||
},
|
},
|
||||||
socialWrapper: {
|
socialWrapper: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ const { width } = Dimensions.get("window");
|
|||||||
const MUSIC_GENERATION_COIN_COST = 8;
|
const MUSIC_GENERATION_COIN_COST = 8;
|
||||||
const CONFIRM_MODAL_MAX_WIDTH = 540;
|
const CONFIRM_MODAL_MAX_WIDTH = 540;
|
||||||
const FUNCTIONS_REGION = "europe-west1";
|
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 ComposeSong = () => {
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
@@ -69,19 +75,19 @@ const ComposeSong = () => {
|
|||||||
}, [currentUserData?.coins]);
|
}, [currentUserData?.coins]);
|
||||||
|
|
||||||
const isStepValid = useMemo(() => {
|
const isStepValid = useMemo(() => {
|
||||||
switch (selectedIndex) {
|
if (selectedIndex === 0) {
|
||||||
case 0:
|
return Array.isArray(genres) && genres.length > 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]);
|
}, [selectedIndex, genres, voice, instruments, rhythm]);
|
||||||
|
|
||||||
const musicConfig = useMemo(() => {
|
const musicConfig = useMemo(() => {
|
||||||
@@ -199,24 +205,24 @@ const ComposeSong = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onPressNext = () => {
|
const onPressNext = () => {
|
||||||
if (selectedIndex === 3) {
|
if (selectedIndex === LAST_STEP_INDEX) {
|
||||||
setIsConfirmVisible(true);
|
setIsConfirmVisible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedIndex(selectedIndex + 1);
|
setSelectedIndex((prev) => Math.min(prev + 1, LAST_STEP_INDEX));
|
||||||
setProgress(progress + 9);
|
setProgress((prev) => prev + 9);
|
||||||
scrollRef.current.scrollToIndex({
|
scrollRef.current.scrollToIndex({
|
||||||
index: selectedIndex + 1,
|
index: Math.min(selectedIndex + 1, LAST_STEP_INDEX),
|
||||||
animated: true,
|
animated: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPressBack = () => {
|
const onPressBack = () => {
|
||||||
if (selectedIndex > 0) {
|
if (selectedIndex > 0) {
|
||||||
setSelectedIndex(selectedIndex - 1);
|
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||||
setProgress(progress - 9);
|
setProgress((prev) => Math.max(18, prev - 9));
|
||||||
scrollRef.current.scrollToIndex({
|
scrollRef.current.scrollToIndex({
|
||||||
index: selectedIndex - 1,
|
index: Math.max(selectedIndex - 1, 0),
|
||||||
animated: true,
|
animated: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -229,6 +235,58 @@ const ComposeSong = () => {
|
|||||||
navigate(Routes.SongReady);
|
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 (
|
return (
|
||||||
<Page backgroundImg={background.studioBG2} headerType="NONE">
|
<Page backgroundImg={background.studioBG2} headerType="NONE">
|
||||||
<MusicLandHeader
|
<MusicLandHeader
|
||||||
@@ -246,57 +304,19 @@ const ComposeSong = () => {
|
|||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
disableGesture
|
disableGesture
|
||||||
>
|
>
|
||||||
<View
|
{swiperSlides}
|
||||||
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>
|
|
||||||
</SwiperFlatList>
|
</SwiperFlatList>
|
||||||
</View>
|
</View>
|
||||||
{selectedIndex !== 4 && (
|
{selectedIndex <= LAST_STEP_INDEX && (
|
||||||
<View style={{ gap: 12 }}>
|
<View style={{ gap: 12 }}>
|
||||||
{selectedIndex === 3 && isRegenerationFlow && (
|
{selectedIndex === LAST_STEP_INDEX && isRegenerationFlow && (
|
||||||
<BorderGradientButton
|
<BorderGradientButton
|
||||||
title="Annuler la nouvelle génération"
|
title="Annuler la nouvelle génération"
|
||||||
onPress={handleCancelGeneration}
|
onPress={handleCancelGeneration}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<GradientButton
|
<GradientButton
|
||||||
title={selectedIndex === 3 ? "Générer" : "Suivant"}
|
title={selectedIndex === LAST_STEP_INDEX ? "Générer" : "Suivant"}
|
||||||
onPress={onPressNext}
|
onPress={onPressNext}
|
||||||
disabled={!isStepValid}
|
disabled={!isStepValid}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const { width: windowWidth } = Dimensions.get("window");
|
|||||||
const MUSIC_GENERATION_COIN_COST = 8;
|
const MUSIC_GENERATION_COIN_COST = 8;
|
||||||
const CONFIRM_MODAL_MAX_WIDTH = 540;
|
const CONFIRM_MODAL_MAX_WIDTH = 540;
|
||||||
const FUNCTIONS_REGION = "europe-west1";
|
const FUNCTIONS_REGION = "europe-west1";
|
||||||
|
const VOICE_SECTION_TITLES = ["BASE", "SENSIBILITÉ", "TECHNIQUE"];
|
||||||
|
|
||||||
const ComposeSong = () => {
|
const ComposeSong = () => {
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
@@ -71,20 +72,68 @@ const ComposeSong = () => {
|
|||||||
return 0;
|
return 0;
|
||||||
}, [currentUserData?.coins]);
|
}, [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(() => {
|
const isStepValid = useMemo(() => {
|
||||||
switch (selectedIndex) {
|
if (selectedIndex === 0) {
|
||||||
case 0:
|
return Array.isArray(genres) && genres.length > 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(() => {
|
const musicConfig = useMemo(() => {
|
||||||
let lyricsArr = [];
|
let lyricsArr = [];
|
||||||
@@ -114,36 +163,6 @@ const ComposeSong = () => {
|
|||||||
};
|
};
|
||||||
}, [selectedProject, genres, voice, instruments, rhythm, selectedProjectId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const nextProgress = 18 + selectedIndex * 9;
|
const nextProgress = 18 + selectedIndex * 9;
|
||||||
|
|||||||
@@ -1,30 +1,112 @@
|
|||||||
import { View, StyleSheet } from "react-native";
|
import { View, StyleSheet } from "react-native";
|
||||||
import React, { useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||||
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
||||||
import { Palette } from "../../styles";
|
import { Palette } from "../../styles";
|
||||||
import { VOICE } from "../../data/data";
|
import { VOICE } from "../../data/data";
|
||||||
import ListSelection from "../../components/ListSelection/ListSelection";
|
import ListSelection from "../../components/ListSelection/ListSelection";
|
||||||
|
|
||||||
const CustomizeVoice = ({ selected = {}, setSelected }) => {
|
const SECTION_INSTRUCTIONS = {
|
||||||
const [containerLayout, setContainerLayout] = useState(null);
|
BASE: "Choisis ta base",
|
||||||
|
SENSIBILITE: "Choisis ta sensibilité",
|
||||||
|
TECHNIQUE: "Choisis ta technique",
|
||||||
|
};
|
||||||
|
|
||||||
// Toggle select: only 1 per category (section.title)
|
const normalizeCategory = (value) => {
|
||||||
const onPressSelect = (category, item) => {
|
if (typeof value !== "string") return "";
|
||||||
if (!setSelected) return;
|
return value
|
||||||
const current = selected && typeof selected === "object" ? selected : {};
|
.normalize("NFD")
|
||||||
// If tapping the same item, deselect; otherwise, set new one for the category
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
const next = { ...current };
|
.toUpperCase();
|
||||||
if (current[category] === item) next[category] = null;
|
};
|
||||||
else next[category] = item;
|
|
||||||
setSelected(next);
|
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 (
|
return (
|
||||||
<View style={{ flex: 1, gap: 10, paddingTop: 16 }}>
|
<View style={{ flex: 1, gap: 10, paddingTop: 16 }}>
|
||||||
<CreateLyricsHeader
|
<CreateLyricsHeader
|
||||||
title="Personnalise la voix que tu veux pour ta chanson"
|
title="Personnalise la voix que tu veux pour ta chanson"
|
||||||
subTitle="Choisis maximum 1 option par catégorie."
|
subTitle={subtitle}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
@@ -33,10 +115,10 @@ const CustomizeVoice = ({ selected = {}, setSelected }) => {
|
|||||||
<ItemContainer height={containerLayout?.height}>
|
<ItemContainer height={containerLayout?.height}>
|
||||||
<View style={{ flex: 1, gap: 10 }}>
|
<View style={{ flex: 1, gap: 10 }}>
|
||||||
<ListSelection
|
<ListSelection
|
||||||
options={VOICE}
|
options={sectionData?.data || []}
|
||||||
variant="sectioned"
|
variant="simple"
|
||||||
selected={selected}
|
selected={currentValue}
|
||||||
setSelected={setSelected}
|
setSelected={handleSelectionChange}
|
||||||
contentContainerStyle={styles.contentContainer}
|
contentContainerStyle={styles.contentContainer}
|
||||||
itemContainerStyle={styles.itemContainer}
|
itemContainerStyle={styles.itemContainer}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ const GeneratingSong = () => {
|
|||||||
const askedRef = useRef(false);
|
const askedRef = useRef(false);
|
||||||
const callingRef = useRef(false);
|
const callingRef = useRef(false);
|
||||||
const localStartRef = useRef(null);
|
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
|
// project loaded from provider
|
||||||
|
|
||||||
@@ -249,6 +259,28 @@ const GeneratingSong = () => {
|
|||||||
startMusicGenerationOnce,
|
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 (
|
return (
|
||||||
<Page headerType="NONE" backgroundImg={background.studioBG2}>
|
<Page headerType="NONE" backgroundImg={background.studioBG2}>
|
||||||
<MusicLandHeader
|
<MusicLandHeader
|
||||||
@@ -303,17 +335,37 @@ const GeneratingSong = () => {
|
|||||||
Ta musique est{"\n"}en cours de création
|
Ta musique est{"\n"}en cours de création
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ alignItems: "center", gap: 16 }}>
|
<View style={{ alignItems: "center", gap: 16 }}>
|
||||||
<ProgressBar gradient progress={progress} />
|
<ProgressBar
|
||||||
|
gradient
|
||||||
|
progress={progress}
|
||||||
|
status={progressStatus}
|
||||||
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Palette.white,
|
color: isFailed ? Palette.red : Palette.white,
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{progress}%
|
{progressLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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
|
<GradientButton
|
||||||
title={
|
title={
|
||||||
selectedProject?.musicStatus === "GENERATED"
|
selectedProject?.musicStatus === "GENERATED"
|
||||||
|
|||||||
+120
-76
@@ -2,11 +2,21 @@
|
|||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
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 { responsiveHeight } from "react-native-responsive-dimensions";
|
||||||
import { background, icons } from "../../assets";
|
import { background, icons } from "../../assets";
|
||||||
import alert from "../../components/Alert";
|
import alert from "../../components/Alert";
|
||||||
import BorderGradientButton from "../../components/BorderGradientButton";
|
import BorderGradientButton from "../../components/BorderGradientButton";
|
||||||
|
import CreditAmount from "../../components/CreditAmount";
|
||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
import ValidateModal from "../../components/modal/ValidateModal";
|
import ValidateModal from "../../components/modal/ValidateModal";
|
||||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||||
@@ -233,21 +243,26 @@ const SongReady = () => {
|
|||||||
marginBottom: responsiveHeight(2),
|
marginBottom: responsiveHeight(2),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View style={{ gap: 16, marginTop: isWeb ? gutters * 3 : 0 }}>
|
<FlatList
|
||||||
{musicUrls.map((url, idx) => (
|
style={{ flex: 1 }}
|
||||||
|
data={musicUrls}
|
||||||
|
keyExtractor={(item, idx) => `${item || "song"}-${idx}`}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
<SongOptionCard
|
<SongOptionCard
|
||||||
key={`${url || "song"}-${idx}`}
|
index={index}
|
||||||
index={idx}
|
url={item}
|
||||||
url={url}
|
isSelected={selectedIndex === index}
|
||||||
isSelected={selectedIndex === idx}
|
|
||||||
onSelect={setSelectedIndex}
|
onSelect={setSelectedIndex}
|
||||||
registerPlayer={registerPlayer}
|
registerPlayer={registerPlayer}
|
||||||
onTogglePlayback={handleTogglePlayback}
|
onTogglePlayback={handleTogglePlayback}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</View>
|
contentContainerStyle={{ gap: 16, paddingBottom: gutters }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
extraData={selectedIndex}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -510,6 +525,18 @@ const SongOptionCard = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RegenerateModal = ({ visible, onClose, onConfirm }) => {
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
@@ -520,60 +547,51 @@ const RegenerateModal = ({ visible, onClose, onConfirm }) => {
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: gutters,
|
|
||||||
paddingVertical: gutters * 1.5,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CreateLyricsHeader
|
<ScrollView
|
||||||
containerStyle={{
|
bounces={false}
|
||||||
width: "100%",
|
contentContainerStyle={{
|
||||||
maxWidth: REGENERATE_MODAL_MAX_WIDTH,
|
flexGrow: 1,
|
||||||
alignSelf: "center",
|
justifyContent: "center",
|
||||||
paddingVertical: 24,
|
alignItems: "center",
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: horizontalPadding,
|
||||||
gap: 24,
|
paddingVertical: verticalPadding,
|
||||||
|
minHeight: height,
|
||||||
}}
|
}}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={{ gap: 24, alignItems: "center" }}>
|
<CreateLyricsHeader
|
||||||
<View
|
containerStyle={{
|
||||||
style={{
|
width: contentWidth ?? "100%",
|
||||||
gap: 12,
|
maxWidth: REGENERATE_MODAL_MAX_WIDTH,
|
||||||
alignItems: "center",
|
alignSelf: "center",
|
||||||
paddingHorizontal: 12,
|
paddingVertical: contentGap,
|
||||||
}}
|
paddingHorizontal: contentGap,
|
||||||
>
|
gap: contentGap,
|
||||||
<Text
|
flexShrink: 1,
|
||||||
style={{
|
}}
|
||||||
fontSize: 22,
|
>
|
||||||
color: Palette.white,
|
<View style={{ gap: contentGap, alignItems: "center" }}>
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Re-générer le morceau ?
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 16,
|
|
||||||
color: Palette.white,
|
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux.`}
|
|
||||||
</Text>
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
gap: isCompactWidth ? 10 : 12,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
paddingHorizontal: isCompactWidth ? 4 : 12,
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Re-générer le morceau ?
|
||||||
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -582,39 +600,65 @@ const RegenerateModal = ({ visible, onClose, onConfirm }) => {
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cette action coûte
|
{`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux.`}
|
||||||
</Text>
|
</Text>
|
||||||
<CreditAmount
|
<View
|
||||||
value={MUSIC_GENERATION_COIN_COST}
|
style={{
|
||||||
iconSize={18}
|
flexDirection: "row",
|
||||||
textStyle={{
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cette action coûte
|
||||||
|
</Text>
|
||||||
|
<CreditAmount
|
||||||
|
value={MUSIC_GENERATION_COIN_COST}
|
||||||
|
iconSize={18}
|
||||||
|
textStyle={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
Les crédits seront utilisés lors de l'étape de génération.
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<View
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
width: isCompactWidth ? "100%" : "85%",
|
||||||
color: Palette.white,
|
alignSelf: "center",
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
gap: buttonGap,
|
||||||
textAlign: "center",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Les crédits seront utilisés lors de l'étape de génération.
|
<GradientButton
|
||||||
</Text>
|
title="Oui, modifier mes choix"
|
||||||
|
onPress={onConfirm}
|
||||||
|
/>
|
||||||
|
<BorderGradientButton title="Retour" onPress={onClose} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
|
</CreateLyricsHeader>
|
||||||
<GradientButton
|
</ScrollView>
|
||||||
title="Oui, modifier mes choix"
|
|
||||||
onPress={onConfirm}
|
|
||||||
/>
|
|
||||||
<BorderGradientButton title="Retour" onPress={onClose} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</CreateLyricsHeader>
|
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ import { getStageAction } from "../../utils/projectStages";
|
|||||||
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||||
|
|
||||||
const COVER_STYLE_PRESETS = [
|
const COVER_STYLE_PRESETS = [
|
||||||
"Néo-Néon / Cyberpunk",
|
"Cyberpunk",
|
||||||
"Photographie Minimaliste & Éditoriale",
|
"Dessins animé",
|
||||||
"Illustration & Collage Surréaliste",
|
"Dessins animé rétro",
|
||||||
"Anti-Design / Maximalisme (Tendance Actuelle)",
|
"Portrait théâtralisé",
|
||||||
|
"Livre de coloriage",
|
||||||
|
"Shooting",
|
||||||
];
|
];
|
||||||
|
|
||||||
const PouchReady = () => {
|
const PouchReady = () => {
|
||||||
@@ -301,10 +303,7 @@ const PouchReady = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page backgroundImg={background.studioBG2} headerType="NONE">
|
||||||
backgroundImg={isWeb ? background.productionBG2 : background.studioBG}
|
|
||||||
headerType="NONE"
|
|
||||||
>
|
|
||||||
<MusicLandHeader onPressBack={goBack} progress={72} />
|
<MusicLandHeader onPressBack={goBack} progress={72} />
|
||||||
<View style={{ flex: 1, marginTop: 0 }}>
|
<View style={{ flex: 1, marginTop: 0 }}>
|
||||||
<CreateLyricsHeader
|
<CreateLyricsHeader
|
||||||
|
|||||||
+38
-38
@@ -1,15 +1,15 @@
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from "expo-router";
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
import AppleSignInButton from '../../components/buttons/AppleSignInButton';
|
import AppleSignInButton from "../../components/buttons/AppleSignInButton";
|
||||||
import Button from '../../components/buttons/Button';
|
import Button from "../../components/buttons/Button";
|
||||||
import GoogleSignInButton from '../../components/buttons/GoogleSignInButton';
|
import GoogleSignInButton from "../../components/buttons/GoogleSignInButton";
|
||||||
import AppInput from '../../components/inputs/Input';
|
import AppInput from "../../components/inputs/Input";
|
||||||
import PublicScreenLayout from '../../components/layout/PublicScreenLayout';
|
import PublicScreenLayout from "../../components/layout/PublicScreenLayout";
|
||||||
import useRegisterForm from '../../hooks/useRegisterForm';
|
import useRegisterForm from "../../hooks/useRegisterForm";
|
||||||
import Fonts from '../../styles/Fonts';
|
import Fonts from "../../styles/Fonts";
|
||||||
import Palette from '../../styles/Palette';
|
import Palette from "../../styles/Palette";
|
||||||
|
|
||||||
export default function RegisterScreen() {
|
export default function RegisterScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -32,7 +32,7 @@ export default function RegisterScreen() {
|
|||||||
router.back();
|
router.back();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace('/(public)/login');
|
router.replace("/(public)/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,10 +52,10 @@ export default function RegisterScreen() {
|
|||||||
type="email"
|
type="email"
|
||||||
containerStyle={styles.input}
|
containerStyle={styles.input}
|
||||||
textInputProps={{
|
textInputProps={{
|
||||||
autoCapitalize: 'none',
|
autoCapitalize: "none",
|
||||||
autoComplete: 'email',
|
autoComplete: "email",
|
||||||
textContentType: 'emailAddress',
|
textContentType: "emailAddress",
|
||||||
name: 'email',
|
name: "email",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -67,9 +67,9 @@ export default function RegisterScreen() {
|
|||||||
type="password"
|
type="password"
|
||||||
containerStyle={styles.input}
|
containerStyle={styles.input}
|
||||||
textInputProps={{
|
textInputProps={{
|
||||||
autoComplete: 'new-password',
|
autoComplete: "new-password",
|
||||||
textContentType: 'newPassword',
|
textContentType: "newPassword",
|
||||||
name: 'new-password',
|
name: "new-password",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -81,9 +81,9 @@ export default function RegisterScreen() {
|
|||||||
type="password"
|
type="password"
|
||||||
containerStyle={styles.input}
|
containerStyle={styles.input}
|
||||||
textInputProps={{
|
textInputProps={{
|
||||||
autoComplete: 'new-password',
|
autoComplete: "new-password",
|
||||||
textContentType: 'newPassword',
|
textContentType: "newPassword",
|
||||||
name: 'confirm-password',
|
name: "confirm-password",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -129,28 +129,28 @@ export default function RegisterScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
body: {
|
body: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
gap: 24,
|
gap: 24,
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
heading: {
|
heading: {
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: '700',
|
fontWeight: "700",
|
||||||
color: Palette.darkPurple,
|
color: Palette.darkPurple,
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
subheading: {
|
subheading: {
|
||||||
...Fonts.bodySmall,
|
...Fonts.bodySmall,
|
||||||
color: 'rgba(15, 12, 20, 0.6)',
|
color: "rgba(15, 12, 20, 0.6)",
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
width: '100%',
|
width: "100%",
|
||||||
maxWidth: 420,
|
maxWidth: 420,
|
||||||
gap: 16,
|
gap: 16,
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
width: '100%',
|
width: "100%",
|
||||||
},
|
},
|
||||||
primaryButton: {
|
primaryButton: {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
@@ -160,28 +160,28 @@ const styles = StyleSheet.create({
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
},
|
},
|
||||||
socialDivider: {
|
socialDivider: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
},
|
},
|
||||||
dividerLine: {
|
dividerLine: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: StyleSheet.hairlineWidth,
|
height: StyleSheet.hairlineWidth,
|
||||||
backgroundColor: 'rgba(15, 12, 20, 0.15)',
|
backgroundColor: "rgba(15, 12, 20, 0.15)",
|
||||||
},
|
},
|
||||||
dividerText: {
|
dividerText: {
|
||||||
...Fonts.tinyBold,
|
...Fonts.tinyBold,
|
||||||
textTransform: 'uppercase',
|
textTransform: "uppercase",
|
||||||
color: 'rgba(15, 12, 20, 0.45)',
|
color: "rgba(15, 12, 20, 0.45)",
|
||||||
},
|
},
|
||||||
socialButton: {
|
socialButton: {
|
||||||
width: '100%',
|
width: "100%",
|
||||||
},
|
},
|
||||||
hint: {
|
hint: {
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
...Fonts.bodySmall,
|
...Fonts.bodySmall,
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
color: 'rgba(15, 12, 20, 0.6)',
|
color: "rgba(15, 12, 20, 0.6)",
|
||||||
},
|
},
|
||||||
secondaryButton: {
|
secondaryButton: {
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
|
|||||||
Reference in New Issue
Block a user