fix of the day, payment + sub + music generation
This commit is contained in:
@@ -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
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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%",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user