Merge branch 'main' of gitlab.com:agenceminuit/musicland
This commit is contained in:
@@ -13,7 +13,7 @@ const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null;
|
|||||||
|
|
||||||
// Initialisation de Expo SDK
|
// Initialisation de Expo SDK
|
||||||
let expo = new Expo();
|
let expo = new Expo();
|
||||||
const EMAIL_FROM = "MusicLand <musicland@minuit.app>";
|
const EMAIL_FROM = "MusicLand <musicland@musicland.ai>";
|
||||||
const DEFAULT_EMAIL_TITLE = "MusicLand";
|
const DEFAULT_EMAIL_TITLE = "MusicLand";
|
||||||
|
|
||||||
function getCollectionRef(collectionName = "") {
|
function getCollectionRef(collectionName = "") {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const {
|
|||||||
const { ACTIVE_SUBSCRIPTION_STATUSES } = require("./constants");
|
const { ACTIVE_SUBSCRIPTION_STATUSES } = require("./constants");
|
||||||
|
|
||||||
// Toggle to stop monthly grants for annual subscriptions while keeping logic handy.
|
// Toggle to stop monthly grants for annual subscriptions while keeping logic handy.
|
||||||
const ENABLE_ANNUAL_GRANT_SCHEDULER = false;
|
const ENABLE_ANNUAL_GRANT_SCHEDULER = true;
|
||||||
|
|
||||||
const processAnnualSubscriptionAllowances = onSchedule(
|
const processAnnualSubscriptionAllowances = onSchedule(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const { onRequest } = require("firebase-functions/https");
|
|||||||
|
|
||||||
const resendClient = new Resend(RESEND_API_KEY);
|
const resendClient = new Resend(RESEND_API_KEY);
|
||||||
const WELCOME_EMAIL_FROM =
|
const WELCOME_EMAIL_FROM =
|
||||||
process.env.RESEND_FROM_EMAIL || "MusicLand <musicland@minuit.app>";
|
process.env.RESEND_FROM_EMAIL || "MusicLand <musicland@musicland.ai>";
|
||||||
const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
|
const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
|
||||||
|
|
||||||
exports.testWelcomMail = onRequest(async (req, res) => {
|
exports.testWelcomMail = onRequest(async (req, res) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import React from "react";
|
import React, { useCallback, useEffect, useRef } from "react";
|
||||||
import { Image, TextInput, View } from "react-native";
|
import { Image, Pressable, TextInput } from "react-native";
|
||||||
import { icons } from "../assets";
|
import { icons } from "../assets";
|
||||||
import { Palette } from "../styles";
|
import { Palette } from "../styles";
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
import { FONT_FAMILY } from "../styles/Fonts";
|
||||||
@@ -10,8 +10,99 @@ const SearchBar = ({
|
|||||||
placeholder = "Que souhaites-tu écouter?",
|
placeholder = "Que souhaites-tu écouter?",
|
||||||
textInputProps = {},
|
textInputProps = {},
|
||||||
}) => {
|
}) => {
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const hasForwardedFocusRef = useRef(false);
|
||||||
|
const focusFallbackRef = useRef(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
onFocus: onFocusProp,
|
||||||
|
onPressIn: onPressInProp,
|
||||||
|
ref: textInputRefProp,
|
||||||
|
...restTextInputProps
|
||||||
|
} = textInputProps;
|
||||||
|
|
||||||
|
const forwardFocus = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (hasForwardedFocusRef.current) return;
|
||||||
|
hasForwardedFocusRef.current = true;
|
||||||
|
onFocusProp?.(event);
|
||||||
|
},
|
||||||
|
[onFocusProp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(
|
||||||
|
(event) => {
|
||||||
|
forwardFocus(event);
|
||||||
|
},
|
||||||
|
[forwardFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const focusInput = useCallback(() => {
|
||||||
|
const node = inputRef.current;
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof node.isFocused === "function" &&
|
||||||
|
node.isFocused() &&
|
||||||
|
hasForwardedFocusRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasForwardedFocusRef.current = false;
|
||||||
|
node.focus?.();
|
||||||
|
|
||||||
|
if (focusFallbackRef.current) {
|
||||||
|
cancelAnimationFrame(focusFallbackRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
focusFallbackRef.current = requestAnimationFrame(() => {
|
||||||
|
// Some platforms do not propagate the focus event when focus() is called programmatically.
|
||||||
|
if (!hasForwardedFocusRef.current) {
|
||||||
|
forwardFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node.isFocused === "function" && !node.isFocused()) {
|
||||||
|
node.focus?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [forwardFocus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (focusFallbackRef.current) {
|
||||||
|
cancelAnimationFrame(focusFallbackRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setRefs = useCallback(
|
||||||
|
(node) => {
|
||||||
|
inputRef.current = node;
|
||||||
|
|
||||||
|
if (typeof textInputRefProp === "function") {
|
||||||
|
textInputRefProp(node);
|
||||||
|
} else if (textInputRefProp) {
|
||||||
|
textInputRefProp.current = node;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[textInputRefProp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputPressIn = useCallback(
|
||||||
|
(event) => {
|
||||||
|
focusInput();
|
||||||
|
onPressInProp?.(event);
|
||||||
|
},
|
||||||
|
[focusInput, onPressInProp],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ borderRadius: 12, overflow: "hidden" }}>
|
<Pressable
|
||||||
|
onPressIn={focusInput}
|
||||||
|
style={{ borderRadius: 12, overflow: "hidden" }}
|
||||||
|
>
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={80}
|
intensity={80}
|
||||||
style={{
|
style={{
|
||||||
@@ -24,6 +115,7 @@ const SearchBar = ({
|
|||||||
>
|
>
|
||||||
<Image source={icons.search} />
|
<Image source={icons.search} />
|
||||||
<TextInput
|
<TextInput
|
||||||
|
ref={setRefs}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor={Palette.gray}
|
placeholderTextColor={Palette.gray}
|
||||||
style={{
|
style={{
|
||||||
@@ -32,10 +124,12 @@ const SearchBar = ({
|
|||||||
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
|
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
{...textInputProps}
|
onPressIn={handleInputPressIn}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
{...restTextInputProps}
|
||||||
/>
|
/>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</View>
|
</Pressable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Image } from "react-native";
|
||||||
|
import { subBadges } from "../assets";
|
||||||
|
|
||||||
|
const allowedLevels = new Set(["starter", "pro", "premium"]);
|
||||||
|
|
||||||
|
const normalizeLevel = (value) => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return allowedLevels.has(normalized) ? normalized : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SubscriptionBadge = ({ level = null, size = 20, style = null }) => {
|
||||||
|
const normalized = normalizeLevel(level);
|
||||||
|
const source = normalized ? subBadges[normalized] : null;
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={source}
|
||||||
|
style={[{ width: size, height: size }, style]}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(SubscriptionBadge);
|
||||||
@@ -18,7 +18,7 @@ import { isWeb } from "../../hooks/useLayoutType";
|
|||||||
import CreditAmount from "../CreditAmount";
|
import CreditAmount from "../CreditAmount";
|
||||||
import { useStripe } from "../../providers/StripeProvider";
|
import { useStripe } from "../../providers/StripeProvider";
|
||||||
|
|
||||||
const WEB_MODAL_MAX_WIDTH = 1000;
|
const WEB_MODAL_MAX_WIDTH = 820;
|
||||||
const formatCurrency = (amount, currency = "eur") => {
|
const formatCurrency = (amount, currency = "eur") => {
|
||||||
if (typeof amount !== "number") {
|
if (typeof amount !== "number") {
|
||||||
return null;
|
return null;
|
||||||
@@ -229,14 +229,6 @@ function CoinPackCard({
|
|||||||
) : null}
|
) : null}
|
||||||
{pack?.name ? <Text style={styles.packName}>{pack.name}</Text> : null}
|
{pack?.name ? <Text style={styles.packName}>{pack.name}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.perksList}>
|
|
||||||
<Text style={styles.perkItem}>
|
|
||||||
- Diffusion sur mes plates formes de streaming
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.perkItem}>
|
|
||||||
*Eligible au Hit parade Chanson/Video
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.priceBlock}>
|
<View style={styles.priceBlock}>
|
||||||
{formattedPrice ? (
|
{formattedPrice ? (
|
||||||
<Text style={styles.packPrice}>{formattedPrice}</Text>
|
<Text style={styles.packPrice}>{formattedPrice}</Text>
|
||||||
@@ -388,6 +380,9 @@ const CoinPackModal = ({ visible, onClose }) => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: modalMaxWidth,
|
maxWidth: modalMaxWidth,
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
|
borderWidth: isWeb ? 1 : 0,
|
||||||
|
borderColor: "rgba(255,255,255,0.16)",
|
||||||
|
backgroundColor: isWeb ? "rgba(12, 10, 18, 0.85)" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -452,6 +447,7 @@ const styles = StyleSheet.create({
|
|||||||
gap: 24,
|
gap: 24,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingBottom: gutters,
|
paddingBottom: gutters,
|
||||||
|
paddingHorizontal: isWeb ? gutters * 1.5 : 0,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : undefined,
|
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : undefined,
|
||||||
},
|
},
|
||||||
@@ -503,7 +499,7 @@ const styles = StyleSheet.create({
|
|||||||
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : "100%",
|
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : "100%",
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
gap: gutters,
|
gap: gutters,
|
||||||
paddingHorizontal: isWeb ? 8 : 0,
|
paddingHorizontal: isWeb ? gutters * 1.5 : 0,
|
||||||
},
|
},
|
||||||
packListWeb: {
|
packListWeb: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -567,16 +563,6 @@ const styles = StyleSheet.create({
|
|||||||
color: "rgba(255, 255, 255, 0.72)",
|
color: "rgba(255, 255, 255, 0.72)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
perksList: {
|
|
||||||
gap: 6,
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
perkItem: {
|
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
|
||||||
fontSize: 13,
|
|
||||||
color: "rgba(255, 255, 255, 0.72)",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
priceBlock: {
|
priceBlock: {
|
||||||
gap: 2,
|
gap: 2,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useRoute } from "@react-navigation/native";
|
import { useRoute } from "@react-navigation/native";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { Modal, Pressable, ScrollView, Text, View } from "react-native";
|
||||||
|
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||||
import { useGlobal } from "reactn";
|
import { useGlobal } from "reactn";
|
||||||
import { background } from "../assets";
|
import { background } from "../assets";
|
||||||
|
import BorderGradientButton from "../components/BorderGradientButton";
|
||||||
import GradientButton from "../components/GradientButton";
|
import GradientButton from "../components/GradientButton";
|
||||||
import { Input } from "../components/Input";
|
import { Input } from "../components/Input";
|
||||||
|
import AppCheckbox from "../components/AppCheckbox";
|
||||||
import ItemContainer from "../components/ItemContainer/ItemContainer";
|
import ItemContainer from "../components/ItemContainer/ItemContainer";
|
||||||
import {
|
import {
|
||||||
checkIfPasswordIsStrongEnough,
|
checkIfPasswordIsStrongEnough,
|
||||||
@@ -30,10 +33,11 @@ const CreatePassword = () => {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [passwordError, setPasswordError] = useState("");
|
const [passwordError, setPasswordError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isCguAccepted, setIsCguAccepted] = useState(false);
|
||||||
|
const [isCguModalVisible, setIsCguModalVisible] = useState(false);
|
||||||
|
const [hasScrolledCguToEnd, setHasScrolledCguToEnd] = useState(false);
|
||||||
const [, setTooltip] = useGlobal("_tooltip");
|
const [, setTooltip] = useGlobal("_tooltip");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handlePasswordChange = (text) => {
|
const handlePasswordChange = (text) => {
|
||||||
setPassword(text);
|
setPassword(text);
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -52,6 +56,30 @@ const CreatePassword = () => {
|
|||||||
|
|
||||||
const isPasswordValid = checkIfPasswordIsStrongEnough({ password });
|
const isPasswordValid = checkIfPasswordIsStrongEnough({ password });
|
||||||
|
|
||||||
|
const handleOpenCguModal = () => {
|
||||||
|
setHasScrolledCguToEnd(false);
|
||||||
|
setIsCguModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseCguModal = () => {
|
||||||
|
setIsCguModalVisible(false);
|
||||||
|
setHasScrolledCguToEnd(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCguScroll = ({ nativeEvent }) => {
|
||||||
|
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
|
||||||
|
const paddingToBottom = 20;
|
||||||
|
const contentFits = contentSize.height <= layoutMeasurement.height + 1;
|
||||||
|
const isAtEnd =
|
||||||
|
layoutMeasurement.height + contentOffset.y >=
|
||||||
|
contentSize.height - paddingToBottom;
|
||||||
|
if (isAtEnd) {
|
||||||
|
setHasScrolledCguToEnd(true);
|
||||||
|
} else if (contentFits) {
|
||||||
|
setHasScrolledCguToEnd(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onCreateAccount = async () => {
|
const onCreateAccount = async () => {
|
||||||
try {
|
try {
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
@@ -62,6 +90,14 @@ const CreatePassword = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isCguAccepted) {
|
||||||
|
setTooltip({
|
||||||
|
text: "Tu dois accepter les CGU pour continuer",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const cred = await firebase
|
const cred = await firebase
|
||||||
.auth()
|
.auth()
|
||||||
@@ -110,8 +146,19 @@ const CreatePassword = () => {
|
|||||||
headerType="NAVIGATION"
|
headerType="NAVIGATION"
|
||||||
title="Inscription"
|
title="Inscription"
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1, paddingTop: 20 }}>
|
<View
|
||||||
<ItemContainer height={300} disableKeyboardHeight>
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 20,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemContainer
|
||||||
|
height={isWeb ? 360 : responsiveHeight(65)}
|
||||||
|
disableKeyboardHeight
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
<View style={{ gap: 32, paddingTop: 5, paddingHorizontal: 5 }}>
|
<View style={{ gap: 32, paddingTop: 5, paddingHorizontal: 5 }}>
|
||||||
<View style={{ gap: 16 }}>
|
<View style={{ gap: 16 }}>
|
||||||
<View style={{ gap: 2 }}>
|
<View style={{ gap: 2 }}>
|
||||||
@@ -154,18 +201,303 @@ const CreatePassword = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppCheckbox selected={isCguAccepted} />
|
||||||
|
<Pressable onPress={handleOpenCguModal}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Voir les CGU
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
<GradientButton
|
<GradientButton
|
||||||
title={loading ? "Création..." : "Suivant"}
|
title={loading ? "Création..." : "Créer mon compte"}
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
width: "80%",
|
width: "80%",
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
}}
|
}}
|
||||||
onPress={onCreateAccount}
|
onPress={onCreateAccount}
|
||||||
disabled={loading || !email || !isPasswordValid}
|
disabled={
|
||||||
|
loading || !email || !isPasswordValid || !isCguAccepted
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ItemContainer>
|
</ItemContainer>
|
||||||
</View>
|
</View>
|
||||||
|
<Modal
|
||||||
|
animationType="slide"
|
||||||
|
visible={isCguModalVisible}
|
||||||
|
transparent
|
||||||
|
onRequestClose={handleCloseCguModal}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Palette.transparentBlack,
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: Palette.darkPurple,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
paddingTop: 28,
|
||||||
|
maxHeight: "75%",
|
||||||
|
width: "90%",
|
||||||
|
flexShrink: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCloseCguModal}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
padding: 6,
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
X
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ gap: 16, flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Conditions Générales d'Utilisation
|
||||||
|
</Text>
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ gap: 16, paddingBottom: 12 }}
|
||||||
|
onScroll={handleCguScroll}
|
||||||
|
onMomentumScrollEnd={handleCguScroll}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cupidatat incididunt aute aute velit deserunt labore excepteur
|
||||||
|
eu velit cillum est. Laboris in sunt ea Lorem culpa velit nisi
|
||||||
|
ad elit esse voluptate id adipisicing nulla magna. Duis qui
|
||||||
|
fugiat consequat elit velit aute ad. Reprehenderit proident
|
||||||
|
duis exercitation velit proident aute minim. Anim
|
||||||
|
reprehenderit mollit consectetur id sint adipisicing pariatur
|
||||||
|
tempor sit pariatur proident nostrud. Sit culpa tempor enim ut
|
||||||
|
consectetur sunt aute est reprehenderit incididunt incididunt
|
||||||
|
in ullamco consectetur. Eiusmod non incididunt proident
|
||||||
|
eiusmod. Esse Lorem ut amet in est id aute consectetur.
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Aliqua est sint consectetur occaecat exercitation. Elit nisi
|
||||||
|
velit ad qui eiusmod reprehenderit proident labore nostrud
|
||||||
|
labore consequat enim fugiat non. Eiusmod occaecat pariatur
|
||||||
|
deserunt velit elit sint irure fugiat excepteur labore ad
|
||||||
|
velit ex deserunt laborum. Aliqua excepteur reprehenderit
|
||||||
|
nostrud aliqua pariatur aliqua excepteur Lorem consectetur. Ut
|
||||||
|
est culpa ullamco ipsum Lorem ullamco ea labore anim.
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Et ut ex duis tempor amet aliquip cupidatat sit sit. Aliqua
|
||||||
|
consectetur amet duis fugiat nulla duis culpa exercitation
|
||||||
|
reprehenderit. Ea voluptate proident ad elit pariatur do.
|
||||||
|
Culpa incididunt deserunt dolor ut officia aliquip ut
|
||||||
|
occaecat eiusmod sint veniam. Deserunt consequat adipisicing
|
||||||
|
aliquip velit labore fugiat aute culpa id sint. Adipisicing eu
|
||||||
|
commodo ex do aliqua in labore laboris sit sunt adipisicing
|
||||||
|
excepteur. Do sint velit veniam pariatur consequat proident
|
||||||
|
cupidatat in incididunt ullamco.
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Culpa eu cillum fugiat elit eiusmod enim. Cupidatat culpa
|
||||||
|
aliquip culpa et tempor est velit. Aute id deserunt non ut
|
||||||
|
minim deserunt adipisicing sit veniam eu id incididunt
|
||||||
|
adipisicing dolore. Eiusmod nisi excepteur est voluptate
|
||||||
|
consequat reprehenderit non exercitation commodo aute.
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Eu culpa enim dolore anim ipsum eu veniam mollit dolore
|
||||||
|
officia cupidatat laborum officia do. Ad proident in anim nisi
|
||||||
|
exercitation consequat exercitation occaecat consequat ut
|
||||||
|
laborum esse consectetur ullamco. Enim elit sit in id velit
|
||||||
|
quis nostrud nostrud eu labore aute reprehenderit voluptate
|
||||||
|
deserunt. Minim voluptate et duis voluptate enim duis.
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Minim laboris deserunt minim ex. Duis aliquip cillum proident.
|
||||||
|
Magna velit nisi fugiat. Non id proident pariatur elit in
|
||||||
|
exercitation et amet id qui ad laborum nulla ullamco ea.
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cillum nisi aute dolore culpa est veniam cupidatat sunt sint
|
||||||
|
ipsum. Amet ullamco minim non voluptate cillum dolore sunt
|
||||||
|
irure nulla pariatur excepteur voluptate id. Commodo ullamco
|
||||||
|
aliqua non ea mollit ullamco do minim dolor magna. Ipsum eu
|
||||||
|
minim quis laborum do dolore labore eiusmod et. Ullamco
|
||||||
|
officia velit anim. Exercitation voluptate reprehenderit ex et
|
||||||
|
do eu fugiat tempor do cillum ad.
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cupidatat consectetur excepteur commodo laborum incididunt
|
||||||
|
laboris minim dolore ut ipsum dolor ullamco culpa aliquip.
|
||||||
|
Cillum proident quis consectetur voluptate labore nisi Lorem
|
||||||
|
pariatur in esse. Amet ipsum mollit officia fugiat Lorem ipsum
|
||||||
|
elit officia. Esse reprehenderit magna quis irure sit
|
||||||
|
consectetur dolore sunt mollit aliquip eiusmod voluptate amet.
|
||||||
|
Ex irure culpa ea cupidatat nulla ea labore aute occaecat
|
||||||
|
consequat consectetur cillum amet. Velit ad do occaecat non
|
||||||
|
elit quis. Amet id reprehenderit ullamco amet tempor deserunt
|
||||||
|
exercitation elit consectetur minim aliqua. Cillum do aliquip
|
||||||
|
do ea ipsum veniam deserunt in ipsum pariatur nisi proident et
|
||||||
|
ut deserunt.
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mollit et adipisicing velit tempor deserunt excepteur fugiat
|
||||||
|
eiusmod adipisicing. Tempor do ex duis dolor deserunt cillum
|
||||||
|
officia est nisi mollit fugiat. Amet duis ea laboris officia
|
||||||
|
aliquip id sint voluptate consectetur velit elit
|
||||||
|
reprehenderit nostrud exercitation. Ea dolore adipisicing nulla
|
||||||
|
incididunt laboris sint commodo non mollit eiusmod. Tempor
|
||||||
|
nulla eu laborum tempor veniam laboris consequat non consequat
|
||||||
|
exercitation pariatur velit. Voluptate magna mollit esse
|
||||||
|
incididunt id. Pariatur eu irure esse ullamco fugiat culpa.
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sint dolore aliqua pariatur do mollit occaecat deserunt qui
|
||||||
|
proident non exercitation mollit cillum culpa. Est cupidatat
|
||||||
|
consequat ea commodo laborum labore sunt excepteur labore aute
|
||||||
|
est amet anim. Tempor deserunt labore mollit enim officia
|
||||||
|
aliqua occaecat. Ullamco mollit qui mollit do ex irure enim.
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
{hasScrolledCguToEnd ? (
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
<GradientButton
|
||||||
|
title="Accepter sans réserve"
|
||||||
|
onPress={() => {
|
||||||
|
setIsCguAccepted(true);
|
||||||
|
handleCloseCguModal();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BorderGradientButton
|
||||||
|
title="Refuser"
|
||||||
|
onPress={handleCloseCguModal}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Fais défiler jusqu'en bas pour afficher les actions
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { background, icons } from "../../assets";
|
|||||||
import BorderGradientButton from "../../components/BorderGradientButton";
|
import BorderGradientButton from "../../components/BorderGradientButton";
|
||||||
import SearchBar from "../../components/SearchBar";
|
import SearchBar from "../../components/SearchBar";
|
||||||
import ShareBtn from "../../components/ShareBtn/ShareBtn";
|
import ShareBtn from "../../components/ShareBtn/ShareBtn";
|
||||||
import { projectsRef } from "../../config/firebase";
|
import firebase, { projectsRef, usersRef } from "../../config/firebase";
|
||||||
import useDataFromRef from "../../hooks/useDataFromRef";
|
import useDataFromRef from "../../hooks/useDataFromRef";
|
||||||
import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails";
|
import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails";
|
||||||
import useSearch from "../../hooks/useSearch";
|
import useSearch from "../../hooks/useSearch";
|
||||||
@@ -37,6 +37,25 @@ import SearchResultsList from "../Library/components/SearchResultsList";
|
|||||||
import PlaybacksCard from "./components/PlaybacksCard";
|
import PlaybacksCard from "./components/PlaybacksCard";
|
||||||
import SongCard from "./components/SongCard";
|
import SongCard from "./components/SongCard";
|
||||||
import BorderGradient from "../../components/BorderGradient/BorderGradient.web";
|
import BorderGradient from "../../components/BorderGradient/BorderGradient.web";
|
||||||
|
|
||||||
|
const allowedPremiumLevels = new Set(["starter", "pro", "premium"]);
|
||||||
|
|
||||||
|
const normalizePremiumLevel = (value) => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return allowedPremiumLevels.has(normalized) ? normalized : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunkArray = (items = [], size = 10) => {
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < items.length; i += size) {
|
||||||
|
chunks.push(items.slice(i, i + size));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
};
|
||||||
|
|
||||||
const HitParade = () => {
|
const HitParade = () => {
|
||||||
const [selectedCategory, setSelectedCategory] = useState("Chansons");
|
const [selectedCategory, setSelectedCategory] = useState("Chansons");
|
||||||
const navigateToMusicDetails = useNavigateToMusicDetails();
|
const navigateToMusicDetails = useNavigateToMusicDetails();
|
||||||
@@ -92,6 +111,7 @@ const HitParade = () => {
|
|||||||
title={item?.title || "Sans titre"}
|
title={item?.title || "Sans titre"}
|
||||||
artist={item?.userName}
|
artist={item?.userName}
|
||||||
coverUrl={item?.coverUrl || null}
|
coverUrl={item?.coverUrl || null}
|
||||||
|
subscriptionLevel={getCreatorLevel(item)}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
navigateToMusicDetails({
|
navigateToMusicDetails({
|
||||||
projectId: item.id,
|
projectId: item.id,
|
||||||
@@ -118,6 +138,7 @@ const HitParade = () => {
|
|||||||
title={item?.title || "Sans titre"}
|
title={item?.title || "Sans titre"}
|
||||||
artist={item?.userName}
|
artist={item?.userName}
|
||||||
thumbnailUrl={resolvePlaybackThumbnail(item)}
|
thumbnailUrl={resolvePlaybackThumbnail(item)}
|
||||||
|
subscriptionLevel={getCreatorLevel(item)}
|
||||||
onPress={() => navigate(Routes.Playbacks, { projectId: item.id })}
|
onPress={() => navigate(Routes.Playbacks, { projectId: item.id })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -133,6 +154,7 @@ const HitParade = () => {
|
|||||||
title={item?.title || "Sans titre"}
|
title={item?.title || "Sans titre"}
|
||||||
artist={item?.userName}
|
artist={item?.userName}
|
||||||
coverUrl={item?.coverUrl || null}
|
coverUrl={item?.coverUrl || null}
|
||||||
|
subscriptionLevel={getCreatorLevel(item)}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
navigateToMusicDetails({
|
navigateToMusicDetails({
|
||||||
projectId: item.id,
|
projectId: item.id,
|
||||||
@@ -154,6 +176,7 @@ const HitParade = () => {
|
|||||||
title={item?.title || "Sans titre"}
|
title={item?.title || "Sans titre"}
|
||||||
artist={item?.userName}
|
artist={item?.userName}
|
||||||
thumbnailUrl={resolvePlaybackThumbnail(item)}
|
thumbnailUrl={resolvePlaybackThumbnail(item)}
|
||||||
|
subscriptionLevel={getCreatorLevel(item)}
|
||||||
onPress={() => navigate(Routes.Playbacks, { projectId: item.id })}
|
onPress={() => navigate(Routes.Playbacks, { projectId: item.id })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -188,6 +211,99 @@ const HitParade = () => {
|
|||||||
[musics]
|
[musics]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [creatorsById, setCreatorsById] = useState({});
|
||||||
|
|
||||||
|
const requiredUserIds = useMemo(() => {
|
||||||
|
const collected = new Set();
|
||||||
|
const collectUserId = (project) => {
|
||||||
|
const uid =
|
||||||
|
project && typeof project.userId === "string"
|
||||||
|
? project.userId.trim()
|
||||||
|
: null;
|
||||||
|
if (uid) {
|
||||||
|
collected.add(uid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
songsList.forEach(collectUserId);
|
||||||
|
playbackList.forEach(collectUserId);
|
||||||
|
return Array.from(collected);
|
||||||
|
}, [playbackList, songsList]);
|
||||||
|
|
||||||
|
const fetchCreatorsByIds = useCallback(
|
||||||
|
async (userIds) => {
|
||||||
|
if (!Array.isArray(userIds) || userIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedIds = userIds
|
||||||
|
.map((id) => (typeof id === "string" ? id.trim() : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (normalizedIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingIds = new Set(normalizedIds);
|
||||||
|
const nextCreators = {};
|
||||||
|
|
||||||
|
const idChunks = chunkArray(normalizedIds, 10);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
idChunks.map(async (chunk) => {
|
||||||
|
try {
|
||||||
|
const snapshot = await usersRef
|
||||||
|
.where(firebase.firestore.FieldPath.documentId(), "in", chunk)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
snapshot.docs.forEach((doc) => {
|
||||||
|
const data = doc.data() || {};
|
||||||
|
nextCreators[doc.id] = {
|
||||||
|
premiumLevel: normalizePremiumLevel(data.premiumLevel),
|
||||||
|
};
|
||||||
|
pendingIds.delete(doc.id);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
"HitParade: unable to fetch creators",
|
||||||
|
error?.message || error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
pendingIds.forEach((userId) => {
|
||||||
|
nextCreators[userId] = { premiumLevel: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(nextCreators).length > 0) {
|
||||||
|
setCreatorsById((previous) => ({ ...previous, ...nextCreators }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setCreatorsById]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const missingIds = requiredUserIds.filter((id) => !creatorsById[id]);
|
||||||
|
if (missingIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchCreatorsByIds(missingIds);
|
||||||
|
}, [creatorsById, fetchCreatorsByIds, requiredUserIds]);
|
||||||
|
|
||||||
|
const getCreatorLevel = useCallback(
|
||||||
|
(project) => {
|
||||||
|
const uid =
|
||||||
|
project && typeof project.userId === "string"
|
||||||
|
? project.userId.trim()
|
||||||
|
: null;
|
||||||
|
if (!uid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return creatorsById?.[uid]?.premiumLevel || null;
|
||||||
|
},
|
||||||
|
[creatorsById]
|
||||||
|
);
|
||||||
|
|
||||||
const playbackResults = useMemo(
|
const playbackResults = useMemo(
|
||||||
() => (Array.isArray(playbacks) ? playbacks : []),
|
() => (Array.isArray(playbacks) ? playbacks : []),
|
||||||
[playbacks]
|
[playbacks]
|
||||||
@@ -246,7 +362,7 @@ const HitParade = () => {
|
|||||||
}, [closeDropdown, dropdownVisible, isWeb]);
|
}, [closeDropdown, dropdownVisible, isWeb]);
|
||||||
|
|
||||||
const shouldShowResults = isWeb && dropdownVisible;
|
const shouldShowResults = isWeb && dropdownVisible;
|
||||||
const shouldBlurContent = shouldShowResults && hasSearchQuery;
|
const shouldBlurContent = shouldShowResults;
|
||||||
|
|
||||||
const handlePlayRandomSong = useCallback(() => {
|
const handlePlayRandomSong = useCallback(() => {
|
||||||
const arr = Array.isArray(topSongs) ? topSongs : [];
|
const arr = Array.isArray(topSongs) ? topSongs : [];
|
||||||
@@ -520,7 +636,9 @@ const HitParade = () => {
|
|||||||
StyleSheet.absoluteFillObject,
|
StyleSheet.absoluteFillObject,
|
||||||
{
|
{
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
|
borderRadius: 18,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.25)",
|
backgroundColor: "rgba(0, 0, 0, 0.25)",
|
||||||
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BlurView } from "expo-blur";
|
|||||||
import { img } from "../../../assets";
|
import { img } from "../../../assets";
|
||||||
import { Palette, Style } from "../../../styles";
|
import { Palette, Style } from "../../../styles";
|
||||||
import { FONT_FAMILY } from "../../../styles/Fonts";
|
import { FONT_FAMILY } from "../../../styles/Fonts";
|
||||||
|
import SubscriptionBadge from "../../../components/SubscriptionBadge";
|
||||||
|
|
||||||
const PlaybacksCard = ({
|
const PlaybacksCard = ({
|
||||||
rank = 1,
|
rank = 1,
|
||||||
@@ -12,6 +13,7 @@ const PlaybacksCard = ({
|
|||||||
thumbnailUrl = null,
|
thumbnailUrl = null,
|
||||||
coverUrl = null,
|
coverUrl = null,
|
||||||
onPress = () => null,
|
onPress = () => null,
|
||||||
|
subscriptionLevel = null,
|
||||||
}) => {
|
}) => {
|
||||||
const resolveUri = (value) =>
|
const resolveUri = (value) =>
|
||||||
typeof value === "string" && value.trim().length > 0 ? value : null;
|
typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||||
@@ -38,43 +40,67 @@ const PlaybacksCard = ({
|
|||||||
style={{
|
style={{
|
||||||
...Style.containerRow,
|
...Style.containerRow,
|
||||||
gap: 15,
|
gap: 15,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<View
|
||||||
style={{
|
style={{
|
||||||
fontSize: 22,
|
...Style.containerRow,
|
||||||
color: Palette.white,
|
gap: 15,
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rank}
|
|
||||||
</Text>
|
|
||||||
<Image
|
|
||||||
source={imageUri ? { uri: imageUri } : img.placeholder3}
|
|
||||||
style={{ width: 67, height: 108, borderRadius: 16 }}
|
|
||||||
/>
|
|
||||||
<View>
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 22,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
fontFamily: FONT_FAMILY.InterMedium,
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
|
||||||
>
|
>
|
||||||
{title}
|
{rank}
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: Palette.white,
|
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{artist}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Image
|
||||||
|
source={imageUri ? { uri: imageUri } : img.placeholder3}
|
||||||
|
style={{ width: 67, height: 108, borderRadius: 16 }}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
...Style.containerRow,
|
||||||
|
gap: 6,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
flexShrink: 1,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{artist}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<SubscriptionBadge level={subscriptionLevel} size={36} />
|
||||||
</View>
|
</View>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { BlurView } from "expo-blur";
|
|||||||
import Style, { size } from "../../../styles/Style";
|
import Style, { size } from "../../../styles/Style";
|
||||||
import { Palette } from "../../../styles";
|
import { Palette } from "../../../styles";
|
||||||
import { FONT_FAMILY } from "../../../styles/Fonts";
|
import { FONT_FAMILY } from "../../../styles/Fonts";
|
||||||
|
import SubscriptionBadge from "../../../components/SubscriptionBadge";
|
||||||
|
|
||||||
const SongCard = ({
|
const SongCard = ({
|
||||||
rank = 1,
|
rank = 1,
|
||||||
@@ -19,6 +20,7 @@ const SongCard = ({
|
|||||||
artist = "MusicLand",
|
artist = "MusicLand",
|
||||||
coverUrl = null,
|
coverUrl = null,
|
||||||
onPress = null,
|
onPress = null,
|
||||||
|
subscriptionLevel = null,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -69,7 +71,7 @@ const SongCard = ({
|
|||||||
>
|
>
|
||||||
{rank}
|
{rank}
|
||||||
</Text>
|
</Text>
|
||||||
<View>
|
<View style={{ flex: 1 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -79,16 +81,32 @@ const SongCard = ({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<View
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
...Style.containerRow,
|
||||||
color: Palette.white,
|
gap: 6,
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{artist}
|
<Text
|
||||||
</Text>
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
flexShrink: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{artist}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<SubscriptionBadge
|
||||||
|
level={subscriptionLevel}
|
||||||
|
size={36}
|
||||||
|
style={{ marginLeft: "auto" }}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ const Library = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const shouldShowResults = dropdownVisible;
|
const shouldShowResults = dropdownVisible;
|
||||||
const shouldBlurContent = dropdownVisible && hasSearchQuery;
|
const shouldBlurContent = dropdownVisible;
|
||||||
|
|
||||||
const handleChangeText = (value) => {
|
const handleChangeText = (value) => {
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
@@ -214,8 +214,9 @@ const Library = () => {
|
|||||||
StyleSheet.absoluteFillObject,
|
StyleSheet.absoluteFillObject,
|
||||||
{
|
{
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
borderRadius: 0,
|
borderRadius: 18,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.25)",
|
backgroundColor: "rgba(0, 0, 0, 0.25)",
|
||||||
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import { useIsFocused, useNavigation, StackActions } from "@react-navigation/nat
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Image, Platform, Text, View } from "react-native";
|
import { Platform, 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 { responsiveHeight } from "react-native-responsive-dimensions";
|
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||||
import { ai, background } from "../../assets";
|
import { background } from "../../assets";
|
||||||
import AppAlert from "../../components/Alert";
|
import AppAlert from "../../components/Alert";
|
||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||||
import ProgressBar from "../../components/ProgressBar";
|
import ProgressBar from "../../components/ProgressBar";
|
||||||
import firebase, { projectsRef } from "../../config/firebase";
|
import firebase, { projectsRef } from "../../config/firebase";
|
||||||
import { isWeb } from "../../hooks/useLayoutType";
|
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
import { Routes } from "../../navigation";
|
import { Routes } from "../../navigation";
|
||||||
import { navigate } from "../../navigation/NavigationService";
|
import { navigate } from "../../navigation/NavigationService";
|
||||||
@@ -24,6 +23,7 @@ const GeneratingSong = () => {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { selectedProjectId, selectedProject } = useUser();
|
const { selectedProjectId, selectedProject } = useUser();
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [isGenerationInFlight, setIsGenerationInFlight] = useState(false);
|
||||||
const { setIsLoading } = useMinuit();
|
const { setIsLoading } = useMinuit();
|
||||||
const progressTimerRef = useRef(null);
|
const progressTimerRef = useRef(null);
|
||||||
const navigatedRef = useRef(false);
|
const navigatedRef = useRef(false);
|
||||||
@@ -60,21 +60,29 @@ const GeneratingSong = () => {
|
|||||||
? selectedProject.musicUrls.filter(Boolean)
|
? selectedProject.musicUrls.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
const hasReadySong = readyUrls.length > 0 || !!selectedProject?.songUrl;
|
const hasReadySong = readyUrls.length > 0 || !!selectedProject?.songUrl;
|
||||||
|
const isActivelyGenerating =
|
||||||
|
status === "GENERATING" ||
|
||||||
|
(isGenerationInFlight &&
|
||||||
|
status !== "FAILED" &&
|
||||||
|
status !== "GENERATED");
|
||||||
|
|
||||||
if (status !== "GENERATING") {
|
if (!isActivelyGenerating) {
|
||||||
// Reset local start when leaving generating state
|
// Reset local start when leaving generating state
|
||||||
localStartRef.current = null;
|
localStartRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "GENERATED") {
|
if (status === "GENERATED") {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
|
setIsGenerationInFlight(false);
|
||||||
setProgress(hasReadySong ? 100 : maxGeneratingProgress);
|
setProgress(hasReadySong ? 100 : maxGeneratingProgress);
|
||||||
return () => clearTimer();
|
return () => clearTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status !== "GENERATING") {
|
if (!isActivelyGenerating) {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
setProgress(0);
|
if (status !== "FAILED") {
|
||||||
|
setProgress(0);
|
||||||
|
}
|
||||||
return () => clearTimer();
|
return () => clearTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +90,8 @@ const GeneratingSong = () => {
|
|||||||
const gs = selectedProject?.generationStartAt;
|
const gs = selectedProject?.generationStartAt;
|
||||||
if (gs?.toDate) return gs.toDate();
|
if (gs?.toDate) return gs.toDate();
|
||||||
if (gs) return new Date(gs);
|
if (gs) return new Date(gs);
|
||||||
if (status === "GENERATING") {
|
if (!localStartRef.current) localStartRef.current = new Date();
|
||||||
if (!localStartRef.current) localStartRef.current = new Date();
|
return localStartRef.current;
|
||||||
return localStartRef.current;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
@@ -98,7 +103,7 @@ const GeneratingSong = () => {
|
|||||||
const elapsed = moment().diff(moment(startDate));
|
const elapsed = moment().diff(moment(startDate));
|
||||||
const raw = Math.floor((elapsed / totalMs) * 100);
|
const raw = Math.floor((elapsed / totalMs) * 100);
|
||||||
// While status is GENERATING, block visual progress at 90%
|
// While status is GENERATING, block visual progress at 90%
|
||||||
const pct = Math.max(0, Math.min(maxGeneratingProgress, raw));
|
const pct = Math.max(1, Math.min(maxGeneratingProgress, raw));
|
||||||
setProgress(pct);
|
setProgress(pct);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,6 +112,7 @@ const GeneratingSong = () => {
|
|||||||
progressTimerRef.current = global.setInterval(update, 1000);
|
progressTimerRef.current = global.setInterval(update, 1000);
|
||||||
return () => clearTimer();
|
return () => clearTimer();
|
||||||
}, [
|
}, [
|
||||||
|
isGenerationInFlight,
|
||||||
selectedProject?.musicStatus,
|
selectedProject?.musicStatus,
|
||||||
selectedProject?.generationStartAt,
|
selectedProject?.generationStartAt,
|
||||||
selectedProject?.musicUrls,
|
selectedProject?.musicUrls,
|
||||||
@@ -122,6 +128,15 @@ const GeneratingSong = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedProject?.musicStatus, selectedProjectId, navigation]);
|
}, [selectedProject?.musicStatus, selectedProjectId, navigation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
selectedProject?.musicStatus === "GENERATED" ||
|
||||||
|
selectedProject?.musicStatus === "FAILED"
|
||||||
|
) {
|
||||||
|
setIsGenerationInFlight(false);
|
||||||
|
}
|
||||||
|
}, [selectedProject?.musicStatus]);
|
||||||
|
|
||||||
const effectiveConfig = useMemo(() => {
|
const effectiveConfig = useMemo(() => {
|
||||||
// Use selectedProject.musicConfig only
|
// Use selectedProject.musicConfig only
|
||||||
const cfg = selectedProject?.musicConfig || {};
|
const cfg = selectedProject?.musicConfig || {};
|
||||||
@@ -138,6 +153,11 @@ const GeneratingSong = () => {
|
|||||||
async function startMusicGeneration() {
|
async function startMusicGeneration() {
|
||||||
try {
|
try {
|
||||||
console.log("startMusicGeneration");
|
console.log("startMusicGeneration");
|
||||||
|
setIsGenerationInFlight(true);
|
||||||
|
if (!localStartRef.current) {
|
||||||
|
localStartRef.current = new Date();
|
||||||
|
}
|
||||||
|
setProgress(1);
|
||||||
await setIsLoading(true);
|
await setIsLoading(true);
|
||||||
const callable = firebase
|
const callable = firebase
|
||||||
.functions()
|
.functions()
|
||||||
@@ -217,6 +237,7 @@ const GeneratingSong = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
askedRef.current = false;
|
askedRef.current = false;
|
||||||
|
setIsGenerationInFlight(false);
|
||||||
} finally {
|
} finally {
|
||||||
await setIsLoading(false);
|
await setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -280,7 +301,11 @@ const GeneratingSong = () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const progressStatus = isFailed ? "error" : "default";
|
const progressStatus = isFailed ? "error" : "default";
|
||||||
const progressLabel = isFailed ? "Erreur" : `${progress}%`;
|
const displayProgress = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, Math.round(progress || 0)),
|
||||||
|
);
|
||||||
|
const progressLabel = isFailed ? "Erreur" : `${displayProgress}%`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page headerType="NONE" backgroundImg={background.studioBG2}>
|
<Page headerType="NONE" backgroundImg={background.studioBG2}>
|
||||||
@@ -339,7 +364,7 @@ const GeneratingSong = () => {
|
|||||||
<View style={{ alignItems: "center", gap: 16 }}>
|
<View style={{ alignItems: "center", gap: 16 }}>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
gradient
|
gradient
|
||||||
progress={progress}
|
progress={displayProgress}
|
||||||
status={progressStatus}
|
status={progressStatus}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
+258
-56
@@ -69,7 +69,7 @@ const getIntervalLabel = (recurring) => {
|
|||||||
return `tous les ${count} ${count > 1 ? terms.plural : terms.singular}`;
|
return `tous les ${count} ${count > 1 ? terms.plural : terms.singular}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SubscriptionCard({ plan, selected, onSelect }) {
|
function SubscriptionCard({ plan, selected, onSelect, isAnnual }) {
|
||||||
const planKey = getPlanKeyForBadge(plan);
|
const planKey = getPlanKeyForBadge(plan);
|
||||||
const planName =
|
const planName =
|
||||||
(planKey && PLAN_DISPLAY_NAME_BY_KEY[planKey]) ||
|
(planKey && PLAN_DISPLAY_NAME_BY_KEY[planKey]) ||
|
||||||
@@ -87,7 +87,6 @@ function SubscriptionCard({ plan, selected, onSelect }) {
|
|||||||
const planBadgeKey = planKey;
|
const planBadgeKey = planKey;
|
||||||
const planBadgeSource =
|
const planBadgeSource =
|
||||||
planBadgeKey && subBadges[planBadgeKey] ? subBadges[planBadgeKey] : null;
|
planBadgeKey && subBadges[planBadgeKey] ? subBadges[planBadgeKey] : null;
|
||||||
const planFeatures = SUBSCRIPTION_FEATURES;
|
|
||||||
const handleSelect = React.useCallback(() => {
|
const handleSelect = React.useCallback(() => {
|
||||||
if (typeof onSelect === "function" && plan?.priceId) {
|
if (typeof onSelect === "function" && plan?.priceId) {
|
||||||
onSelect(plan.priceId);
|
onSelect(plan.priceId);
|
||||||
@@ -156,22 +155,23 @@ function SubscriptionCard({ plan, selected, onSelect }) {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{planFeatures?.length ? (
|
|
||||||
<View style={styles.features}>
|
|
||||||
{planFeatures.map((feature) => (
|
|
||||||
<Text key={feature} style={styles.featureText}>
|
|
||||||
{feature}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
|
{isAnnual ? (
|
||||||
|
<View style={styles.annualBadge}>
|
||||||
|
<Text style={styles.annualBadgeText}>2 mois offert</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRICE_PRIORITY_BY_PERIOD = {
|
const PRICE_PRIORITY_BY_PERIOD = {
|
||||||
|
monthly: [
|
||||||
|
"price_1SPgitCzf2o5bDRdbnhLFx6f", // Starter
|
||||||
|
"price_1SPgjCCzf2o5bDRdr08Xzp8u", // Pro
|
||||||
|
"price_1SPgjaCzf2o5bDRdd9Xo2u26", // Premium
|
||||||
|
],
|
||||||
annual: [
|
annual: [
|
||||||
"price_1SPgkDCzf2o5bDRdNGLVNeQ3", // Starter
|
"price_1SPgkDCzf2o5bDRdNGLVNeQ3", // Starter
|
||||||
"price_1SPgkXCzf2o5bDRdejBVxEBY", // Pro
|
"price_1SPgkXCzf2o5bDRdejBVxEBY", // Pro
|
||||||
@@ -180,6 +180,11 @@ const PRICE_PRIORITY_BY_PERIOD = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PACK_PRICE_ID_BY_PERIOD = {
|
const PACK_PRICE_ID_BY_PERIOD = {
|
||||||
|
monthly: {
|
||||||
|
starter: "price_1SPgitCzf2o5bDRdbnhLFx6f",
|
||||||
|
pro: "price_1SPgjCCzf2o5bDRdr08Xzp8u",
|
||||||
|
premium: "price_1SPgjaCzf2o5bDRdd9Xo2u26",
|
||||||
|
},
|
||||||
annual: {
|
annual: {
|
||||||
starter: "price_1SPgkDCzf2o5bDRdNGLVNeQ3",
|
starter: "price_1SPgkDCzf2o5bDRdNGLVNeQ3",
|
||||||
pro: "price_1SPgkXCzf2o5bDRdejBVxEBY",
|
pro: "price_1SPgkXCzf2o5bDRdejBVxEBY",
|
||||||
@@ -212,12 +217,6 @@ const PLAN_DISPLAY_NAME_BY_KEY = {
|
|||||||
premium: "Play Backer Gold",
|
premium: "Play Backer Gold",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUBSCRIPTION_FEATURES = [
|
|
||||||
"*Eligible au Hit parade Vidéo",
|
|
||||||
"Privilège Membre : Récompense doublée",
|
|
||||||
"Eligible au hit parade Artiste trimestriel et annuel",
|
|
||||||
];
|
|
||||||
|
|
||||||
const getPlanKeyForBadge = (plan) => {
|
const getPlanKeyForBadge = (plan) => {
|
||||||
if (!plan || typeof plan !== "object") {
|
if (!plan || typeof plan !== "object") {
|
||||||
return null;
|
return null;
|
||||||
@@ -293,7 +292,7 @@ const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360;
|
|||||||
const PAGE_BACKGROUND_COLOR = "#303438";
|
const PAGE_BACKGROUND_COLOR = "#303438";
|
||||||
const SUBSCRIPTION_DISCLAIMER =
|
const SUBSCRIPTION_DISCLAIMER =
|
||||||
"Résiliable en un clic à tout moment. Les prix sont indiqués TTC. Contacte-nous pour des besoins spécifiques (facturation annuelle, volume, offres éducation).";
|
"Résiliable en un clic à tout moment. Les prix sont indiqués TTC. Contacte-nous pour des besoins spécifiques (facturation annuelle, volume, offres éducation).";
|
||||||
const CARD_MIN_HEIGHT = isWeb ? 320 : 240;
|
const CARD_MIN_HEIGHT = isWeb ? 260 : 180;
|
||||||
|
|
||||||
export default function Subscriptions() {
|
export default function Subscriptions() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -313,16 +312,29 @@ export default function Subscriptions() {
|
|||||||
catalogError,
|
catalogError,
|
||||||
createSubscriptionCheckout,
|
createSubscriptionCheckout,
|
||||||
} = useStripe();
|
} = useStripe();
|
||||||
|
const [selectedPeriodKey, setSelectedPeriodKey] = React.useState("annual");
|
||||||
const [selectedPriceId, setSelectedPriceId] = React.useState(null);
|
const [selectedPriceId, setSelectedPriceId] = React.useState(null);
|
||||||
const [processingPriceId, setProcessingPriceId] = React.useState(null);
|
const [processingPriceId, setProcessingPriceId] = React.useState(null);
|
||||||
const [errorMessage, setErrorMessage] = React.useState(null);
|
const [errorMessage, setErrorMessage] = React.useState(null);
|
||||||
const initialPackHandledRef = React.useRef(false);
|
const initialPackHandledRef = React.useRef(false);
|
||||||
|
|
||||||
const normalizedPlans = React.useMemo(
|
const normalizedPlansByPeriod = React.useMemo(
|
||||||
() => normalizePlans(subscriptions?.annual, "annual"),
|
() => ({
|
||||||
[subscriptions?.annual]
|
monthly: normalizePlans(subscriptions?.monthly, "monthly"),
|
||||||
|
annual: normalizePlans(subscriptions?.annual, "annual"),
|
||||||
|
}),
|
||||||
|
[subscriptions?.annual, subscriptions?.monthly]
|
||||||
);
|
);
|
||||||
const hasAnyPlan = (normalizedPlans?.length || 0) > 0;
|
|
||||||
|
const availablePeriods = React.useMemo(
|
||||||
|
() =>
|
||||||
|
["monthly", "annual"].filter(
|
||||||
|
(period) => (normalizedPlansByPeriod?.[period]?.length || 0) > 0
|
||||||
|
),
|
||||||
|
[normalizedPlansByPeriod]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAnyPlan = availablePeriods.length > 0;
|
||||||
const isLoadingPlans = isCatalogLoading && !hasAnyPlan;
|
const isLoadingPlans = isCatalogLoading && !hasAnyPlan;
|
||||||
const combinedErrorMessage = errorMessage || catalogError;
|
const combinedErrorMessage = errorMessage || catalogError;
|
||||||
|
|
||||||
@@ -331,25 +343,39 @@ export default function Subscriptions() {
|
|||||||
}, [initialSubscriptionPack]);
|
}, [initialSubscriptionPack]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
const periodEntries = Object.entries(normalizedPlansByPeriod).filter(
|
||||||
|
([, plans]) => (plans?.length || 0) > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!periodEntries.length) {
|
||||||
|
setSelectedPriceId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const shouldApplyPack =
|
const shouldApplyPack =
|
||||||
Boolean(initialSubscriptionPack) && !initialPackHandledRef.current;
|
Boolean(initialSubscriptionPack) && !initialPackHandledRef.current;
|
||||||
|
|
||||||
let matchedPriceId = null;
|
let matchedPriceId = null;
|
||||||
|
let matchedPeriodKey = selectedPeriodKey;
|
||||||
|
let nextPeriodKey =
|
||||||
|
periodEntries.find(([periodKey]) => periodKey === selectedPeriodKey)?.[0] ||
|
||||||
|
periodEntries[0]?.[0] ||
|
||||||
|
"annual";
|
||||||
|
|
||||||
if (shouldApplyPack && normalizedPlans?.length) {
|
if (shouldApplyPack) {
|
||||||
const desired = initialSubscriptionPack.trim().toLowerCase();
|
const desired = initialSubscriptionPack.trim().toLowerCase();
|
||||||
const candidateId = PACK_PRICE_ID_BY_PERIOD?.annual?.[desired];
|
for (const [periodKey, plans] of periodEntries) {
|
||||||
if (candidateId) {
|
const candidateId = PACK_PRICE_ID_BY_PERIOD?.[periodKey]?.[desired];
|
||||||
const exists = normalizedPlans.some(
|
if (candidateId) {
|
||||||
(plan) => plan?.priceId === candidateId
|
const exists = plans.some((plan) => plan?.priceId === candidateId);
|
||||||
);
|
if (exists) {
|
||||||
if (exists) {
|
matchedPriceId = candidateId;
|
||||||
matchedPriceId = candidateId;
|
matchedPeriodKey = periodKey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!matchedPriceId) {
|
const matched = plans.find((plan) => {
|
||||||
const matched = normalizedPlans.find((plan) => {
|
|
||||||
const label = (plan?.product?.name || plan?.nickname || "")
|
const label = (plan?.product?.name || plan?.nickname || "")
|
||||||
.toString()
|
.toString()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
@@ -358,25 +384,38 @@ export default function Subscriptions() {
|
|||||||
|
|
||||||
if (matched?.priceId) {
|
if (matched?.priceId) {
|
||||||
matchedPriceId = matched.priceId;
|
matchedPriceId = matched.priceId;
|
||||||
|
matchedPeriodKey = periodKey;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedPriceId((current) => {
|
if (matchedPriceId) {
|
||||||
|
nextPeriodKey = matchedPeriodKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plansForPeriod = normalizedPlansByPeriod?.[nextPeriodKey] || [];
|
||||||
|
|
||||||
|
const nextPriceId = (() => {
|
||||||
if (matchedPriceId) {
|
if (matchedPriceId) {
|
||||||
return matchedPriceId;
|
return matchedPriceId;
|
||||||
}
|
}
|
||||||
|
const hasCurrent = plansForPeriod.some(
|
||||||
const hasCurrent = normalizedPlans?.some(
|
(plan) => plan.priceId === selectedPriceId
|
||||||
(plan) => plan.priceId === current
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasCurrent) {
|
if (hasCurrent) {
|
||||||
return current;
|
return selectedPriceId;
|
||||||
}
|
}
|
||||||
|
return plansForPeriod?.[0]?.priceId || null;
|
||||||
|
})();
|
||||||
|
|
||||||
return normalizedPlans?.[0]?.priceId || null;
|
if (nextPeriodKey !== selectedPeriodKey) {
|
||||||
});
|
setSelectedPeriodKey(nextPeriodKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPriceId !== selectedPriceId) {
|
||||||
|
setSelectedPriceId(nextPriceId);
|
||||||
|
}
|
||||||
|
|
||||||
if (matchedPriceId || (shouldApplyPack && !isCatalogLoading)) {
|
if (matchedPriceId || (shouldApplyPack && !isCatalogLoading)) {
|
||||||
initialPackHandledRef.current = true;
|
initialPackHandledRef.current = true;
|
||||||
@@ -384,10 +423,12 @@ export default function Subscriptions() {
|
|||||||
}, [
|
}, [
|
||||||
initialSubscriptionPack,
|
initialSubscriptionPack,
|
||||||
isCatalogLoading,
|
isCatalogLoading,
|
||||||
normalizedPlans,
|
normalizedPlansByPeriod,
|
||||||
|
selectedPeriodKey,
|
||||||
|
selectedPriceId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const currentPlans = normalizedPlans || [];
|
const currentPlans = normalizedPlansByPeriod?.[selectedPeriodKey] || [];
|
||||||
|
|
||||||
const handleSelect = React.useCallback(
|
const handleSelect = React.useCallback(
|
||||||
(priceId) => {
|
(priceId) => {
|
||||||
@@ -438,13 +479,36 @@ export default function Subscriptions() {
|
|||||||
goBack();
|
goBack();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handlePeriodChange = React.useCallback(
|
||||||
|
(periodKey) => {
|
||||||
|
if (!periodKey || periodKey === selectedPeriodKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedPeriodKey(periodKey);
|
||||||
|
setSelectedPriceId(null);
|
||||||
|
setProcessingPriceId(null);
|
||||||
|
},
|
||||||
|
[selectedPeriodKey]
|
||||||
|
);
|
||||||
|
|
||||||
const renderHeaderSection = React.useCallback(() => {
|
const renderHeaderSection = React.useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>
|
<Text style={styles.title}>
|
||||||
Choisissez l’abonnement qui vous correspond
|
Rejoignez le club MusicLand
|
||||||
</Text>
|
</Text>
|
||||||
|
<View style={styles.benefitsBox}>
|
||||||
|
<Text style={styles.benefitsTitle}>Privilège Membre :</Text>
|
||||||
|
<View style={styles.benefitsList}>
|
||||||
|
<Text style={styles.benefitsItem}>
|
||||||
|
- Eligible au concours mensuel chanson/Vidéo
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.benefitsItem}>
|
||||||
|
- Crédits gratuits tous les mois
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{combinedErrorMessage ? (
|
{combinedErrorMessage ? (
|
||||||
<Text style={styles.errorText}>{combinedErrorMessage}</Text>
|
<Text style={styles.errorText}>{combinedErrorMessage}</Text>
|
||||||
@@ -453,6 +517,66 @@ export default function Subscriptions() {
|
|||||||
);
|
);
|
||||||
}, [combinedErrorMessage]);
|
}, [combinedErrorMessage]);
|
||||||
|
|
||||||
|
const renderSegmentedControl = React.useCallback(() => {
|
||||||
|
const segments = [
|
||||||
|
{ key: "monthly", label: "Mensuel", plans: normalizedPlansByPeriod?.monthly },
|
||||||
|
{ key: "annual", label: "Annuel", plans: normalizedPlansByPeriod?.annual },
|
||||||
|
];
|
||||||
|
|
||||||
|
const visibleSegments = segments.filter(
|
||||||
|
(segment) => (segment.plans?.length || 0) > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visibleSegments.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.segmentedControl,
|
||||||
|
isMobile && styles.segmentedControlMobile,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{visibleSegments.map((segment) => {
|
||||||
|
const isActive = selectedPeriodKey === segment.key;
|
||||||
|
const showAnnualPromo = segment.key === "annual";
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={segment.key}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected: isActive }}
|
||||||
|
onPress={() => handlePeriodChange(segment.key)}
|
||||||
|
style={[
|
||||||
|
styles.segmentButton,
|
||||||
|
isActive && styles.segmentButtonActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.segmentLabel,
|
||||||
|
isActive && styles.segmentLabelActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{segment.label}
|
||||||
|
</Text>
|
||||||
|
{showAnnualPromo ? (
|
||||||
|
<View style={styles.segmentBadge}>
|
||||||
|
<Text style={styles.segmentBadgeText}>-16%</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
handlePeriodChange,
|
||||||
|
isMobile,
|
||||||
|
normalizedPlansByPeriod,
|
||||||
|
selectedPeriodKey,
|
||||||
|
]);
|
||||||
|
|
||||||
const renderMobilePlanItem = React.useCallback(
|
const renderMobilePlanItem = React.useCallback(
|
||||||
({ item }) => (
|
({ item }) => (
|
||||||
<View style={styles.mobileCard}>
|
<View style={styles.mobileCard}>
|
||||||
@@ -460,10 +584,11 @@ export default function Subscriptions() {
|
|||||||
plan={item}
|
plan={item}
|
||||||
selected={selectedPriceId === item.priceId}
|
selected={selectedPriceId === item.priceId}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
isAnnual={selectedPeriodKey === "annual"}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
[handleSelect, selectedPriceId]
|
[handleSelect, selectedPeriodKey, selectedPriceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderMobileEmptyComponent = React.useCallback(() => {
|
const renderMobileEmptyComponent = React.useCallback(() => {
|
||||||
@@ -497,9 +622,10 @@ export default function Subscriptions() {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.mobileStickyHeader}>
|
<View style={styles.mobileStickyHeader}>
|
||||||
{renderHeaderSection()}
|
{renderHeaderSection()}
|
||||||
|
{renderSegmentedControl()}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}, [renderHeaderSection]);
|
}, [renderHeaderSection, renderSegmentedControl]);
|
||||||
|
|
||||||
const renderMobileBottomActions = React.useCallback(() => {
|
const renderMobileBottomActions = React.useCallback(() => {
|
||||||
return (
|
return (
|
||||||
@@ -588,6 +714,8 @@ export default function Subscriptions() {
|
|||||||
<>
|
<>
|
||||||
{renderHeaderSection()}
|
{renderHeaderSection()}
|
||||||
|
|
||||||
|
{renderSegmentedControl()}
|
||||||
|
|
||||||
{isLoadingPlans && currentPlanCount === 0 ? (
|
{isLoadingPlans && currentPlanCount === 0 ? (
|
||||||
<View style={styles.loaderContainer}>
|
<View style={styles.loaderContainer}>
|
||||||
<ActivityIndicator color={Palette.white} />
|
<ActivityIndicator color={Palette.white} />
|
||||||
@@ -607,6 +735,7 @@ export default function Subscriptions() {
|
|||||||
plan={plan}
|
plan={plan}
|
||||||
selected={selectedPriceId === plan.priceId}
|
selected={selectedPriceId === plan.priceId}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
isAnnual={selectedPeriodKey === "annual"}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -700,6 +829,29 @@ const styles = StyleSheet.create({
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
|
benefitsBox: {
|
||||||
|
width: "100%",
|
||||||
|
gap: 6,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||||
|
},
|
||||||
|
benefitsTitle: {
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.white,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
benefitsList: {
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
benefitsItem: {
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "rgba(255, 255, 255, 0.85)",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
webBackContainer: {
|
webBackContainer: {
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
@@ -760,6 +912,61 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "stretch",
|
alignItems: "stretch",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
|
segmentedControl: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignSelf: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||||
|
marginTop: isWeb ? 12 : 8,
|
||||||
|
marginBottom: isWeb ? 8 : 4,
|
||||||
|
},
|
||||||
|
segmentButton: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
borderRadius: 999,
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
segmentButtonActive: {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.18)",
|
||||||
|
},
|
||||||
|
segmentLabel: {
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
|
},
|
||||||
|
segmentLabelActive: {
|
||||||
|
color: Palette.white,
|
||||||
|
},
|
||||||
|
segmentBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: -6,
|
||||||
|
right: -8,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: Palette.red,
|
||||||
|
},
|
||||||
|
segmentBadgeText: {
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
fontSize: 11,
|
||||||
|
color: Palette.white,
|
||||||
|
},
|
||||||
|
annualBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: isWeb ? 12 : 10,
|
||||||
|
right: isWeb ? 12 : 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: Palette.red,
|
||||||
|
},
|
||||||
|
annualBadgeText: {
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
fontSize: 12,
|
||||||
|
color: Palette.white,
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -772,6 +979,7 @@ const styles = StyleSheet.create({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: CARD_MIN_HEIGHT,
|
minHeight: CARD_MIN_HEIGHT,
|
||||||
|
position: "relative",
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -854,16 +1062,6 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Palette.primary,
|
color: Palette.primary,
|
||||||
},
|
},
|
||||||
features: {
|
|
||||||
marginTop: isWeb ? 12 : 8,
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
featureText: {
|
|
||||||
fontFamily: FONT_FAMILY.InterMedium,
|
|
||||||
fontSize: 13,
|
|
||||||
lineHeight: 18,
|
|
||||||
color: "rgba(255, 255, 255, 0.85)",
|
|
||||||
},
|
|
||||||
priceValue: {
|
priceValue: {
|
||||||
fontFamily: FONT_FAMILY.InterBold,
|
fontFamily: FONT_FAMILY.InterBold,
|
||||||
fontSize: isWeb ? 22 : 20,
|
fontSize: isWeb ? 22 : 20,
|
||||||
@@ -904,6 +1102,10 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: PAGE_BACKGROUND_COLOR,
|
backgroundColor: PAGE_BACKGROUND_COLOR,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
|
segmentedControlMobile: {
|
||||||
|
alignSelf: "center",
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
mobileList: {
|
mobileList: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|||||||
@@ -445,8 +445,17 @@ const PouchReady = () => {
|
|||||||
Génération en cours.
|
Génération en cours.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<View style={{ alignItems: "center", gap: 10 }}>
|
<View
|
||||||
<ProgressBar gradient progress={coverProgressValue} />
|
style={[
|
||||||
|
styles.coverProgressWrapper,
|
||||||
|
isWeb ? styles.coverProgressWrapperWeb : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ProgressBar
|
||||||
|
gradient
|
||||||
|
progress={coverProgressValue}
|
||||||
|
containerStyle={styles.coverProgressBar}
|
||||||
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
@@ -824,6 +833,17 @@ const styles = StyleSheet.create({
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
minWidth: 240,
|
minWidth: 240,
|
||||||
},
|
},
|
||||||
|
coverProgressWrapper: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
width: 220,
|
||||||
|
},
|
||||||
|
coverProgressWrapperWeb: {
|
||||||
|
width: 260,
|
||||||
|
},
|
||||||
|
coverProgressBar: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
customInputWrapper: {
|
customInputWrapper: {
|
||||||
alignSelf: "stretch",
|
alignSelf: "stretch",
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
|
|||||||
@@ -414,7 +414,9 @@ const SongDownload = ({ route }) => {
|
|||||||
size={22}
|
size={22}
|
||||||
color={Palette.white}
|
color={Palette.white}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.downloadText}>Télécharger la musique</Text>
|
<Text style={styles.downloadText}>
|
||||||
|
Acheter ce morceau pour 1,99€
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user