Files
musicland/src/screens/Home/Home.js
T
2025-12-03 17:18:37 +01:00

875 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Image as ExpoImage } from "expo-image";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { background, cardsImg, icons } from "../../assets";
import Alert from "../../components/Alert";
import BorderGradient from "../../components/BorderGradient/BorderGradient";
import { LinearGradient } from "../../components/LinearGradient/LinearGradient";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
import GradientButton from "../../components/GradientButton";
import MoreMenu from "../../components/MoreMenu";
import ProjectDropDown from "../../components/ProjectDropDown/ProjectDropDown";
import CoinPackModal from "../../components/modal/CoinPackModal";
import { projectsRef, usersRef } from "../../config/firebase";
import { isWeb } from "../../hooks/useLayoutType.js";
import Page from "../../layouts/Page";
import LandingPage from "../LandingPage";
import { navigate } from "../../navigation/NavigationService";
import { Routes } from "../../navigation/Routes";
import { useUser } from "../../providers/UserDataProvider";
import CreditAmount from "../../components/CreditAmount";
import ShareBtn from "../../components/ShareBtn/ShareBtn";
import { Palette, gutters } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import {
getCreationStageStates,
getStageAction,
} from "../../utils/projectStages";
import { openCoinPackModal } from "../../utils/coinPackModal";
import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails";
import StageCard from "./components/StageCard";
import ClubCard from "./components/ClubCard";
const isProjectEmpty = (project) => {
if (!project) {
return false;
}
return !(
typeof project?.title === "string" && project.title.trim().length > 0
);
};
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;
const STAGE_CARD_CONTENT = [
{
key: "songwriter",
step: "ÉTAPE 1",
description:
"Ensemble, nous allons écrire ta chanson.\nMéthodiquement je vais te guider pour construire une oeuvre unique et authentique.",
image: cardsImg.writing,
imagePosition: "left",
textAlign: "left",
lockSide: "right",
},
{
key: "beatmaker",
step: "ÉTAPE 2",
description:
"Je suis Malik, responsable du studio de MusicLand.\nJe vais mettre en musique tes paroles en fonction de tes goûts musicaux, ça va être top !",
image: cardsImg.studio,
imagePosition: "right",
textAlign: "right",
lockSide: "left",
},
{
key: "director",
step: "ÉTAPE 3",
description:
"Tu vas faire une expérience extraordinaire, tu vas te filmer en train dinterpreter ta chanson en Play Back et je vais te guider pour te faciliter la tâche!",
image: cardsImg.video,
imagePosition: "left",
textAlign: "left",
lockSide: "right",
},
{
key: "publisher",
step: "ÉTAPE 4",
description:
"Je suis Mr Benhaï, Producteur de MusicLand, et je vais te faire une proposition qui pourrait tintéresser. On se retrouve à la sortie du studio !",
image: cardsImg.production,
imagePosition: "right",
textAlign: "right",
lockSide: "left",
},
];
const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
"trialing",
"active",
"past_due",
"unpaid",
]);
const Home = ({ navigation, route }) => {
const {
userProjects = [],
selectProject,
selectedProject,
selectedProjectId,
currentUserData,
currentUID,
createNewProject,
videos,
hasActiveSubscription,
} = useUser();
const { setTooltip } = useMinuit();
const [videoUrl, setVideoUrl] = useState(null);
const navigateToMusicDetails = useNavigateToMusicDetails();
if (!currentUID) {
return <LandingPage />;
}
const projects = useMemo(
() => (Array.isArray(userProjects) ? userProjects : []),
[userProjects],
);
const currentProject = useMemo(() => {
if (!projects.length) return null;
if (selectedProjectId === null) {
return null;
}
const activeId = selectedProject?.id || selectedProjectId;
if (!activeId) {
return projects[0];
}
return projects.find((project) => project?.id === activeId) || projects[0];
}, [projects, selectedProject?.id, selectedProjectId]);
const formatDate = useCallback((timestamp) => {
try {
const value = timestamp?.toDate ? timestamp.toDate() : timestamp;
const date = value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) {
return "";
}
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
return `${day}/${month}`;
} catch (error) {
console.warn("Home.web: formatDate error", error);
return "";
}
}, []);
const stageStates = useMemo(
() => getCreationStageStates(currentProject),
[currentProject],
);
const stageStatesByKey = useMemo(() => {
if (!Array.isArray(stageStates)) {
return {};
}
return stageStates.reduce((acc, stage) => {
if (stage?.key) {
acc[stage.key] = stage;
}
return acc;
}, {});
}, [stageStates]);
const [menuVisible, setMenuVisible] = useState(false);
const [menuAnchor, setMenuAnchor] = useState(null);
const [menuProject, setMenuProject] = useState(null);
const menuAnchorRef = useRef(null);
const projectDropdownRef = useRef(null);
const [hasLocalAdventureFlag, setHasLocalAdventureFlag] = useState(false);
const handleSelectProject = useCallback(
(project) => {
if (!project?.id) return;
selectProject(project.id);
},
[selectProject],
);
const handleCloseMenu = useCallback(() => {
setMenuVisible(false);
setMenuAnchor(null);
setMenuProject(null);
menuAnchorRef.current = null;
}, []);
const handleDeleteProject = useCallback(async () => {
if (!menuProject?.id) return;
try {
await projectsRef.doc(menuProject.id).delete();
setTooltip?.({ type: "success", text: "Projet supprimé" });
} catch (error) {
setTooltip?.({
type: "error",
text: error?.message || "Suppression impossible",
});
} finally {
handleCloseMenu();
}
}, [handleCloseMenu, menuProject?.id, setTooltip]);
const handleModifyProject = useCallback((project, anchor) => {
if (!project?.id) return;
const prevAnchor = menuAnchorRef.current;
const normalizedAnchor =
typeof anchor === "number"
? { top: anchor }
: anchor && typeof anchor === "object"
? anchor
: {};
setMenuProject(project);
setMenuAnchor(normalizedAnchor);
setMenuVisible((prev) => {
if (!prev) return true;
const prevTop =
typeof prevAnchor?.top === "number" ? prevAnchor.top : null;
const nextTop =
typeof normalizedAnchor?.top === "number" ? normalizedAnchor.top : null;
return prevTop !== nextTop;
});
menuAnchorRef.current = normalizedAnchor;
}, []);
const moreMenuItems = useMemo(() => {
if (!menuProject?.id) return [];
const items = [];
const hasSongUrl =
typeof menuProject?.songUrl === "string" &&
menuProject.songUrl.length > 0;
if (hasSongUrl) {
items.push({
label: "Jouer la musique",
onPress: () => {
projectDropdownRef.current?.close?.();
navigateToMusicDetails({
projectId: menuProject.id,
songUrl: menuProject.songUrl,
project: menuProject,
});
},
});
}
items.push({
label: "Supprimer",
onPress: () =>
Alert(
"Confirmer la suppression",
"Cette action supprimera définitivement ce projet.",
[
{ text: "Annuler", style: "cancel" },
{
text: "Supprimer",
style: "destructive",
onPress: () => {
handleDeleteProject();
},
},
],
{ cancelable: true },
),
});
return items;
}, [handleDeleteProject, menuProject, navigateToMusicDetails]);
useEffect(() => {
if (!route?.params?.showLyricsCongrats) {
return;
}
const beatmakerStage = currentProject
? getStageAction("beatmaker", currentProject)
: null;
Alert(
"Céline",
"Bravo ! Vous venez de terminer de créer les paroles de votre musique ! Maintenant vous pouvez passer à la prochaine étape : le Studio pour donner vie à votre chanson !",
[
{
text: "Retour à l'accueil",
style: "cancel",
},
{
text: "Continuer vers le Studio",
onPress: () => {
if (!currentProject?.id) {
return;
}
if (selectedProject?.id !== currentProject.id) {
selectProject(currentProject.id);
}
const targetRoute = beatmakerStage?.route || Routes.Compose;
navigate(targetRoute, beatmakerStage?.params);
},
},
],
);
navigation?.setParams?.({ showLyricsCongrats: false });
}, [
currentProject,
navigation,
selectProject,
selectedProject?.id,
route?.params?.showLyricsCongrats,
]);
const hasActiveProject = !!currentProject;
const ensureProjectSelected = useCallback(() => {
if (!currentProject?.id) {
return;
}
if (selectedProject?.id !== currentProject.id) {
selectProject(currentProject.id);
}
}, [currentProject?.id, selectProject, selectedProject?.id]);
const songwriterAction = useMemo(
() => getStageAction("songwriter", null),
[],
);
const handleStartNew = useCallback(async () => {
const targetRoute = songwriterAction?.route || Routes.WritingLyrics;
const targetParams = songwriterAction?.params;
try {
const emptyProject =
projects.find((project) => isProjectEmpty(project)) || null;
if (emptyProject?.id) {
selectProject(emptyProject.id);
navigate(targetRoute, targetParams);
return;
}
const newProjectId = await createNewProject({ hasLyrics: false });
if (!newProjectId) {
return;
}
navigate(targetRoute, targetParams);
} catch (error) {
console.warn("Home: unable to start new project", error);
setTooltip?.({
type: "error",
text: "Impossible de démarrer un nouveau projet",
});
}
}, [createNewProject, projects, selectProject, setTooltip, songwriterAction]);
useEffect(() => {
if (currentUserData?.adventureStarted) {
setHasLocalAdventureFlag(true);
}
}, [currentUserData?.adventureStarted]);
const persistAdventureStarted = useCallback(async () => {
if (!currentUID) {
return;
}
await usersRef
.doc(currentUID)
.set({ adventureStarted: true }, { merge: true });
}, [currentUID]);
const markAdventureStarted = useCallback(() => {
if (hasLocalAdventureFlag) {
return;
}
setHasLocalAdventureFlag(true);
persistAdventureStarted().catch((error) => {
console.warn("Home: adventureStarted update failed", error);
setHasLocalAdventureFlag(false);
setTooltip?.({
type: "error",
text: "Impossible de mettre à jour votre profil",
});
});
}, [hasLocalAdventureFlag, persistAdventureStarted, setTooltip]);
const handleStartVisit = useCallback(() => {
setVideoUrl(isWeb ? videos?.landingWeb : videos?.landing);
}, []);
const handleIntroVideoClose = useCallback(() => {
setVideoUrl(null);
markAdventureStarted();
}, [markAdventureStarted]);
const adventureStarted =
hasLocalAdventureFlag || !!currentUserData?.adventureStarted;
const homeBackgroundImage = background.bgTrans;
const landingBackgroundImage = homeBackgroundImage;
const stageCards = useMemo(
() =>
STAGE_CARD_CONTENT.map((card) => ({
...card,
isLocked: stageStatesByKey[card.key]?.isLocked ?? true,
})),
[stageStatesByKey],
);
const [isCoinModalVisible, setCoinModalVisible] = useState(false);
const handleStagePress = useCallback(
(stageKey, isLocked) => {
if (isLocked) {
return;
}
if (!hasActiveProject || !currentProject) {
if (stageKey === "songwriter") {
handleStartNew();
}
return;
}
ensureProjectSelected();
const action = getStageAction(stageKey, currentProject);
if (!action?.route) {
return;
}
navigate(action.route, action.params);
},
[currentProject, ensureProjectSelected, hasActiveProject, handleStartNew],
);
const handleClubPress = useCallback(() => {
navigate(Routes.Payments);
}, []);
const handleOpenCoinModal = useCallback(() => {
if (isWeb) {
openCoinPackModal();
return;
}
setCoinModalVisible(true);
}, []);
const handleCloseCoinModal = useCallback(() => {
setCoinModalVisible(false);
}, []);
const handleNavigateToLanding = useCallback(() => {
navigate(Routes.LandingPage);
}, []);
const stageCardContainerStyle = isWeb ? styles.cardsGrid : styles.cardsStack;
const stageCardItemStyle = isWeb
? styles.webStageCard
: styles.mobileStageCard;
const stageCardsList = (
<View style={stageCardContainerStyle}>
{stageCards.map((card) => (
<StageCard
key={card.key}
step={card.step}
description={card.description}
image={card.image}
imagePosition={card.imagePosition}
textAlign={card.textAlign}
isLocked={card.isLocked}
lockSide={card.lockSide}
onPress={() => handleStagePress(card.key, card.isLocked)}
containerStyle={stageCardItemStyle}
variant={isWeb ? "web" : "mobile"}
/>
))}
</View>
);
const coinBalance = useMemo(() => {
const value = currentUserData?.coins;
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return 0;
}, [currentUserData?.coins]);
const mobileInfoRow = !isWeb ? (
<View style={styles.mobileInfoRow}>
<Pressable
onPress={handleOpenCoinModal}
accessibilityRole="button"
style={styles.mobileCoinButton}
>
<CreditAmount
value={coinBalance}
style={styles.mobileCoinAmount}
textStyle={styles.mobileCoinText}
iconSize={20}
iconPosition="left"
/>
</Pressable>
<ShareBtn style={styles.mobileShareButton} label="Partager" />
</View>
) : null;
const topBar = (
<View style={styles.topBar}>
<Pressable
onPress={handleNavigateToLanding}
accessibilityRole="button"
style={[
styles.returnLandingButton,
isWeb ? styles.returnLandingButtonWeb : null,
]}
>
<Text style={styles.returnLandingButtonText}>
Retour à l'accueil
</Text>
</Pressable>
<View style={styles.topBarContent}>
{mobileInfoRow}
<ProjectDropDown
ref={projectDropdownRef}
style={styles.projectDropDown}
projects={projects}
selectedProject={currentProject}
allowEmptySelection
onSelectProject={handleSelectProject}
onModifyProject={handleModifyProject}
onCreateProject={handleStartNew}
formatDate={formatDate}
/>
</View>
</View>
);
const journeyContent = (
<>
<MoreMenu
visible={menuVisible}
top={menuAnchor?.top ?? 0}
position={menuAnchor}
onClose={handleCloseMenu}
inPlaylist={false}
projectId={menuProject?.id || null}
extraItems={moreMenuItems}
/>
<View style={styles.subtitleWrapper}>
{isWeb ? (
<LinearGradient
colors={["#F94697", "#7023F7"]}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={[styles.subtitleGradient, styles.subtitleGradientWeb]}
>
<View style={[styles.subtitleInner, styles.subtitleInnerWeb]}>
<Text style={styles.subtitle}>5 espaces à découvrir</Text>
</View>
</LinearGradient>
) : (
<BorderGradient
style={[styles.subtitleGradient, styles.subtitleBorder]}
gradientProps={{
colors: ["#F94697", "#7023F7"],
start: { x: 0, y: 0.5 },
end: { x: 1, y: 0.5 },
locations: [0, 1],
}}
>
<View style={styles.subtitleInner}>
<Text style={styles.subtitle}>5 espaces à découvrir</Text>
</View>
</BorderGradient>
)}
</View>
{stageCardsList}
<ClubCard
onPress={handleClubPress}
/>
</>
);
return (
<>
{adventureStarted ? (
<View style={styles.root}>
<Page
shareBtn
headerType="NONE"
scrollEnabled={false}
containerStyle={styles.page}
contentContainerStyle={styles.pageContent}
maxWidth={1600}
width="100%"
backgroundColor="#303438"
>
<View style={styles.inner}>
<ExpoImage
source={homeBackgroundImage}
contentFit="cover"
style={styles.centerImage}
/>
{isWeb ? (
<View style={styles.contentOverlay}>
{topBar}
{journeyContent}
</View>
) : (
<View style={[styles.contentOverlay, styles.mobileContent]}>
{topBar}
<ScrollView
style={styles.mobileScrollView}
contentContainerStyle={styles.mobileScrollContent}
showsVerticalScrollIndicator={false}
>
{journeyContent}
</ScrollView>
</View>
)}
</View>
</Page>
</View>
) : (
<>
<Page
shareBtn
title="Landing Page"
backgroundImg={landingBackgroundImage}
contentContainerStyle={styles.pageContent}
backgroundColor="#303438"
>
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<GradientButton
title="Commencer l'aventure MusicLand"
onPress={handleStartVisit}
/>
</View>
</Page>
<FullscreenIntroVideo
url={videoUrl}
visible={!!videoUrl}
onClose={handleIntroVideoClose}
/>
</>
)}
{!isWeb ? (
<CoinPackModal
visible={isCoinModalVisible}
onClose={handleCloseCoinModal}
/>
) : null}
</>
);
};
export default Home;
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "#303438",
},
page: {
backgroundColor: "transparent",
padding: 0,
paddingTop: 0,
paddingBottom: 0,
width: "100%",
},
pageContent: {
flexGrow: 1,
},
inner: {
flex: 1,
width: "100%",
alignSelf: "stretch",
alignItems: "stretch",
justifyContent: "flex-start",
position: "relative",
},
centerImage: {
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,
},
contentOverlay: {
position: "relative",
width: "100%",
flexGrow: 1,
},
topBar: {
width: "100%",
flexDirection: isWeb ? "row" : "column",
flexWrap: isWeb ? "wrap" : "nowrap",
alignItems: isWeb ? "center" : "stretch",
justifyContent: isWeb ? "center" : "flex-start",
gap: isWeb ? 0 : 12,
marginTop: isWeb ? 12 : 0,
marginBottom: isWeb ? 4 : 16,
paddingHorizontal: isWeb ? 0 : gutters,
zIndex: 10,
position: "relative",
},
returnLandingButton: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 18,
backgroundColor: "rgba(12, 14, 18, 0.72)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
alignSelf: "flex-start",
},
returnLandingButtonWeb: {
position: "absolute",
left: 0,
top: 0,
transform: [{ translateY: -2 }],
},
returnLandingButtonText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
topBarContent: {
flex: 1,
width: "100%",
flexDirection: isWeb ? "row" : "column",
alignItems: isWeb ? "center" : "stretch",
justifyContent: isWeb ? "center" : "flex-start",
gap: 12,
},
projectDropDown: {
width: isWeb ? 420 : "100%",
maxWidth: 420,
flexGrow: 1,
},
mobileInfoRow: {
width: "100%",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
},
mobileCoinButton: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: "rgba(12, 14, 18, 0.72)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
flexShrink: 1,
},
mobileCoinAmount: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
mobileCoinText: {
fontSize: 18,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
mobileShareButton: {
flexShrink: 0,
},
mobileContent: {
flex: 1,
width: "100%",
},
mobileScrollView: {
flex: 1,
width: "100%",
},
mobileScrollContent: {
paddingHorizontal: gutters,
paddingTop: 0,
paddingBottom: gutters * 6,
},
subtitleWrapper: {
width: "100%",
marginBottom: isWeb ? 12 : 16,
marginTop: isWeb ? 8 : 15,
alignItems: "center",
},
subtitleGradient: {
width: "100%",
maxWidth: 420,
borderRadius: 999,
alignSelf: "center",
},
subtitleBorder: {
borderWidth: 1,
},
subtitleGradientWeb: {
padding: 2,
},
subtitleInner: {
width: "100%",
paddingHorizontal: 24,
paddingVertical: 10,
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
backgroundColor: Palette.glass,
},
subtitleInnerWeb: {
paddingVertical: 10,
backgroundColor: Palette.lightPurple,
},
subtitle: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 18,
color: Palette.white,
textAlign: "center",
textTransform: "uppercase",
letterSpacing: 1,
},
cardsGrid: {
width: "100%",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "space-between",
rowGap: isWeb ? 16 : 20,
},
cardsStack: {
width: "100%",
flexDirection: "column",
alignItems: "stretch",
},
webStageCard: {
width: "48%",
},
mobileStageCard: {
width: "100%",
marginBottom: 20,
},
});