fix tickets add reort mail and design fixes

This commit is contained in:
Thomas Demirdjian
2025-10-29 15:36:12 +01:00
parent d1be3c19a2
commit ce285881ca
35 changed files with 1069 additions and 202 deletions
+29
View File
@@ -0,0 +1,29 @@
# Étape 2 Blocage après "Suivant"
## Reproduction
- Plateforme : Web & mobile (Expo) navigation `Routes.CreateLyricsWithAi`.
- Préconditions : Projet sans paroles existantes.
- Étapes :
1. Lancer l'atelier d'écriture et sélectionner un objectif (Étape 1).
2. Renseigner un contexte dans l'input (Étape 2).
3. Appuyer sur `Suivant`.
- Résultat observé : le bouton devient inactif, l'écran reste figé sur l'étape 2 et il est impossible de poursuivre car le swipe est désactivé.
## Analyse & cause racine
- L'étape dépend de `SwiperFlatList` avec `disableGesture={true}` : seule la navigation programmée via `scrollToIndex` permet d'avancer.
- L'appel `scrollRef.current.scrollToIndex` levait par intermittence une exception (`Invariant Violation: scrollToIndex out of range`) lorsque la mesure des vues n'était pas prête au moment du clic.
- L'erreur était avalée silencieusement (`catch {}`) ce qui laissait l'index interne sur `1` et donc l'utilisateur bloqué sur Étape 2.
## Correctif
- Ajout d'une logique de re-tentative autour de `scrollToIndex` avec journalisation pour diagnostiquer les cas anormaux.
- Clamp de l'index (`Math.min(idx + 1, MAX_STEP_INDEX)`) pour éviter toute sortie de plage.
- Progression recalculée en même temps que la navigation afin d'éviter les états intermédiaires incohérents.
## Validation manuelle
1. Rejouer le scénario de reproduction sur web et mobile (Expo Go).
2. Vérifier la console : absence d'avertissements `[CreateLyricsWithAi] scrollToIndex failed`.
3. Confirmer que l'écran passe correctement à "Étape 3: Quelle émotion veux-tu transmettre ?" et que le bouton `Suivant` se réactive après sélection d'une émotion.
4. Revenir en arrière (`Précédent`) pour s'assurer que l'étape 2 reste éditable et que l'avancement se met à jour (progression dynamique).
## Points de suivi
- Ajouter une suite E2E complète une fois l'infrastructure de tests dispo (Detox/Playwright) pour vérifier la navigation multi-étapes sans interaction manuelle.
Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y"; exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y";
// exports.SUNO_API_KEY = "543b5650b30d93e98d8046a830244c80"; client api key
exports.SUNO_API_KEY = "5a689606d3c59f58268b4963d5107361"; // api key exports.SUNO_API_KEY = "5a689606d3c59f58268b4963d5107361"; // api key
exports.RESEND_API_KEY = "re_"; exports.RESEND_API_KEY = "re_";
+15
View File
@@ -1,3 +1,12 @@
const fs = require("fs");
const path = require("path");
const musicLandLogoBase64 = fs.readFileSync(
path.join(__dirname, "../assets/musicLandLogo.png"),
{ encoding: "base64" },
);
const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`;
function basicTemplate({ title = "", content = "", button = null }) { function basicTemplate({ title = "", content = "", button = null }) {
const btn = button?.url const btn = button?.url
? `<p><a href="${button.url}" style="display:inline-block;padding:10px 16px;background:#6C5CE7;color:#fff;border-radius:8px;text-decoration:none">${ ? `<p><a href="${button.url}" style="display:inline-block;padding:10px 16px;background:#6C5CE7;color:#fff;border-radius:8px;text-decoration:none">${
@@ -6,6 +15,9 @@ function basicTemplate({ title = "", content = "", button = null }) {
: ""; : "";
return `<!doctype html><html><body style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;color:#111"> return `<!doctype html><html><body style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;color:#111">
<div style="max-width:640px;margin:40px auto;padding:24px;border:1px solid #eee;border-radius:12px"> <div style="max-width:640px;margin:40px auto;padding:24px;border:1px solid #eee;border-radius:12px">
<div style="text-align:center;margin-bottom:20px">
<img src="${musicLandLogoSrc}" alt="MusicLand" style="height:40px" />
</div>
<h2 style="margin:0 0 16px">${title}</h2> <h2 style="margin:0 0 16px">${title}</h2>
<div>${content}</div> <div>${content}</div>
${btn} ${btn}
@@ -22,6 +34,9 @@ function welcomeTemplate({ firstName = "", lastName = "" }) {
<div style="max-width:640px;margin:0 auto;padding:40px 24px"> <div style="max-width:640px;margin:0 auto;padding:40px 24px">
<div style="background:#151520;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)"> <div style="background:#151520;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)">
<div style="padding:32px 28px"> <div style="padding:32px 28px">
<div style="text-align:center;margin-bottom:24px">
<img src="${musicLandLogoSrc}" alt="MusicLand" style="height:48px" />
</div>
<p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px">${greeting}</p> <p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px">${greeting}</p>
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.2">Bienvenue sur MusicLand</h1> <h1 style="margin:0 0 16px;font-size:28px;line-height:1.2">Bienvenue sur MusicLand</h1>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:rgba(255,255,255,0.85)"> <p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:rgba(255,255,255,0.85)">
+5
View File
@@ -27,6 +27,7 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
receiverCollection = "users", receiverCollection = "users",
message = "", message = "",
data: notifData = {}, data: notifData = {},
mailOnly = false,
} = event.data.data(); } = event.data.data();
if (!receiver || !message) { if (!receiver || !message) {
@@ -40,6 +41,7 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
emailNotifications = false, emailNotifications = false,
} = (await db.collection(receiverCollection).doc(receiver).get()).data(); } = (await db.collection(receiverCollection).doc(receiver).get()).data();
if (!mailOnly) {
const tokensSet = new Set( const tokensSet = new Set(
[] []
.concat(Array.isArray(pushTokens) ? pushTokens : []) .concat(Array.isArray(pushTokens) ? pushTokens : [])
@@ -61,6 +63,7 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
message: message, message: message,
data: notifData || {}, data: notifData || {},
}); });
}
if (emailNotifications && !!email) { if (emailNotifications && !!email) {
try { try {
if (!email) { if (!email) {
@@ -219,6 +222,7 @@ const sendNotification = async ({
receiverCollection = "users", receiverCollection = "users",
title = "", title = "",
message = null, message = null,
mailOnly = false,
data = {}, data = {},
}) => { }) => {
try { try {
@@ -234,6 +238,7 @@ const sendNotification = async ({
time: admin.firestore.FieldValue.serverTimestamp(), time: admin.firestore.FieldValue.serverTimestamp(),
read: false, read: false,
readAt: null, readAt: null,
mailOnly,
data, data,
}; };
const { id } = await refList.notifications.add(payload); const { id } = await refList.notifications.add(payload);
+7 -1
View File
@@ -23,12 +23,18 @@ exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
return; return;
} }
try { try {
await resendClient.emails.send({ console.log(`Sending welcome email to ${email}`);
const { data, error } = await resendClient.emails.send({
from: WELCOME_EMAIL_FROM, from: WELCOME_EMAIL_FROM,
to: [email], to: [email],
subject: WELCOME_EMAIL_SUBJECT, subject: WELCOME_EMAIL_SUBJECT,
html: welcomeTemplate({ firstName, lastName }), html: welcomeTemplate({ firstName, lastName }),
}); });
if (error) {
console.log("Failed to send welcome email:", error);
return;
}
console.log("Welcome email sent:", data);
} catch (error) { } catch (error) {
console.log("Failed to send welcome email:", error); console.log("Failed to send welcome email:", error);
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

+16 -3
View File
@@ -1,26 +1,38 @@
import { Portal } from "@gorhom/portal"; import { Portal } from "@gorhom/portal";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import React from "react"; import React, { useCallback } from "react";
import { Platform, Pressable, StyleSheet, View } from "react-native"; import { Platform, Pressable, StyleSheet, View } from "react-native";
import ActionSheet from "react-native-actions-sheet"; import ActionSheet, { SheetManager } from "react-native-actions-sheet";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { isWeb } from "../hooks/useLayoutType"; import { isWeb } from "../hooks/useLayoutType";
import { gutters, Palette } from "../styles"; import { gutters, Palette } from "../styles";
const AppActionSheet = ({ const AppActionSheet = ({
id,
children, children,
webModal = false, webModal = false,
onClose = () => {}, onClose = () => {},
...sheetProps ...sheetProps
}) => { }) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const handleRequestClose = useCallback(() => {
if (id) {
Promise.resolve(SheetManager.hide(id))
.catch(() => {})
.finally(() => {
onClose?.();
});
return;
}
onClose?.();
}, [id, onClose]);
if (isWeb && webModal) { if (isWeb && webModal) {
return ( return (
<Portal> <Portal>
<View style={styles.webOverlay}> <View style={styles.webOverlay}>
<Pressable <Pressable
onPress={onClose} onPress={handleRequestClose}
accessibilityRole="button" accessibilityRole="button"
style={styles.webBackdrop} style={styles.webBackdrop}
/> />
@@ -36,6 +48,7 @@ const AppActionSheet = ({
return ( return (
<ActionSheet <ActionSheet
id={id}
containerStyle={{ backgroundColor: Palette.tran }} containerStyle={{ backgroundColor: Palette.tran }}
gestureEnabled gestureEnabled
indicatorStyle={{ indicatorStyle={{
+21 -3
View File
@@ -6,6 +6,18 @@ import { FONT_FAMILY } from "../styles/Fonts";
import { size } from "../styles/Style"; import { size } from "../styles/Style";
import BorderGradient from "./BorderGradient/BorderGradient"; import BorderGradient from "./BorderGradient/BorderGradient";
const HEIGHT_BY_SIZE = {
small: 40,
medium: 50,
large: 58,
};
const FONT_SIZE_BY_SIZE = {
small: 13,
medium: 15,
large: 17,
};
const BorderGradientButton = ({ const BorderGradientButton = ({
title = "Jai déjà mes paroles", title = "Jai déjà mes paroles",
onPress, onPress,
@@ -15,7 +27,13 @@ const BorderGradientButton = ({
tint = "dark", tint = "dark",
disabled = false, disabled = false,
maxWidth = null, maxWidth = null,
size = "medium",
}) => { }) => {
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
const buttonHeight = HEIGHT_BY_SIZE[resolvedSize];
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
const iconSize = resolvedSize === "small" ? 14 : 16;
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
@@ -34,7 +52,7 @@ const BorderGradientButton = ({
locations: [0, 1], locations: [0, 1],
}} }}
style={{ style={{
height: 50, height: buttonHeight,
borderRadius: 14, borderRadius: 14,
borderWidth: 1, borderWidth: 1,
zIndex: 1, zIndex: 1,
@@ -61,10 +79,10 @@ const BorderGradientButton = ({
gap: 11, gap: 11,
}} }}
> >
{icon && <Image source={icon} style={size({ size: 16 })} />} {icon && <Image source={icon} style={size({ size: iconSize })} />}
<Text <Text
style={{ style={{
fontSize: 15, fontSize,
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium, fontFamily: FONT_FAMILY.InterMedium,
...titleStyle, ...titleStyle,
+23 -2
View File
@@ -5,6 +5,18 @@ import { FONT_FAMILY } from "../styles/Fonts";
import { size } from "../styles/Style"; import { size } from "../styles/Style";
import { LinearGradient } from "./LinearGradient/LinearGradient"; import { LinearGradient } from "./LinearGradient/LinearGradient";
const HEIGHT_BY_SIZE = {
small: 44,
medium: 50,
large: 58,
};
const FONT_SIZE_BY_SIZE = {
small: 14,
medium: 15,
large: 17,
};
const GradientButton = ({ const GradientButton = ({
title = "", title = "",
colors = ["#F94697", "#7023F7"], colors = ["#F94697", "#7023F7"],
@@ -14,7 +26,14 @@ const GradientButton = ({
icon, icon,
disabled = false, disabled = false,
maxWidth = null, maxWidth = null,
size = "medium",
textStyle = {},
gradientStyle = {},
}) => { }) => {
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
const buttonHeight = HEIGHT_BY_SIZE[resolvedSize];
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
@@ -30,9 +49,10 @@ const GradientButton = ({
style={{ style={{
...Style.containerCenter, ...Style.containerCenter,
...Style.containerRow, ...Style.containerRow,
height: 50, height: buttonHeight,
borderRadius: 14, borderRadius: 14,
gap: 10, gap: 10,
...gradientStyle,
}} }}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
@@ -41,10 +61,11 @@ const GradientButton = ({
{icon && <Image source={icon} style={size({ size: 16 })} />} {icon && <Image source={icon} style={size({ size: 16 })} />}
<Text <Text
style={{ style={{
fontSize: 15, fontSize,
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium, fontFamily: FONT_FAMILY.InterMedium,
paddingHorizontal: 10, paddingHorizontal: 10,
...textStyle,
}} }}
> >
{title} {title}
@@ -166,6 +166,7 @@ const ListSelection = ({
const itemKey = buildItemKey(String(val ?? ""), cat); const itemKey = buildItemKey(String(val ?? ""), cat);
const isHovered = isWeb && !disableHover && hoveredKey === itemKey; const isHovered = isWeb && !disableHover && hoveredKey === itemKey;
const showHoverOutline = isHovered && !sel; const showHoverOutline = isHovered && !sel;
const showSelectionOutline = !isWeb && sel;
const baseContent = renderSimpleContent(item); const baseContent = renderSimpleContent(item);
const headerContainerStyle = { const headerContainerStyle = {
@@ -205,7 +206,7 @@ const ListSelection = ({
{withWebBlueGradientIfSelected(baseContent, sel)} {withWebBlueGradientIfSelected(baseContent, sel)}
</CreateLyricsHeader> </CreateLyricsHeader>
{showHoverOutline && ( {(showHoverOutline || showSelectionOutline) && (
<View <View
pointerEvents="none" pointerEvents="none"
style={{ style={{
+6 -2
View File
@@ -6,7 +6,11 @@ import { FONT_FAMILY } from "../../styles/Fonts";
import { Entypo } from "@expo/vector-icons"; import { Entypo } from "@expo/vector-icons";
import { openShareSheet } from "../../utils/shareSheet"; import { openShareSheet } from "../../utils/shareSheet";
export default function ShareBtn({ style, onPress = null }) { export default function ShareBtn({
style,
onPress = null,
label = "Partager lexpérience",
}) {
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
if (typeof onPress === "function") { if (typeof onPress === "function") {
onPress(); onPress();
@@ -41,7 +45,7 @@ export default function ShareBtn({ style, onPress = null }) {
marginRight: 5, marginRight: 5,
}} }}
> >
{"Partager lexpérience"} {label}
</Text> </Text>
<Entypo name="share-alternative" size={16} color="white" /> <Entypo name="share-alternative" size={16} color="white" />
</BlurView> </BlurView>
+3 -3
View File
@@ -30,7 +30,7 @@ const DeleteAccountModal = () => {
onPress: () => { onPress: () => {
alert( alert(
"Confirmation", "Confirmation",
"Dernière vérification: supprimer définitivement votre profil ?", "Dernière vérification : souhaitez-vous supprimer définitivement votre profil ?",
[ [
{ text: "Non", style: "cancel", onPress: () => {} }, { text: "Non", style: "cancel", onPress: () => {} },
{ {
@@ -85,8 +85,8 @@ const DeleteAccountModal = () => {
textAlign: "center", textAlign: "center",
}} }}
> >
Es-tu sur de vouloir supprimer ton compte?{"\n"}Attention, cette Es-tu sûr de vouloir supprimer ton compte ?{"\n"}Attention, cette
action est irréversible! action est irréversible !
</Text> </Text>
</View> </View>
<View style={{ width: "80%", alignSelf: "center", gap: 12 }}> <View style={{ width: "80%", alignSelf: "center", gap: 12 }}>
+268
View File
@@ -0,0 +1,268 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { SheetManager } from "react-native-actions-sheet";
import {
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { useGlobal } from "reactn";
import BorderGradientButton from "../BorderGradientButton";
import GradientButton from "../GradientButton";
import { REPORT_OPTIONS } from "../../constants/reportOptions";
import { reportsRef, serverTimestamp } from "../../config/firebase";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
const SHEET_ID = "Report";
const ReportModal = ({ payload }) => {
const [, setTooltip] = useGlobal("_tooltip");
const [selectedReason, setSelectedReason] = useState(REPORT_OPTIONS[0].key);
const [details, setDetails] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const resetForm = useCallback(() => {
setSelectedReason(REPORT_OPTIONS[0].key);
setDetails("");
setIsSubmitting(false);
}, []);
const targetContext = useMemo(() => {
return {
type: payload?.targetType || "music",
projectId: payload?.projectId || null,
playbackId: payload?.playbackId || null,
title: payload?.title || "",
ownerId: payload?.ownerId || null,
};
}, [payload]);
const closeSheet = useCallback(() => {
setIsVisible(false);
Promise.resolve(SheetManager.hide(SHEET_ID))
.catch(() => {})
.finally(() => {
resetForm();
});
}, [resetForm]);
useEffect(() => {
setIsVisible(true);
}, [payload]);
const handleSubmit = useCallback(async () => {
if (isSubmitting) return;
if (selectedReason === "other" && !details.trim()) return;
try {
setIsSubmitting(true);
const selectedOption = REPORT_OPTIONS.find(
(option) => option.key === selectedReason,
);
await reportsRef.add({
...targetContext,
reason: selectedReason,
reasonLabel: selectedOption?.label || selectedReason,
details: details.trim() || null,
platform: Platform.OS,
createdAt: serverTimestamp(),
});
setTooltip({
type: "success",
text: "Merci pour ton signalement, notre équipe va l'examiner.",
});
closeSheet();
} catch (error) {
setTooltip({
type: "error",
text:
error?.message ||
"Impossible d'envoyer le signalement pour le moment.",
});
} finally {
setIsSubmitting(false);
}
}, [
details,
closeSheet,
isSubmitting,
selectedReason,
setTooltip,
targetContext,
]);
const showDetailsField = selectedReason === "other";
return (
<Modal
visible={isVisible}
transparent
animationType="fade"
onRequestClose={closeSheet}
onDismiss={resetForm}
>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={closeSheet} />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.modalWrapper}
>
<View style={styles.card}>
<View style={{ gap: 8 }}>
<Text style={styles.heading}>Signaler ce contenu</Text>
{targetContext?.title ? (
<Text style={styles.subheading}>{targetContext.title}</Text>
) : null}
</View>
<View style={{ gap: 12 }}>
{REPORT_OPTIONS.map((option) => {
const isSelected = option.key === selectedReason;
return (
<Pressable
key={option.key}
style={[
styles.optionRow,
isSelected ? styles.optionRowSelected : null,
]}
onPress={() => setSelectedReason(option.key)}
>
<View
style={[
styles.radio,
isSelected ? styles.radioSelected : null,
]}
/>
<Text style={styles.optionLabel}>{option.label}</Text>
</Pressable>
);
})}
</View>
{showDetailsField ? (
<View style={{ gap: 6 }}>
<Text style={styles.fieldLabel}>Détails (optionnel)</Text>
<TextInput
style={styles.textArea}
placeholder="Explique-nous rapidement le problème…"
placeholderTextColor="rgba(255, 255, 255, 0.55)"
multiline
onChangeText={setDetails}
value={details}
/>
</View>
) : null}
<View style={{ gap: 12 }}>
<GradientButton
title={isSubmitting ? "Envoi en cours…" : "Envoyer"}
onPress={handleSubmit}
disabled={
isSubmitting ||
(selectedReason === "other" && details.trim().length === 0)
}
gradientStyle={{ minWidth: 160 }}
/>
<BorderGradientButton
title="Annuler"
onPress={closeSheet}
containerStyle={{ width: "100%" }}
/>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "center",
paddingHorizontal: 24,
position: "relative",
},
backdrop: {
...StyleSheet.absoluteFillObject,
},
modalWrapper: {
justifyContent: "center",
alignItems: "center",
},
card: {
gap: 20,
borderRadius: 24,
padding: 24,
backgroundColor: "rgba(18, 16, 24, 0.94)",
width: "100%",
maxWidth: 420,
alignSelf: "center",
},
heading: {
fontSize: 20,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueMedium,
textAlign: "center",
},
subheading: {
fontSize: 16,
color: Palette.transparentWhite,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
},
optionRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 12,
backgroundColor: "rgba(255, 255, 255, 0.05)",
gap: 12,
},
optionRowSelected: {
backgroundColor: "rgba(251, 104, 168, 0.18)",
},
radio: {
width: 16,
height: 16,
borderRadius: 8,
borderWidth: 2,
borderColor: Palette.transparentWhite,
},
radioSelected: {
borderColor: Palette.primary,
backgroundColor: Palette.primary,
},
optionLabel: {
flex: 1,
fontSize: 15,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
fieldLabel: {
fontSize: 14,
color: Palette.transparentWhite,
fontFamily: FONT_FAMILY.InterMedium,
},
textArea: {
minHeight: 90,
borderRadius: 12,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
padding: 12,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
textAlignVertical: "top",
backgroundColor: "rgba(0, 0, 0, 0.25)",
},
});
export default ReportModal;
+1
View File
@@ -52,6 +52,7 @@ export const playlistsRef = firestore.collection("playlists");
export const chatsRef = firestore.collection("chats"); export const chatsRef = firestore.collection("chats");
export const videosRef = firestore.collection("videos"); export const videosRef = firestore.collection("videos");
export const notificationsRef = firestore.collection("notifications"); export const notificationsRef = firestore.collection("notifications");
export const reportsRef = firestore.collection("reports");
export const { arrayUnion, arrayRemove, increment, serverTimestamp } = export const { arrayUnion, arrayRemove, increment, serverTimestamp } =
firebase.firestore.FieldValue; firebase.firestore.FieldValue;
+2
View File
@@ -1,4 +1,5 @@
import { Palette } from "../styles"; import { Palette } from "../styles";
import { musiclandProductionBaseUrl } from "../data";
export default { export default {
currentUID: null, currentUID: null,
@@ -16,6 +17,7 @@ export default {
url: null, url: null,
title: "", title: "",
}, },
productionBaseUrl: musiclandProductionBaseUrl,
_isLoading: false, _isLoading: false,
_loadingMessage: null, _loadingMessage: null,
+18
View File
@@ -0,0 +1,18 @@
export const REPORT_OPTIONS = [
{
key: "copyright",
label: "Ce morceau enfreint mes droits d'auteur",
},
{
key: "problematic_content",
label: "Le contenu de ce morceau est problématique",
},
{
key: "spam",
label: "Il s'agit de spam ou de contenu promotionnel non sollicité",
},
{
key: "other",
label: "Autre…",
},
];
+35
View File
@@ -0,0 +1,35 @@
export const strings = {
writing: {
steps: {
contextTitle: "Étape 2: Quel est le contexte ?",
contextSubtitle:
"Ajoute quelques détails pour nous guider (ex.: anniversaire, équipe de sport ou dentreprise…)",
contextExamples:
"Exemples : « Anniversaire de Clara ton joyeux et pop », « Hymne d’équipe style rap énergique », « Esprit dentreprise ambiance motivante »",
emotionTitle: "Étape 3: Quelle émotion veux-tu transmettre ?",
emotionSubtitle: "Sélectionne une seule intention émotionnelle.",
},
labels: {
otherObjective: "As-tu un autre objectif ?",
otherObjectivePlaceholder: "Décrire lobjectif",
},
onboarding: {
heroTitle: "Bienvenue dans ton atelier d'écriture",
heroSubtitle:
"C'est ici qu'ensemble nous allons écrire ta chanson, alors vas-y, suis le Guide et Étape 1 Quel est ton objectif?",
primaryCta: "Je crée mes paroles",
secondaryCta: "Jai déjà mes paroles",
secondaryNote:
"*Si tu as déjà des paroles tu auras loccasion de les intégrer le moment venu*",
},
lyrics: {
title: "Proposition de paroles",
instructions:
"Relis et personnalise les paroles proposées. Tu peux soit personnaliser la version actuelle, soit lancer une nouvelle génération.",
personalizationBanner:
"Personnalisation : tu peux modifier chaque section et si tu as tes paroles intègre les à la structure proposée",
},
},
};
export default strings;
+3 -1
View File
@@ -13,7 +13,9 @@ export const firebaseDashboardUrl =
export const appleAppStoreUrl = "https://apps.apple.com/app"; export const appleAppStoreUrl = "https://apps.apple.com/app";
export const algoliaAppUrl = "https://dashboard.algolia.com/apps"; export const algoliaAppUrl = "https://dashboard.algolia.com/apps";
export const musiclandShareUrl = "https://musicland.ai"; export const musiclandProductionBaseUrl =
"https://musicland-one.vercel.app";
export const musiclandShareUrl = musiclandProductionBaseUrl;
export const musiclandShareHeading = "Partagez l'expérience MusicLand"; export const musiclandShareHeading = "Partagez l'expérience MusicLand";
export const musiclandShareMessage = export const musiclandShareMessage =
"Invite tes proches a decouvrir MusicLand et cree des experiences musicales ensemble."; "Invite tes proches a decouvrir MusicLand et cree des experiences musicales ensemble.";
+2
View File
@@ -54,6 +54,8 @@ const HIDDEN_ROUTE_NAMES = new Set([
Routes.ChangePassword, Routes.ChangePassword,
Routes.Notifications, Routes.Notifications,
Routes.Language, Routes.Language,
Routes.Login,
Routes.Register,
]); ]);
const noopAsync = async () => {}; const noopAsync = async () => {};
+31 -6
View File
@@ -2,7 +2,8 @@ import { useContext, useState, useEffect } from "reactn";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import { UserDataContext } from "./UserDataProvider"; import { UserDataContext } from "./UserDataProvider";
import { isWeb } from "../hooks/useLayoutType"; import { isWeb } from "../hooks/useLayoutType";
import { navigateToTask } from "../navigation/NavigationService"; import { navigate, navigateToTask } from "../navigation/NavigationService";
import { Routes } from "../navigation/Routes";
import { SplashAnimationContext } from "./SplashAnimationProvider"; import { SplashAnimationContext } from "./SplashAnimationProvider";
const appJson = require("../../app.json"); const appJson = require("../../app.json");
@@ -16,6 +17,7 @@ const UniversalLinkProvider = ({ children }) => {
const { isFullyLoaded } = useContext(SplashAnimationContext); const { isFullyLoaded } = useContext(SplashAnimationContext);
const [tempTaskData, setTempTaskData] = useState(null); const [tempTaskData, setTempTaskData] = useState(null);
const [pendingMusicId, setPendingMusicId] = useState(null);
useEffect(() => { useEffect(() => {
const handleDeepLink = async (event) => { const handleDeepLink = async (event) => {
@@ -51,13 +53,13 @@ const UniversalLinkProvider = ({ children }) => {
} }
} catch (error) { } catch (error) {
console.error("Redirection error:", error); console.error("Redirection error:", error);
handleParams(path); handleParams(path, queryParams);
} }
} else { } else {
handleParams(path); handleParams(path, queryParams);
} }
} else { } else {
handleParams(path); handleParams(path, queryParams);
} }
}; };
@@ -86,8 +88,31 @@ const UniversalLinkProvider = ({ children }) => {
} }
}, [tempTaskData, currentUID, isFullyLoaded]); }, [tempTaskData, currentUID, isFullyLoaded]);
const handleParams = (path) => { useEffect(() => {
// console.log("path", path); if (!pendingMusicId || !isFullyLoaded) {
return;
}
navigate(Routes.MusicDetails, {
projectId: pendingMusicId,
autoPlay: true,
action: "share",
});
setPendingMusicId(null);
}, [isFullyLoaded, pendingMusicId]);
const handleParams = (path, _queryParams = {}) => {
if (typeof path === "string" && path.length > 0) {
const normalized = path.replace(/^\/+/, "");
const [first, second] = normalized.split("/");
if (first?.toLowerCase() === "music" && second) {
try {
setPendingMusicId(decodeURIComponent(second));
} catch (_) {
setPendingMusicId(second);
}
}
}
cleanURL(); cleanURL();
}; };
+80 -2
View File
@@ -17,6 +17,7 @@ import {
import { useGlobal } from "reactn"; import { useGlobal } from "reactn";
import { background, icons } from "../../assets"; import { background, icons } from "../../assets";
import PressableScale from "../../components/PressableScale"; import PressableScale from "../../components/PressableScale";
import ShareBtn from "../../components/ShareBtn/ShareBtn";
import Slider from "../../components/Slider"; import Slider from "../../components/Slider";
import { import {
arrayRemove, arrayRemove,
@@ -37,6 +38,11 @@ import {
getSegmentMeta, getSegmentMeta,
normalizeStructureType, normalizeStructureType,
} from "../../utils/songStructure"; } from "../../utils/songStructure";
import {
createMusicSharePayload,
openShareSheet,
} from "../../utils/shareSheet";
import { Feather } from "@expo/vector-icons";
// 20 secondes // 20 secondes
const timeBeforeIncrement = 20000; const timeBeforeIncrement = 20000;
@@ -44,6 +50,7 @@ const timeBeforeIncrement = 20000;
const MusicDetails = ({ route }) => { const MusicDetails = ({ route }) => {
const { params } = route || {}; const { params } = route || {};
const action = params?.action; const action = params?.action;
const autoPlayRequested = params?.autoPlay;
const projectId = params?.projectId || null; const projectId = params?.projectId || null;
const [fav, setFav] = useState(false); const [fav, setFav] = useState(false);
const [currentUID] = useGlobal("currentUID"); const [currentUID] = useGlobal("currentUID");
@@ -51,6 +58,7 @@ const MusicDetails = ({ route }) => {
const listenedMsRef = useRef(0); const listenedMsRef = useRef(0);
const incrementDoneRef = useRef(false); const incrementDoneRef = useRef(false);
const timerRef = useRef(null); const timerRef = useRef(null);
const hasAutoPlayedRef = useRef(false);
const { data: project } = useDataFromRef({ const { data: project } = useDataFromRef({
ref: projectId ? projectsRef.doc(projectId) : null, ref: projectId ? projectsRef.doc(projectId) : null,
@@ -105,6 +113,33 @@ const MusicDetails = ({ route }) => {
}; };
}, [trackId, songUrl, title, artist, coverUrl, projectId]); }, [trackId, songUrl, title, artist, coverUrl, projectId]);
const sharePayload = useMemo(
() =>
createMusicSharePayload({
projectId,
title,
artist,
}),
[projectId, title, artist]
);
const handleShare = useCallback(() => {
if (!sharePayload) return;
openShareSheet(sharePayload);
}, [sharePayload]);
const handleReport = useCallback(() => {
if (!projectId) return;
SheetManager.show("Report", {
payload: {
targetType: "music",
projectId,
title,
ownerId: project?.userId || owner?.id || null,
},
});
}, [owner?.id, project?.userId, projectId, title]);
const { const {
isCurrent: isCurrentTrack, isCurrent: isCurrentTrack,
isPlaying: isTrackPlaying, isPlaying: isTrackPlaying,
@@ -137,6 +172,7 @@ const MusicDetails = ({ route }) => {
useEffect(() => { useEffect(() => {
listenedMsRef.current = 0; listenedMsRef.current = 0;
incrementDoneRef.current = false; incrementDoneRef.current = false;
hasAutoPlayedRef.current = false;
}, [trackId]); }, [trackId]);
// Start/stop a timer to accumulate listened milliseconds while playing // Start/stop a timer to accumulate listened milliseconds while playing
@@ -247,6 +283,34 @@ const MusicDetails = ({ route }) => {
[durationMs, trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo] [durationMs, trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo]
); );
useEffect(() => {
if (!autoPlayRequested || hasAutoPlayedRef.current) return;
if (!trackDescriptor || !songUrl) return;
const run = async () => {
try {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: 0, autoPlay: true });
} else if (!isTrackPlaying) {
await resumeTrack();
}
hasAutoPlayedRef.current = true;
} catch (e) {
console.log("MusicDetails autoplay error", e?.message);
}
};
run();
}, [
autoPlayRequested,
trackDescriptor,
songUrl,
isCurrentTrack,
ensureLoaded,
isTrackPlaying,
resumeTrack,
]);
const handleSliderSeekEnd = useCallback(async () => { const handleSliderSeekEnd = useCallback(async () => {
try { try {
if (wasPlayingBeforeSeek.current) { if (wasPlayingBeforeSeek.current) {
@@ -627,12 +691,19 @@ const MusicDetails = ({ route }) => {
style={styles.img} style={styles.img}
/> />
)} )}
<View style={{ ...Style.containerSpaceBetween }}> <View style={{ gap: 12 }}>
<View
style={{
...Style.containerRow,
justifyContent: "space-between",
alignItems: "center",
}}
>
<View> <View>
<Text style={styles.title}>{title}</Text> <Text style={styles.title}>{title}</Text>
<Text style={styles.name}>{artist}</Text> <Text style={styles.name}>{artist}</Text>
</View> </View>
<View style={{ ...Style.containerRow, gap: 12 }}> <View style={{ ...Style.containerRow, gap: 16 }}>
<PressableScale <PressableScale
onPress={async () => { onPress={async () => {
if (!projectId || !currentUID) return; if (!projectId || !currentUID) return;
@@ -655,6 +726,12 @@ const MusicDetails = ({ route }) => {
resizeMode="contain" resizeMode="contain"
/> />
</PressableScale> </PressableScale>
<PressableScale onPress={handleReport}>
<Feather name="flag" size={22} color={Palette.white} style={{ marginHorizontal: 2 }} />
</PressableScale>
{!!sharePayload && (
<ShareBtn style={{ borderRadius: 18 }} onPress={handleShare} />
)}
<Pressable <Pressable
onPress={() => onPress={() =>
SheetManager.show("Playlist", { payload: { projectId } }) SheetManager.show("Playlist", { payload: { projectId } })
@@ -669,6 +746,7 @@ const MusicDetails = ({ route }) => {
</View> </View>
</View> </View>
</View> </View>
</View>
{songUrl && ( {songUrl && (
<View style={{ paddingTop: 22 }}> <View style={{ paddingTop: 22 }}>
<Slider <Slider
+226
View File
@@ -24,6 +24,7 @@ import { useGlobal } from "reactn";
import { background, icons } from "../../assets"; import { background, icons } from "../../assets";
import PressableScale from "../../components/PressableScale"; import PressableScale from "../../components/PressableScale";
import Slider from "../../components/Slider"; import Slider from "../../components/Slider";
import Svg, { Circle } from "react-native-svg";
import { import {
arrayRemove, arrayRemove,
arrayUnion, arrayUnion,
@@ -45,6 +46,11 @@ import {
normalizeStructureType, normalizeStructureType,
segmentRequiresLyrics, segmentRequiresLyrics,
} from "../../utils/songStructure"; } from "../../utils/songStructure";
import {
createMusicSharePayload,
openShareSheet,
} from "../../utils/shareSheet";
import { Feather } from "@expo/vector-icons";
// 20 secondes // 20 secondes
const timeBeforeIncrement = 20000; const timeBeforeIncrement = 20000;
@@ -52,14 +58,17 @@ const timeBeforeIncrement = 20000;
const MusicDetails = ({ route }) => { const MusicDetails = ({ route }) => {
const { params } = route || {}; const { params } = route || {};
const action = params?.action; const action = params?.action;
const autoPlayRequested = params?.autoPlay;
const projectId = params?.projectId || null; const projectId = params?.projectId || null;
const [fav, setFav] = useState(false); const [fav, setFav] = useState(false);
const [currentUID] = useGlobal("currentUID"); const [currentUID] = useGlobal("currentUID");
const [, setTooltip] = useGlobal("_tooltip");
const wasPlayingBeforeSeek = React.useRef(false); const wasPlayingBeforeSeek = React.useRef(false);
const lastSeekTargetMs = React.useRef(null); const lastSeekTargetMs = React.useRef(null);
const listenedMsRef = React.useRef(0); const listenedMsRef = React.useRef(0);
const incrementDoneRef = React.useRef(false); const incrementDoneRef = React.useRef(false);
const timerRef = React.useRef(null); const timerRef = React.useRef(null);
const hasAutoPlayedRef = React.useRef(false);
const { data: project } = useDataFromRef({ const { data: project } = useDataFromRef({
ref: projectId ? projectsRef.doc(projectId) : null, ref: projectId ? projectsRef.doc(projectId) : null,
@@ -116,6 +125,108 @@ const MusicDetails = ({ route }) => {
}; };
}, [trackId, songUrl, title, artist, coverUrl, projectId]); }, [trackId, songUrl, title, artist, coverUrl, projectId]);
const sharePayload = useMemo(
() =>
createMusicSharePayload({
projectId,
title,
artist,
}),
[projectId, title, artist]
);
const handleShare = useCallback(() => {
if (!sharePayload) return;
openShareSheet(sharePayload);
}, [sharePayload]);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const handleDownload = useCallback(async () => {
if (!songUrl || isDownloading) return;
try {
setIsDownloading(true);
setDownloadProgress(0);
const response = await fetch(songUrl);
if (!response.ok) {
throw new Error(`download_failed_${response.status}`);
}
const contentType = response.headers.get("content-type") || "audio/mpeg";
const total = Number(response.headers.get("content-length")) || 0;
const sanitizedTitle = String(title || "musicland-track")
.replace(/[\\/:*?"<>|]+/g, "-")
.trim()
.slice(0, 80);
const fileName = `${sanitizedTitle || "musicland-track"}.mp3`;
if (response.body && typeof response.body.getReader === "function") {
const reader = response.body.getReader();
const chunks = [];
let received = 0;
let pseudoProgress = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
received += value.length;
if (total > 0) {
setDownloadProgress(Math.min(1, received / total));
} else {
pseudoProgress = Math.min(0.95, pseudoProgress + 0.05);
setDownloadProgress(pseudoProgress);
}
}
}
const blob = new Blob(chunks, { type: contentType });
setDownloadProgress(1);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} else {
const blob = await response.blob();
setDownloadProgress(1);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
} catch (error) {
console.error("MusicDetails.download", error);
setTooltip({
type: "error",
text: "Téléchargement impossible",
});
} finally {
setIsDownloading(false);
setTimeout(() => setDownloadProgress(0), 400);
}
}, [isDownloading, setTooltip, songUrl, title]);
const handleReport = useCallback(() => {
if (!projectId) return;
SheetManager.show("Report", {
payload: {
targetType: "music",
projectId,
title,
ownerId: project?.userId || owner?.id || null,
},
});
}, [owner?.id, project?.userId, projectId, title]);
const { const {
isCurrent: isCurrentTrack, isCurrent: isCurrentTrack,
isPlaying: isTrackPlaying, isPlaying: isTrackPlaying,
@@ -150,6 +261,7 @@ const MusicDetails = ({ route }) => {
useEffect(() => { useEffect(() => {
listenedMsRef.current = 0; listenedMsRef.current = 0;
incrementDoneRef.current = false; incrementDoneRef.current = false;
hasAutoPlayedRef.current = false;
}, [trackId]); }, [trackId]);
// Start/stop a timer to accumulate listened milliseconds while playing // Start/stop a timer to accumulate listened milliseconds while playing
@@ -282,6 +394,38 @@ const MusicDetails = ({ route }) => {
[trackDescriptor, durationMs, isCurrentTrack, ensureLoaded, seekTrackTo] [trackDescriptor, durationMs, isCurrentTrack, ensureLoaded, seekTrackTo]
); );
useEffect(() => {
if (!autoPlayRequested || hasAutoPlayedRef.current) {
return;
}
if (!trackDescriptor || !songUrl) {
return;
}
const run = async () => {
try {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: 0, autoPlay: true });
} else if (!isPlaying) {
await resumeTrack();
}
hasAutoPlayedRef.current = true;
} catch (e) {
console.log("MusicDetails autoplay error", e?.message);
}
};
run();
}, [
autoPlayRequested,
ensureLoaded,
isCurrentTrack,
isPlaying,
resumeTrack,
songUrl,
trackDescriptor,
]);
const handleSliderSeekEnd = useCallback(async () => { const handleSliderSeekEnd = useCallback(async () => {
const targetMs = const targetMs =
typeof lastSeekTargetMs.current === "number" typeof lastSeekTargetMs.current === "number"
@@ -677,6 +821,14 @@ const MusicDetails = ({ route }) => {
backgroundImg={ backgroundImg={
action === "userProfile" ? background.profileBG : background.libraryBG2 action === "userProfile" ? background.profileBG : background.libraryBG2
} }
shareBtn={
sharePayload
? {
label: "Partager le morceau",
onPress: handleShare,
}
: false
}
{...(action === "userProfile" && { {...(action === "userProfile" && {
containerStyle: { containerStyle: {
backgroundColor: "#0000004D", backgroundColor: "#0000004D",
@@ -761,6 +913,45 @@ const MusicDetails = ({ route }) => {
resizeMode="contain" resizeMode="contain"
/> />
</Pressable> </Pressable>
<PressableScale onPress={handleReport}>
<Feather
name="flag"
size={22}
color={Palette.white}
style={{ marginHorizontal: 2 }}
/>
</PressableScale>
<View
style={{
width: 36,
height: 36,
alignItems: "center",
justifyContent: "center",
}}
>
{isDownloading ? (
<DownloadProgressRing
progress={downloadProgress}
size={36}
strokeWidth={3}
/>
) : null}
<Pressable
onPress={handleDownload}
disabled={isDownloading || !songUrl}
style={{
width: 36,
height: 36,
borderRadius: 18,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255,255,255,0.08)",
opacity: isDownloading || !songUrl ? 0.6 : 1,
}}
>
<Feather name="download" size={18} color={Palette.white} />
</Pressable>
</View>
<Pressable <Pressable
onPress={() => onPress={() =>
SheetManager.show("Playlist", { payload: { projectId } }) SheetManager.show("Playlist", { payload: { projectId } })
@@ -975,3 +1166,38 @@ const styles = StyleSheet.create({
marginBottom: 6, marginBottom: 6,
}, },
}); });
const DownloadProgressRing = ({ progress = 0, size = 36, strokeWidth = 3 }) => {
const radius = size / 2 - strokeWidth / 2;
const circumference = 2 * Math.PI * radius;
const clamped = Math.max(0, Math.min(1, progress || 0));
const offset = circumference * (1 - clamped);
return (
<Svg
width={size}
height={size}
style={{ position: "absolute", top: 0, left: 0 }}
>
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="rgba(255, 255, 255, 0.15)"
strokeWidth={strokeWidth}
fill="transparent"
/>
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={Palette.primary}
strokeWidth={strokeWidth}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
strokeLinecap="round"
fill="transparent"
/>
</Svg>
);
};
+28
View File
@@ -28,6 +28,8 @@ import {
LIKE_TARGET, LIKE_TARGET,
toggleProjectLike, toggleProjectLike,
} from "../../utils/likes"; } from "../../utils/likes";
import { SheetManager } from "react-native-actions-sheet";
import { Feather } from "@expo/vector-icons";
const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
const { currentUID, followUser, unfollowUser } = useUser() || {}; const { currentUID, followUser, unfollowUser } = useUser() || {};
@@ -142,6 +144,18 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
} catch (e) {} } catch (e) {}
}; };
}, [videoPlayer]); }, [videoPlayer]);
const handleReport = useCallback(() => {
if (!item?.id) return;
SheetManager.show("Report", {
payload: {
targetType: "playback",
projectId: item?.id || null,
playbackId: item?.id || null,
title: item?.title || "",
ownerId: item?.userId || null,
},
});
}, [item?.id, item?.title, item?.userId]);
// Build alignedWords from item musicTimestamps // Build alignedWords from item musicTimestamps
const alignedWords = useMemo(() => { const alignedWords = useMemo(() => {
@@ -365,6 +379,20 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
> >
<Image source={icons.share} style={size({ size: 26 })} /> <Image source={icons.share} style={size({ size: 26 })} />
</Pressable> </Pressable>
<Pressable onPress={handleReport} style={{ alignItems: "center" }}>
<Feather name="flag" size={26} color={Palette.white} />
<Text
style={{
color: Palette.white,
fontSize: 11,
marginTop: 4,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
>
Signaler
</Text>
</Pressable>
</View> </View>
<View style={{ paddingHorizontal: 28 }}> <View style={{ paddingHorizontal: 28 }}>
<BlurView <BlurView
@@ -30,6 +30,8 @@ import {
LIKE_TARGET, LIKE_TARGET,
toggleProjectLike, toggleProjectLike,
} from "../../../utils/likes"; } from "../../../utils/likes";
import { SheetManager } from "react-native-actions-sheet";
import { Feather } from "@expo/vector-icons";
// Debug logging toggle for web playback // Debug logging toggle for web playback
const DEBUG_PLAYBACK_WEB = true; const DEBUG_PLAYBACK_WEB = true;
@@ -252,6 +254,19 @@ const PlaybackItem = ({
const descriptionText = const descriptionText =
item?.description || item?.title || "Description chanson"; item?.description || item?.title || "Description chanson";
const handleReport = useCallback(() => {
if (!item?.id) return;
SheetManager.show("Report", {
payload: {
targetType: "playback",
projectId: item?.id || null,
playbackId: item?.id || null,
title: item?.title || "",
ownerId: item?.userId || null,
},
});
}, [item?.id, item?.title, item?.userId]);
const handleLayout = useCallback((event) => { const handleLayout = useCallback((event) => {
const { width = 0, height = 0 } = event?.nativeEvent?.layout || {}; const { width = 0, height = 0 } = event?.nativeEvent?.layout || {};
setLayoutSize((prev) => ({ setLayoutSize((prev) => ({
@@ -486,6 +501,13 @@ const PlaybackItem = ({
resizeMode="contain" resizeMode="contain"
/> />
</Pressable> </Pressable>
<Pressable
onPress={handleReport}
style={[styles.actionButton, styles.actionSpacing]}
>
<Feather name="flag" size={20} color={Palette.white} />
<Text style={styles.actionLabel}>Signaler</Text>
</Pressable>
</View> </View>
<View style={styles.lyricsContainer}> <View style={styles.lyricsContainer}>
+1 -10
View File
@@ -240,16 +240,7 @@ const GeneratingSong = () => {
if (titleOk && isIdle && !askedRef.current) { if (titleOk && isIdle && !askedRef.current) {
askedRef.current = true; askedRef.current = true;
AppAlert("Attention", "Une génération va être lancée. Continuer ?", [ startMusicGenerationOnce();
{
text: "Non",
style: "cancel",
onPress: () => {
askedRef.current = false;
},
},
{ text: "Oui", onPress: () => startMusicGenerationOnce() },
]);
} }
}, [ }, [
isFocused, isFocused,
+32 -48
View File
@@ -8,8 +8,7 @@ import MusicLandHeader from "../../components/MusicLandHeader";
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from "../../data/data"; import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from "../../data/data";
import useLayoutType from "../../hooks/useLayoutType"; import useLayoutType from "../../hooks/useLayoutType";
import Page from "../../layouts/Page"; import Page from "../../layouts/Page";
import { Routes } from "../../navigation"; import { goBack } from "../../navigation/NavigationService";
import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles"; import { gutters } from "../../styles";
import { sanitizeStructureList } from "../../utils/songStructure"; import { sanitizeStructureList } from "../../utils/songStructure";
@@ -23,13 +22,13 @@ import SongStyle from "./SongStyle";
import SongTo from "./SongTo"; import SongTo from "./SongTo";
import SpecificityContext from "./SpecificityContext"; import SpecificityContext from "./SpecificityContext";
const { width: windowWidth } = Dimensions.get("window"); const { width: windowWidth } = Dimensions.get("window");
const MAX_STEP_INDEX = 8;
const CreateLyricsWithAi = () => { const CreateLyricsWithAi = () => {
const { isWeb } = useLayoutType(); const { isWeb } = useLayoutType();
const { selectedProject, updateProjectData } = useUser(); const { selectedProject } = useUser();
const hasLyrics = selectedProject?.hasLyrics === true;
const scrollRef = useRef(null); const scrollRef = useRef(null);
const [selectedIndex, setSelectedIndex] = useState(hasLyrics ? 5 : 0); const [selectedIndex, setSelectedIndex] = useState(0);
const [progress, setProgress] = useState(16); const [progress, setProgress] = useState(16);
const [parentLayout, setparentLayout] = useState(null); const [parentLayout, setparentLayout] = useState(null);
const containerWidth = windowWidth; const containerWidth = windowWidth;
@@ -188,13 +187,6 @@ const CreateLyricsWithAi = () => {
} }
}, [structure]); }, [structure]);
// Si déjà des paroles, forcer l'accès à partir de l'étape 5 et ignorer 0-4
React.useEffect(() => {
if (hasLyrics && selectedIndex < 5) {
setSelectedIndex(5);
}
}, [hasLyrics]);
const lyricsConfig = useMemo(() => { const lyricsConfig = useMemo(() => {
const resolvedObjective = shouldUseOtherObjective const resolvedObjective = shouldUseOtherObjective
? persistedOtherObjective || undefined ? persistedOtherObjective || undefined
@@ -297,37 +289,7 @@ const CreateLyricsWithAi = () => {
]); ]);
const onPressNext = async () => { const onPressNext = async () => {
// Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6), setSelectedIndex((idx) => Math.min(idx + 1, MAX_STEP_INDEX));
// sauter Rhymes et CreatingLyrics et aller directement sur Lyrics
if (hasLyrics && selectedIndex === 6) {
const chosenStructure = sanitizeStructureList(
Array.isArray(customStructure) && customStructure.length > 0
? customStructure
: Array.isArray(parsedStructure)
? parsedStructure
: []
);
await updateProjectData({
config: { structure: chosenStructure },
selections: {
objective,
otherObjective: persistedOtherObjective,
context,
emotion,
style,
otherStyle: persistedOtherStyle,
audience,
structure,
parsedStructure,
customStructure: sanitizeStructureList(customStructure),
rhymes,
},
hasLyrics: true,
});
navigate(Routes.Lyrics);
return;
}
setSelectedIndex((idx) => idx + 1);
}; };
const onPressBack = () => { const onPressBack = () => {
@@ -344,14 +306,36 @@ const CreateLyricsWithAi = () => {
// Sync scroll position and progress with selectedIndex // Sync scroll position and progress with selectedIndex
React.useEffect(() => { React.useEffect(() => {
if (!parentLayout?.height) return; if (!parentLayout?.height) return;
try { const nextProgress = Math.max(0, Math.min(100, 16 + selectedIndex * 9));
const nextProgress = 16 + selectedIndex * 9;
setProgress(nextProgress); setProgress(nextProgress);
scrollRef.current?.scrollToIndex?.({
const ref = scrollRef.current;
if (!ref || typeof ref.scrollToIndex !== "function") {
return;
}
let cancelled = false;
const attemptScroll = (retries = 0) => {
if (cancelled) return;
try {
ref.scrollToIndex({ index: selectedIndex, animated: true });
} catch (error) {
if (retries < 3) {
setTimeout(() => attemptScroll(retries + 1), 40);
} else {
console.warn("[CreateLyricsWithAi] scrollToIndex failed", {
index: selectedIndex, index: selectedIndex,
animated: true, error,
}); });
} catch (_) {} }
}
};
attemptScroll();
return () => {
cancelled = true;
};
}, [selectedIndex, parentLayout?.height]); }, [selectedIndex, parentLayout?.height]);
return ( return (
+6 -43
View File
@@ -6,8 +6,7 @@ import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader"; import MusicLandHeader from "../../components/MusicLandHeader";
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from "../../data/data"; import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from "../../data/data";
import Page from "../../layouts/Page"; import Page from "../../layouts/Page";
import { Routes } from "../../navigation"; import { goBack } from "../../navigation/NavigationService";
import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles"; import { gutters } from "../../styles";
import { sanitizeStructureList } from "../../utils/songStructure"; import { sanitizeStructureList } from "../../utils/songStructure";
@@ -21,10 +20,11 @@ import SongStyle from "./SongStyle";
import SongTo from "./SongTo"; import SongTo from "./SongTo";
import SpecificityContext from "./SpecificityContext"; import SpecificityContext from "./SpecificityContext";
const MAX_STEP_INDEX = 8;
const CreateLyricsWithAi = () => { const CreateLyricsWithAi = () => {
const { selectedProject, updateProjectData } = useUser(); const { selectedProject } = useUser();
const hasLyrics = selectedProject?.hasLyrics === true; const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedIndex, setSelectedIndex] = useState(hasLyrics ? 5 : 0);
// Collected state across steps // Collected state across steps
const [objective, setObjective] = useState(null); // from Goals list const [objective, setObjective] = useState(null); // from Goals list
@@ -187,13 +187,6 @@ const CreateLyricsWithAi = () => {
} }
}, [structure]); }, [structure]);
// Si déjà des paroles, forcer l'accès à partir de l'étape 5 et ignorer 0-4
React.useEffect(() => {
if (hasLyrics && selectedIndex < 5) {
setSelectedIndex(5);
}
}, [hasLyrics]);
const progress = useMemo( const progress = useMemo(
() => 16 + selectedIndex * 9, () => 16 + selectedIndex * 9,
[selectedIndex] [selectedIndex]
@@ -301,37 +294,7 @@ const CreateLyricsWithAi = () => {
]); ]);
const onPressNext = async () => { const onPressNext = async () => {
// Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6), setSelectedIndex((idx) => Math.min(idx + 1, MAX_STEP_INDEX));
// sauter Rhymes et CreatingLyrics et aller directement sur Lyrics
if (hasLyrics && selectedIndex === 6) {
const chosenStructure = sanitizeStructureList(
Array.isArray(customStructure) && customStructure.length > 0
? customStructure
: Array.isArray(parsedStructure)
? parsedStructure
: []
);
await updateProjectData({
config: { structure: chosenStructure },
selections: {
objective,
otherObjective: persistedOtherObjective,
context,
emotion,
style,
otherStyle: persistedOtherStyle,
audience,
structure,
parsedStructure,
customStructure: sanitizeStructureList(customStructure),
rhymes,
},
hasLyrics: true,
});
navigate(Routes.Lyrics);
return;
}
setSelectedIndex((idx) => idx + 1);
}; };
const onPressBack = () => { const onPressBack = () => {
+3 -2
View File
@@ -3,6 +3,7 @@ import { View } from "react-native";
import ItemContainer from "../../components/ItemContainer/ItemContainer"; import ItemContainer from "../../components/ItemContainer/ItemContainer";
import ListSelection from "../../components/ListSelection/ListSelection"; import ListSelection from "../../components/ListSelection/ListSelection";
import { EMOTION_CONVEY } from "../../data/data"; import { EMOTION_CONVEY } from "../../data/data";
import { strings } from "../../constants/strings";
import CreateLyricsHeader from "./components/CreateLyricsHeader"; import CreateLyricsHeader from "./components/CreateLyricsHeader";
const EmotionConvey = ({ const EmotionConvey = ({
@@ -19,8 +20,8 @@ const EmotionConvey = ({
return ( return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}> <View style={{ flex: 1, gap: 10, marginTop: 16 }}>
<CreateLyricsHeader <CreateLyricsHeader
title={`Quelle émotion veux-tu\ntransmettre ?`} title={strings.writing.steps.emotionTitle}
subTitle="Sélectionne une seule intention émotionnelle." subTitle={strings.writing.steps.emotionSubtitle}
/> />
<View <View
+22 -5
View File
@@ -1,8 +1,9 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { Platform, ScrollView, StyleSheet, View } from "react-native"; import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import ItemContainer from "../../components/ItemContainer/ItemContainer"; import ItemContainer from "../../components/ItemContainer/ItemContainer";
import ListSelection from "../../components/ListSelection/ListSelection"; import ListSelection from "../../components/ListSelection/ListSelection";
import { GOALS, OTHER_OBJECTIVE_OPTION } from "../../data/data"; import { GOALS, OTHER_OBJECTIVE_OPTION } from "../../data/data";
import { strings } from "../../constants/strings";
import { Palette } from "../../styles"; import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
import CreateLyricsHeader from "./components/CreateLyricsHeader"; import CreateLyricsHeader from "./components/CreateLyricsHeader";
@@ -39,9 +40,14 @@ const Goals = ({
<ScrollView contentContainerStyle={{ flex: 1, gap: 16, marginTop: 16 }}> <ScrollView contentContainerStyle={{ flex: 1, gap: 16, marginTop: 16 }}>
<View style={{ gap: 10 }}> <View style={{ gap: 10 }}>
<CreateLyricsHeader <CreateLyricsHeader
title="Quel est le contexte ?" title={strings.writing.steps.contextTitle}
subTitle="Besoin d'idées ? Pense à un anniversaire, une équipe de sport ou ton entreprise." subTitle={strings.writing.steps.contextSubtitle}
/> />
<View style={styles.examplesContainer}>
<Text style={styles.examplesText}>
{strings.writing.steps.contextExamples}
</Text>
</View>
<ItemContainer> <ItemContainer>
<ListSelection <ListSelection
options={goalsOptions} options={goalsOptions}
@@ -55,8 +61,8 @@ const Goals = ({
</ItemContainer> </ItemContainer>
</View> </View>
<CustomInput <CustomInput
label="As-tu un autre objectif ?" label={strings.writing.labels.otherObjective}
placeholder="Décrire lobjectif" placeholder={strings.writing.labels.otherObjectivePlaceholder}
value={otherObjective} value={otherObjective}
setValue={handleOtherObjectiveChange} setValue={handleOtherObjectiveChange}
height={Platform.OS === "web" ? 160 : undefined} height={Platform.OS === "web" ? 160 : undefined}
@@ -95,4 +101,15 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterRegular, fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center", textAlign: "center",
}, },
examplesContainer: {
backgroundColor: Palette.ultraLightWhite,
borderRadius: 12,
padding: 12,
},
examplesText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
},
}); });
+20 -16
View File
@@ -8,6 +8,7 @@ import GradientButton from "../../components/GradientButton";
import ItemContainer from "../../components/ItemContainer/ItemContainer"; import ItemContainer from "../../components/ItemContainer/ItemContainer";
import MusicLandHeader from "../../components/MusicLandHeader"; import MusicLandHeader from "../../components/MusicLandHeader";
import firebase, { projectsRef } from "../../config/firebase"; import firebase, { projectsRef } from "../../config/firebase";
import { strings } from "../../constants/strings";
import { isWeb } from "../../hooks/useLayoutType"; import { isWeb } from "../../hooks/useLayoutType";
import Page from "../../layouts/Page"; import Page from "../../layouts/Page";
import { Routes } from "../../navigation"; import { Routes } from "../../navigation";
@@ -363,25 +364,18 @@ const Lyrics = ({ navigation }) => {
}} }}
> >
<View style={styles.headerContainer}> <View style={styles.headerContainer}>
<Text style={styles.headerTitle}>Proposition de paroles</Text> <Text style={styles.headerTitle}>
<Text style={styles.instructions}> {strings.writing.lyrics.title}
Relis attentivement la proposition générée avant de valider.
</Text> </Text>
<Text style={styles.instructions}> <Text style={styles.instructions}>
<Text style={styles.instructionsHighlight}> {strings.writing.lyrics.instructions}
Personnalisation :
</Text>{" "}
modifie le titre et chaque section pour que les paroles te
ressemblent.
</Text> </Text>
<Text style={styles.instructions}> <View style={styles.personalizationBanner}>
<Text style={styles.instructionsHighlight}> <Text style={styles.personalizationText}>
Nouvelle génération : {strings.writing.lyrics.personalizationBanner}
</Text>{" "}
appuie sur "Générer d'autres paroles" si tu souhaites une autre
suggestion.
</Text> </Text>
</View> </View>
</View>
<CustomInput <CustomInput
label="Titre" label="Titre"
placeholder="Titre" placeholder="Titre"
@@ -489,8 +483,18 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
lineHeight: 20, lineHeight: 20,
}, },
instructionsHighlight: { personalizationBanner: {
fontFamily: FONT_FAMILY.InterSemiBold, backgroundColor: Palette.ultraLightWhite,
borderRadius: 12,
paddingVertical: 10,
paddingHorizontal: 12,
marginTop: 4,
},
personalizationText: {
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
lineHeight: 20,
}, },
instrumentalBlock: { instrumentalBlock: {
padding: 16, padding: 16,
+65 -21
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Image, StyleSheet, View } from "react-native"; import { Image, StyleSheet, Text, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { ai, background, icons } from "../../assets"; import { ai, background, icons } from "../../assets";
import BorderGradientButton from "../../components/BorderGradientButton"; import BorderGradientButton from "../../components/BorderGradientButton";
@@ -10,22 +10,24 @@ import Page from "../../layouts/Page";
import { Routes } from "../../navigation"; import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService"; import { goBack, navigate } from "../../navigation/NavigationService";
import { useUserData } from "../../providers/UserDataProvider"; import { useUserData } from "../../providers/UserDataProvider";
import { gutters } from "../../styles"; import { strings } from "../../constants/strings";
import { Palette, gutters } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
const WritingLyrics = () => { const WritingLyrics = () => {
const { createNewProject, selectedProject, updateProjectData } = const { createNewProject, selectedProject, updateProjectData } =
useUserData(); useUserData();
const { setIsLoading } = useMinuit(); const { setIsLoading } = useMinuit();
async function createAndNavigate({ hasLyrics = false }) { const startWriting = React.useCallback(async () => {
try { try {
await setIsLoading(true); await setIsLoading(true);
if (selectedProject) { if (selectedProject) {
updateProjectData({ hasLyrics }); updateProjectData({ hasLyrics: false });
setTimeout(() => navigate(Routes.CreateLyricsWithAi), 500); setTimeout(() => navigate(Routes.CreateLyricsWithAi), 500);
return; return;
} }
const newProjectId = await createNewProject({ hasLyrics }); const newProjectId = await createNewProject({ hasLyrics: false });
if (!newProjectId) { if (!newProjectId) {
return; return;
} }
@@ -35,7 +37,7 @@ const WritingLyrics = () => {
} finally { } finally {
await setIsLoading(false); await setIsLoading(false);
} }
} }, [createNewProject, navigate, selectedProject, setIsLoading, updateProjectData]);
return ( return (
<Page <Page
@@ -48,27 +50,34 @@ const WritingLyrics = () => {
progress={9} progress={9}
logo={icons.musicLandWriting} logo={icons.musicLandWriting}
/> />
<View <View style={styles.contentWrapper}>
style={{ flex: 1, position: "relative", justifyContent: "flex-end" }} <View style={styles.heroContainer}>
> <Text style={styles.heroTitle}>
<View {strings.writing.onboarding.heroTitle}
style={{ </Text>
paddingBottom: gutters * 2, <Text style={styles.heroSubtitle}>
paddingHorizontal: gutters, {strings.writing.onboarding.heroSubtitle}
gap: 12, </Text>
}} </View>
> <View style={styles.ctaGroup}>
<GradientButton <GradientButton
maxWidth={500} size="large"
maxWidth={520}
containerStyle={{ alignSelf: "center", width: "100%" }} containerStyle={{ alignSelf: "center", width: "100%" }}
title="Écrire des paroles avec une IA" title={strings.writing.onboarding.primaryCta}
onPress={() => createAndNavigate({ hasLyrics: false })} onPress={startWriting}
textStyle={{ fontFamily: FONT_FAMILY.InterSemiBold }}
/> />
<BorderGradientButton <BorderGradientButton
maxWidth={500} size="small"
onPress={() => createAndNavigate({ hasLyrics: true })} title={strings.writing.onboarding.secondaryCta}
disabled
containerStyle={{ alignSelf: "center", width: "100%" }} containerStyle={{ alignSelf: "center", width: "100%" }}
maxWidth={360}
/> />
<Text style={styles.secondaryNote}>
{strings.writing.onboarding.secondaryNote}
</Text>
</View> </View>
</View> </View>
</Page> </Page>
@@ -85,4 +94,39 @@ const styles = StyleSheet.create({
bottom: -40, bottom: -40,
right: -30, right: -30,
}, },
contentWrapper: {
flex: 1,
justifyContent: "space-between",
paddingTop: gutters * 6,
paddingBottom: gutters * 2,
paddingHorizontal: gutters,
gap: 32,
},
heroContainer: {
gap: 12,
maxWidth: 560,
},
heroTitle: {
fontSize: 28,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
heroSubtitle: {
fontSize: 16,
lineHeight: 24,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
opacity: 0.88,
},
ctaGroup: {
gap: 12,
},
secondaryNote: {
textAlign: "center",
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
opacity: 0.85,
fontStyle: "italic",
},
}); });
+2
View File
@@ -7,6 +7,7 @@ import DeleteAudioModal from "../components/modal/DeleteAudioModal";
import PlaylistModal from "../components/modal/PlaylistModal"; import PlaylistModal from "../components/modal/PlaylistModal";
import PlaybackPickerModal from "../components/modal/PlaybackPickerModal"; import PlaybackPickerModal from "../components/modal/PlaybackPickerModal";
import ShareModal from "../components/modal/ShareModal"; import ShareModal from "../components/modal/ShareModal";
import ReportModal from "../components/modal/ReportModal";
registerSheet("Delete", DeleteModal); registerSheet("Delete", DeleteModal);
registerSheet("ProfileSettings", ProfileSettingsModal); registerSheet("ProfileSettings", ProfileSettingsModal);
@@ -16,5 +17,6 @@ registerSheet("DeleteAudio", DeleteAudioModal);
registerSheet("Playlist", PlaylistModal); registerSheet("Playlist", PlaylistModal);
registerSheet("PlaybackPicker", PlaybackPickerModal); registerSheet("PlaybackPicker", PlaybackPickerModal);
registerSheet("Share", ShareModal); registerSheet("Share", ShareModal);
registerSheet("Report", ReportModal);
export {}; export {};
+42
View File
@@ -14,6 +14,48 @@ const defaultPayload = {
qrUrl: musiclandShareUrl, qrUrl: musiclandShareUrl,
}; };
const trimTrailingSlash = (value) =>
typeof value === "string" ? value.replace(/\/+$/, "") : "";
const resolveBaseShareUrl = () => {
if (typeof window !== "undefined" && window.location?.origin) {
const origin = window.location.origin;
if (/localhost|127\.0\.0\.1/i.test(origin)) {
return origin;
}
}
return musiclandShareUrl;
};
export const createMusicSharePayload = ({
projectId,
title,
artist,
} = {}) => {
if (!projectId) return null;
const baseUrl = trimTrailingSlash(resolveBaseShareUrl());
const shareUrl = `${baseUrl}/music/${projectId}`;
let shareMessage = musiclandShareMessage;
if (title) {
shareMessage = `Découvre "${title}"`;
if (artist) {
shareMessage += ` de ${artist}`;
}
shareMessage += " sur MusicLand.";
}
return {
heading: "Partager le morceau",
shareTitle: title ? `${title} - MusicLand` : "MusicLand",
shareMessage,
url: shareUrl,
qrUrl: shareUrl,
linkLabel: shareUrl,
};
};
export const openShareSheet = (payload = {}) => { export const openShareSheet = (payload = {}) => {
SheetManager.show("Share", { SheetManager.show("Share", {
payload: { payload: {