clear more ticket, sub and design
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import React from "react";
|
||||
import { Image, TextInput, View } from "react-native";
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import { Image, Pressable, TextInput } from "react-native";
|
||||
import { icons } from "../assets";
|
||||
import { Palette } from "../styles";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
@@ -10,8 +10,99 @@ const SearchBar = ({
|
||||
placeholder = "Que souhaites-tu écouter?",
|
||||
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 (
|
||||
<View style={{ borderRadius: 12, overflow: "hidden" }}>
|
||||
<Pressable
|
||||
onPressIn={focusInput}
|
||||
style={{ borderRadius: 12, overflow: "hidden" }}
|
||||
>
|
||||
<BlurView
|
||||
intensity={80}
|
||||
style={{
|
||||
@@ -24,6 +115,7 @@ const SearchBar = ({
|
||||
>
|
||||
<Image source={icons.search} />
|
||||
<TextInput
|
||||
ref={setRefs}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={Palette.gray}
|
||||
style={{
|
||||
@@ -32,10 +124,12 @@ const SearchBar = ({
|
||||
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
|
||||
flex: 1,
|
||||
}}
|
||||
{...textInputProps}
|
||||
onPressIn={handleInputPressIn}
|
||||
onFocus={handleFocus}
|
||||
{...restTextInputProps}
|
||||
/>
|
||||
</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 { useStripe } from "../../providers/StripeProvider";
|
||||
|
||||
const WEB_MODAL_MAX_WIDTH = 1000;
|
||||
const WEB_MODAL_MAX_WIDTH = 820;
|
||||
const formatCurrency = (amount, currency = "eur") => {
|
||||
if (typeof amount !== "number") {
|
||||
return null;
|
||||
@@ -229,14 +229,6 @@ function CoinPackCard({
|
||||
) : null}
|
||||
{pack?.name ? <Text style={styles.packName}>{pack.name}</Text> : null}
|
||||
</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}>
|
||||
{formattedPrice ? (
|
||||
<Text style={styles.packPrice}>{formattedPrice}</Text>
|
||||
@@ -388,6 +380,9 @@ const CoinPackModal = ({ visible, onClose }) => {
|
||||
width: "100%",
|
||||
maxWidth: modalMaxWidth,
|
||||
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}>
|
||||
@@ -452,6 +447,7 @@ const styles = StyleSheet.create({
|
||||
gap: 24,
|
||||
alignItems: "center",
|
||||
paddingBottom: gutters,
|
||||
paddingHorizontal: isWeb ? gutters * 1.5 : 0,
|
||||
width: "100%",
|
||||
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : undefined,
|
||||
},
|
||||
@@ -503,7 +499,7 @@ const styles = StyleSheet.create({
|
||||
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : "100%",
|
||||
alignSelf: "center",
|
||||
gap: gutters,
|
||||
paddingHorizontal: isWeb ? 8 : 0,
|
||||
paddingHorizontal: isWeb ? gutters * 1.5 : 0,
|
||||
},
|
||||
packListWeb: {
|
||||
flexDirection: "row",
|
||||
@@ -567,16 +563,6 @@ const styles = StyleSheet.create({
|
||||
color: "rgba(255, 255, 255, 0.72)",
|
||||
textAlign: "center",
|
||||
},
|
||||
perksList: {
|
||||
gap: 6,
|
||||
alignItems: "center",
|
||||
},
|
||||
perkItem: {
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 13,
|
||||
color: "rgba(255, 255, 255, 0.72)",
|
||||
textAlign: "center",
|
||||
},
|
||||
priceBlock: {
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import React, { useState } from "react";
|
||||
import { Modal, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||
import { useGlobal } from "reactn";
|
||||
import { background } from "../assets";
|
||||
import BorderGradientButton from "../components/BorderGradientButton";
|
||||
import GradientButton from "../components/GradientButton";
|
||||
import { Input } from "../components/Input";
|
||||
import AppCheckbox from "../components/AppCheckbox";
|
||||
import ItemContainer from "../components/ItemContainer/ItemContainer";
|
||||
import {
|
||||
checkIfPasswordIsStrongEnough,
|
||||
@@ -30,10 +33,11 @@ const CreatePassword = () => {
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordError, setPasswordError] = useState("");
|
||||
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 handlePasswordChange = (text) => {
|
||||
setPassword(text);
|
||||
if (!text) {
|
||||
@@ -52,6 +56,30 @@ const CreatePassword = () => {
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
if (!isPasswordValid) {
|
||||
@@ -62,6 +90,14 @@ const CreatePassword = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCguAccepted) {
|
||||
setTooltip({
|
||||
text: "Tu dois accepter les CGU pour continuer",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const cred = await firebase
|
||||
.auth()
|
||||
@@ -110,8 +146,19 @@ const CreatePassword = () => {
|
||||
headerType="NAVIGATION"
|
||||
title="Inscription"
|
||||
>
|
||||
<View style={{ flex: 1, paddingTop: 20 }}>
|
||||
<ItemContainer height={300} disableKeyboardHeight>
|
||||
<View
|
||||
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: 16 }}>
|
||||
<View style={{ gap: 2 }}>
|
||||
@@ -154,18 +201,303 @@ const CreatePassword = () => {
|
||||
</Text>
|
||||
) : null}
|
||||
</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
|
||||
title={loading ? "Création..." : "Suivant"}
|
||||
title={loading ? "Création..." : "Créer mon compte"}
|
||||
containerStyle={{
|
||||
width: "80%",
|
||||
alignSelf: "center",
|
||||
}}
|
||||
onPress={onCreateAccount}
|
||||
disabled={loading || !email || !isPasswordValid}
|
||||
disabled={
|
||||
loading || !email || !isPasswordValid || !isCguAccepted
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</ItemContainer>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import { background, icons } from "../../assets";
|
||||
import BorderGradientButton from "../../components/BorderGradientButton";
|
||||
import SearchBar from "../../components/SearchBar";
|
||||
import ShareBtn from "../../components/ShareBtn/ShareBtn";
|
||||
import { projectsRef } from "../../config/firebase";
|
||||
import firebase, { projectsRef, usersRef } from "../../config/firebase";
|
||||
import useDataFromRef from "../../hooks/useDataFromRef";
|
||||
import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails";
|
||||
import useSearch from "../../hooks/useSearch";
|
||||
@@ -37,6 +37,25 @@ import SearchResultsList from "../Library/components/SearchResultsList";
|
||||
import PlaybacksCard from "./components/PlaybacksCard";
|
||||
import SongCard from "./components/SongCard";
|
||||
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 [selectedCategory, setSelectedCategory] = useState("Chansons");
|
||||
const navigateToMusicDetails = useNavigateToMusicDetails();
|
||||
@@ -92,6 +111,7 @@ const HitParade = () => {
|
||||
title={item?.title || "Sans titre"}
|
||||
artist={item?.userName}
|
||||
coverUrl={item?.coverUrl || null}
|
||||
subscriptionLevel={getCreatorLevel(item)}
|
||||
onPress={() =>
|
||||
navigateToMusicDetails({
|
||||
projectId: item.id,
|
||||
@@ -118,6 +138,7 @@ const HitParade = () => {
|
||||
title={item?.title || "Sans titre"}
|
||||
artist={item?.userName}
|
||||
thumbnailUrl={resolvePlaybackThumbnail(item)}
|
||||
subscriptionLevel={getCreatorLevel(item)}
|
||||
onPress={() => navigate(Routes.Playbacks, { projectId: item.id })}
|
||||
/>
|
||||
)}
|
||||
@@ -133,6 +154,7 @@ const HitParade = () => {
|
||||
title={item?.title || "Sans titre"}
|
||||
artist={item?.userName}
|
||||
coverUrl={item?.coverUrl || null}
|
||||
subscriptionLevel={getCreatorLevel(item)}
|
||||
onPress={() =>
|
||||
navigateToMusicDetails({
|
||||
projectId: item.id,
|
||||
@@ -154,6 +176,7 @@ const HitParade = () => {
|
||||
title={item?.title || "Sans titre"}
|
||||
artist={item?.userName}
|
||||
thumbnailUrl={resolvePlaybackThumbnail(item)}
|
||||
subscriptionLevel={getCreatorLevel(item)}
|
||||
onPress={() => navigate(Routes.Playbacks, { projectId: item.id })}
|
||||
/>
|
||||
))}
|
||||
@@ -188,6 +211,99 @@ const HitParade = () => {
|
||||
[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(
|
||||
() => (Array.isArray(playbacks) ? playbacks : []),
|
||||
[playbacks]
|
||||
@@ -246,7 +362,7 @@ const HitParade = () => {
|
||||
}, [closeDropdown, dropdownVisible, isWeb]);
|
||||
|
||||
const shouldShowResults = isWeb && dropdownVisible;
|
||||
const shouldBlurContent = shouldShowResults && hasSearchQuery;
|
||||
const shouldBlurContent = shouldShowResults;
|
||||
|
||||
const handlePlayRandomSong = useCallback(() => {
|
||||
const arr = Array.isArray(topSongs) ? topSongs : [];
|
||||
@@ -520,7 +636,9 @@ const HitParade = () => {
|
||||
StyleSheet.absoluteFillObject,
|
||||
{
|
||||
zIndex: 10,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.25)",
|
||||
overflow: "hidden",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BlurView } from "expo-blur";
|
||||
import { img } from "../../../assets";
|
||||
import { Palette, Style } from "../../../styles";
|
||||
import { FONT_FAMILY } from "../../../styles/Fonts";
|
||||
import SubscriptionBadge from "../../../components/SubscriptionBadge";
|
||||
|
||||
const PlaybacksCard = ({
|
||||
rank = 1,
|
||||
@@ -12,6 +13,7 @@ const PlaybacksCard = ({
|
||||
thumbnailUrl = null,
|
||||
coverUrl = null,
|
||||
onPress = () => null,
|
||||
subscriptionLevel = null,
|
||||
}) => {
|
||||
const resolveUri = (value) =>
|
||||
typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
@@ -38,43 +40,67 @@ const PlaybacksCard = ({
|
||||
style={{
|
||||
...Style.containerRow,
|
||||
gap: 15,
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
<View
|
||||
style={{
|
||||
fontSize: 22,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
...Style.containerRow,
|
||||
gap: 15,
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{rank}
|
||||
</Text>
|
||||
<Image
|
||||
source={imageUri ? { uri: imageUri } : img.placeholder3}
|
||||
style={{ width: 67, height: 108, borderRadius: 16 }}
|
||||
/>
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontSize: 22,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{artist}
|
||||
{rank}
|
||||
</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>
|
||||
<SubscriptionBadge level={subscriptionLevel} size={36} />
|
||||
</View>
|
||||
</BlurView>
|
||||
</Pressable>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BlurView } from "expo-blur";
|
||||
import Style, { size } from "../../../styles/Style";
|
||||
import { Palette } from "../../../styles";
|
||||
import { FONT_FAMILY } from "../../../styles/Fonts";
|
||||
import SubscriptionBadge from "../../../components/SubscriptionBadge";
|
||||
|
||||
const SongCard = ({
|
||||
rank = 1,
|
||||
@@ -19,6 +20,7 @@ const SongCard = ({
|
||||
artist = "MusicLand",
|
||||
coverUrl = null,
|
||||
onPress = null,
|
||||
subscriptionLevel = null,
|
||||
}) => {
|
||||
return (
|
||||
<Pressable
|
||||
@@ -69,7 +71,7 @@ const SongCard = ({
|
||||
>
|
||||
{rank}
|
||||
</Text>
|
||||
<View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
@@ -79,16 +81,32 @@ const SongCard = ({
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
<View
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
...Style.containerRow,
|
||||
gap: 6,
|
||||
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>
|
||||
<SubscriptionBadge
|
||||
level={subscriptionLevel}
|
||||
size={36}
|
||||
style={{ marginLeft: "auto" }}
|
||||
/>
|
||||
</View>
|
||||
</BlurView>
|
||||
</Pressable>
|
||||
|
||||
@@ -104,7 +104,7 @@ const Library = () => {
|
||||
};
|
||||
|
||||
const shouldShowResults = dropdownVisible;
|
||||
const shouldBlurContent = dropdownVisible && hasSearchQuery;
|
||||
const shouldBlurContent = dropdownVisible;
|
||||
|
||||
const handleChangeText = (value) => {
|
||||
setSearch(value);
|
||||
@@ -214,8 +214,9 @@ const Library = () => {
|
||||
StyleSheet.absoluteFillObject,
|
||||
{
|
||||
zIndex: 10,
|
||||
borderRadius: 0,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.25)",
|
||||
overflow: "hidden",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -2,16 +2,15 @@ import { useIsFocused } from "@react-navigation/native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import moment from "moment";
|
||||
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 { responsiveHeight } from "react-native-responsive-dimensions";
|
||||
import { ai, background } from "../../assets";
|
||||
import { background } from "../../assets";
|
||||
import AppAlert from "../../components/Alert";
|
||||
import GradientButton from "../../components/GradientButton";
|
||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||
import ProgressBar from "../../components/ProgressBar";
|
||||
import firebase, { projectsRef } from "../../config/firebase";
|
||||
import { isWeb } from "../../hooks/useLayoutType";
|
||||
import Page from "../../layouts/Page";
|
||||
import { Routes } from "../../navigation";
|
||||
import { navigate } from "../../navigation/NavigationService";
|
||||
@@ -23,6 +22,7 @@ import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||
const GeneratingSong = () => {
|
||||
const { selectedProjectId, selectedProject } = useUser();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isGenerationInFlight, setIsGenerationInFlight] = useState(false);
|
||||
const { setIsLoading } = useMinuit();
|
||||
const progressTimerRef = useRef(null);
|
||||
const navigatedRef = useRef(false);
|
||||
@@ -59,21 +59,29 @@ const GeneratingSong = () => {
|
||||
? selectedProject.musicUrls.filter(Boolean)
|
||||
: [];
|
||||
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
|
||||
localStartRef.current = null;
|
||||
}
|
||||
|
||||
if (status === "GENERATED") {
|
||||
clearTimer();
|
||||
setIsGenerationInFlight(false);
|
||||
setProgress(hasReadySong ? 100 : maxGeneratingProgress);
|
||||
return () => clearTimer();
|
||||
}
|
||||
|
||||
if (status !== "GENERATING") {
|
||||
if (!isActivelyGenerating) {
|
||||
clearTimer();
|
||||
setProgress(0);
|
||||
if (status !== "FAILED") {
|
||||
setProgress(0);
|
||||
}
|
||||
return () => clearTimer();
|
||||
}
|
||||
|
||||
@@ -81,11 +89,8 @@ const GeneratingSong = () => {
|
||||
const gs = selectedProject?.generationStartAt;
|
||||
if (gs?.toDate) return gs.toDate();
|
||||
if (gs) return new Date(gs);
|
||||
if (status === "GENERATING") {
|
||||
if (!localStartRef.current) localStartRef.current = new Date();
|
||||
return localStartRef.current;
|
||||
}
|
||||
return null;
|
||||
if (!localStartRef.current) localStartRef.current = new Date();
|
||||
return localStartRef.current;
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
@@ -97,7 +102,7 @@ const GeneratingSong = () => {
|
||||
const elapsed = moment().diff(moment(startDate));
|
||||
const raw = Math.floor((elapsed / totalMs) * 100);
|
||||
// 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);
|
||||
};
|
||||
|
||||
@@ -106,6 +111,7 @@ const GeneratingSong = () => {
|
||||
progressTimerRef.current = global.setInterval(update, 1000);
|
||||
return () => clearTimer();
|
||||
}, [
|
||||
isGenerationInFlight,
|
||||
selectedProject?.musicStatus,
|
||||
selectedProject?.generationStartAt,
|
||||
selectedProject?.musicUrls,
|
||||
@@ -121,6 +127,15 @@ const GeneratingSong = () => {
|
||||
}
|
||||
}, [selectedProject?.musicStatus, selectedProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedProject?.musicStatus === "GENERATED" ||
|
||||
selectedProject?.musicStatus === "FAILED"
|
||||
) {
|
||||
setIsGenerationInFlight(false);
|
||||
}
|
||||
}, [selectedProject?.musicStatus]);
|
||||
|
||||
const effectiveConfig = useMemo(() => {
|
||||
// Use selectedProject.musicConfig only
|
||||
const cfg = selectedProject?.musicConfig || {};
|
||||
@@ -137,6 +152,11 @@ const GeneratingSong = () => {
|
||||
async function startMusicGeneration() {
|
||||
try {
|
||||
console.log("startMusicGeneration");
|
||||
setIsGenerationInFlight(true);
|
||||
if (!localStartRef.current) {
|
||||
localStartRef.current = new Date();
|
||||
}
|
||||
setProgress(1);
|
||||
await setIsLoading(true);
|
||||
const callable = firebase
|
||||
.functions()
|
||||
@@ -216,6 +236,7 @@ const GeneratingSong = () => {
|
||||
}
|
||||
}
|
||||
askedRef.current = false;
|
||||
setIsGenerationInFlight(false);
|
||||
} finally {
|
||||
await setIsLoading(false);
|
||||
}
|
||||
@@ -279,7 +300,11 @@ const GeneratingSong = () => {
|
||||
]);
|
||||
|
||||
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 (
|
||||
<Page headerType="NONE" backgroundImg={background.studioBG2}>
|
||||
@@ -338,7 +363,7 @@ const GeneratingSong = () => {
|
||||
<View style={{ alignItems: "center", gap: 16 }}>
|
||||
<ProgressBar
|
||||
gradient
|
||||
progress={progress}
|
||||
progress={displayProgress}
|
||||
status={progressStatus}
|
||||
/>
|
||||
<Text
|
||||
|
||||
+258
-56
@@ -69,7 +69,7 @@ const getIntervalLabel = (recurring) => {
|
||||
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 planName =
|
||||
(planKey && PLAN_DISPLAY_NAME_BY_KEY[planKey]) ||
|
||||
@@ -87,7 +87,6 @@ function SubscriptionCard({ plan, selected, onSelect }) {
|
||||
const planBadgeKey = planKey;
|
||||
const planBadgeSource =
|
||||
planBadgeKey && subBadges[planBadgeKey] ? subBadges[planBadgeKey] : null;
|
||||
const planFeatures = SUBSCRIPTION_FEATURES;
|
||||
const handleSelect = React.useCallback(() => {
|
||||
if (typeof onSelect === "function" && plan?.priceId) {
|
||||
onSelect(plan.priceId);
|
||||
@@ -156,22 +155,23 @@ function SubscriptionCard({ plan, selected, onSelect }) {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{planFeatures?.length ? (
|
||||
<View style={styles.features}>
|
||||
{planFeatures.map((feature) => (
|
||||
<Text key={feature} style={styles.featureText}>
|
||||
{feature}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</BlurView>
|
||||
{isAnnual ? (
|
||||
<View style={styles.annualBadge}>
|
||||
<Text style={styles.annualBadgeText}>2 mois offert</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const PRICE_PRIORITY_BY_PERIOD = {
|
||||
monthly: [
|
||||
"price_1SPgitCzf2o5bDRdbnhLFx6f", // Starter
|
||||
"price_1SPgjCCzf2o5bDRdr08Xzp8u", // Pro
|
||||
"price_1SPgjaCzf2o5bDRdd9Xo2u26", // Premium
|
||||
],
|
||||
annual: [
|
||||
"price_1SPgkDCzf2o5bDRdNGLVNeQ3", // Starter
|
||||
"price_1SPgkXCzf2o5bDRdejBVxEBY", // Pro
|
||||
@@ -180,6 +180,11 @@ const PRICE_PRIORITY_BY_PERIOD = {
|
||||
};
|
||||
|
||||
const PACK_PRICE_ID_BY_PERIOD = {
|
||||
monthly: {
|
||||
starter: "price_1SPgitCzf2o5bDRdbnhLFx6f",
|
||||
pro: "price_1SPgjCCzf2o5bDRdr08Xzp8u",
|
||||
premium: "price_1SPgjaCzf2o5bDRdd9Xo2u26",
|
||||
},
|
||||
annual: {
|
||||
starter: "price_1SPgkDCzf2o5bDRdNGLVNeQ3",
|
||||
pro: "price_1SPgkXCzf2o5bDRdejBVxEBY",
|
||||
@@ -212,12 +217,6 @@ const PLAN_DISPLAY_NAME_BY_KEY = {
|
||||
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) => {
|
||||
if (!plan || typeof plan !== "object") {
|
||||
return null;
|
||||
@@ -293,7 +292,7 @@ const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360;
|
||||
const PAGE_BACKGROUND_COLOR = "#303438";
|
||||
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).";
|
||||
const CARD_MIN_HEIGHT = isWeb ? 320 : 240;
|
||||
const CARD_MIN_HEIGHT = isWeb ? 260 : 180;
|
||||
|
||||
export default function Subscriptions() {
|
||||
const route = useRoute();
|
||||
@@ -313,16 +312,29 @@ export default function Subscriptions() {
|
||||
catalogError,
|
||||
createSubscriptionCheckout,
|
||||
} = useStripe();
|
||||
const [selectedPeriodKey, setSelectedPeriodKey] = React.useState("annual");
|
||||
const [selectedPriceId, setSelectedPriceId] = React.useState(null);
|
||||
const [processingPriceId, setProcessingPriceId] = React.useState(null);
|
||||
const [errorMessage, setErrorMessage] = React.useState(null);
|
||||
const initialPackHandledRef = React.useRef(false);
|
||||
|
||||
const normalizedPlans = React.useMemo(
|
||||
() => normalizePlans(subscriptions?.annual, "annual"),
|
||||
[subscriptions?.annual]
|
||||
const normalizedPlansByPeriod = React.useMemo(
|
||||
() => ({
|
||||
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 combinedErrorMessage = errorMessage || catalogError;
|
||||
|
||||
@@ -331,25 +343,39 @@ export default function Subscriptions() {
|
||||
}, [initialSubscriptionPack]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const periodEntries = Object.entries(normalizedPlansByPeriod).filter(
|
||||
([, plans]) => (plans?.length || 0) > 0
|
||||
);
|
||||
|
||||
if (!periodEntries.length) {
|
||||
setSelectedPriceId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldApplyPack =
|
||||
Boolean(initialSubscriptionPack) && !initialPackHandledRef.current;
|
||||
|
||||
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 candidateId = PACK_PRICE_ID_BY_PERIOD?.annual?.[desired];
|
||||
if (candidateId) {
|
||||
const exists = normalizedPlans.some(
|
||||
(plan) => plan?.priceId === candidateId
|
||||
);
|
||||
if (exists) {
|
||||
matchedPriceId = candidateId;
|
||||
for (const [periodKey, plans] of periodEntries) {
|
||||
const candidateId = PACK_PRICE_ID_BY_PERIOD?.[periodKey]?.[desired];
|
||||
if (candidateId) {
|
||||
const exists = plans.some((plan) => plan?.priceId === candidateId);
|
||||
if (exists) {
|
||||
matchedPriceId = candidateId;
|
||||
matchedPeriodKey = periodKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedPriceId) {
|
||||
const matched = normalizedPlans.find((plan) => {
|
||||
const matched = plans.find((plan) => {
|
||||
const label = (plan?.product?.name || plan?.nickname || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
@@ -358,25 +384,38 @@ export default function Subscriptions() {
|
||||
|
||||
if (matched?.priceId) {
|
||||
matchedPriceId = matched.priceId;
|
||||
matchedPeriodKey = periodKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedPriceId((current) => {
|
||||
if (matchedPriceId) {
|
||||
nextPeriodKey = matchedPeriodKey;
|
||||
}
|
||||
|
||||
const plansForPeriod = normalizedPlansByPeriod?.[nextPeriodKey] || [];
|
||||
|
||||
const nextPriceId = (() => {
|
||||
if (matchedPriceId) {
|
||||
return matchedPriceId;
|
||||
}
|
||||
|
||||
const hasCurrent = normalizedPlans?.some(
|
||||
(plan) => plan.priceId === current
|
||||
const hasCurrent = plansForPeriod.some(
|
||||
(plan) => plan.priceId === selectedPriceId
|
||||
);
|
||||
|
||||
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)) {
|
||||
initialPackHandledRef.current = true;
|
||||
@@ -384,10 +423,12 @@ export default function Subscriptions() {
|
||||
}, [
|
||||
initialSubscriptionPack,
|
||||
isCatalogLoading,
|
||||
normalizedPlans,
|
||||
normalizedPlansByPeriod,
|
||||
selectedPeriodKey,
|
||||
selectedPriceId,
|
||||
]);
|
||||
|
||||
const currentPlans = normalizedPlans || [];
|
||||
const currentPlans = normalizedPlansByPeriod?.[selectedPeriodKey] || [];
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(priceId) => {
|
||||
@@ -438,13 +479,36 @@ export default function Subscriptions() {
|
||||
goBack();
|
||||
}, []);
|
||||
|
||||
const handlePeriodChange = React.useCallback(
|
||||
(periodKey) => {
|
||||
if (!periodKey || periodKey === selectedPeriodKey) {
|
||||
return;
|
||||
}
|
||||
setSelectedPeriodKey(periodKey);
|
||||
setSelectedPriceId(null);
|
||||
setProcessingPriceId(null);
|
||||
},
|
||||
[selectedPeriodKey]
|
||||
);
|
||||
|
||||
const renderHeaderSection = React.useCallback(() => {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>
|
||||
Choisissez l’abonnement qui vous correspond
|
||||
Rejoignez le club MusicLand
|
||||
</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>
|
||||
{combinedErrorMessage ? (
|
||||
<Text style={styles.errorText}>{combinedErrorMessage}</Text>
|
||||
@@ -453,6 +517,66 @@ export default function Subscriptions() {
|
||||
);
|
||||
}, [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(
|
||||
({ item }) => (
|
||||
<View style={styles.mobileCard}>
|
||||
@@ -460,10 +584,11 @@ export default function Subscriptions() {
|
||||
plan={item}
|
||||
selected={selectedPriceId === item.priceId}
|
||||
onSelect={handleSelect}
|
||||
isAnnual={selectedPeriodKey === "annual"}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[handleSelect, selectedPriceId]
|
||||
[handleSelect, selectedPeriodKey, selectedPriceId]
|
||||
);
|
||||
|
||||
const renderMobileEmptyComponent = React.useCallback(() => {
|
||||
@@ -497,9 +622,10 @@ export default function Subscriptions() {
|
||||
return (
|
||||
<View style={styles.mobileStickyHeader}>
|
||||
{renderHeaderSection()}
|
||||
{renderSegmentedControl()}
|
||||
</View>
|
||||
);
|
||||
}, [renderHeaderSection]);
|
||||
}, [renderHeaderSection, renderSegmentedControl]);
|
||||
|
||||
const renderMobileBottomActions = React.useCallback(() => {
|
||||
return (
|
||||
@@ -588,6 +714,8 @@ export default function Subscriptions() {
|
||||
<>
|
||||
{renderHeaderSection()}
|
||||
|
||||
{renderSegmentedControl()}
|
||||
|
||||
{isLoadingPlans && currentPlanCount === 0 ? (
|
||||
<View style={styles.loaderContainer}>
|
||||
<ActivityIndicator color={Palette.white} />
|
||||
@@ -607,6 +735,7 @@ export default function Subscriptions() {
|
||||
plan={plan}
|
||||
selected={selectedPriceId === plan.priceId}
|
||||
onSelect={handleSelect}
|
||||
isAnnual={selectedPeriodKey === "annual"}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -700,6 +829,29 @@ const styles = StyleSheet.create({
|
||||
gap: 12,
|
||||
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: {
|
||||
alignSelf: "flex-start",
|
||||
marginBottom: 12,
|
||||
@@ -760,6 +912,61 @@ const styles = StyleSheet.create({
|
||||
alignItems: "stretch",
|
||||
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: {
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
@@ -772,6 +979,7 @@ const styles = StyleSheet.create({
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
minHeight: CARD_MIN_HEIGHT,
|
||||
position: "relative",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
borderWidth: 1,
|
||||
@@ -854,16 +1062,6 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
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: {
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
fontSize: isWeb ? 22 : 20,
|
||||
@@ -904,6 +1102,10 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: PAGE_BACKGROUND_COLOR,
|
||||
alignItems: "center",
|
||||
},
|
||||
segmentedControlMobile: {
|
||||
alignSelf: "center",
|
||||
marginBottom: 0,
|
||||
},
|
||||
mobileList: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
|
||||
@@ -445,8 +445,17 @@ const PouchReady = () => {
|
||||
Génération en cours.
|
||||
</Text>
|
||||
)}
|
||||
<View style={{ alignItems: "center", gap: 10 }}>
|
||||
<ProgressBar gradient progress={coverProgressValue} />
|
||||
<View
|
||||
style={[
|
||||
styles.coverProgressWrapper,
|
||||
isWeb ? styles.coverProgressWrapperWeb : null,
|
||||
]}
|
||||
>
|
||||
<ProgressBar
|
||||
gradient
|
||||
progress={coverProgressValue}
|
||||
containerStyle={styles.coverProgressBar}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: Palette.white,
|
||||
@@ -824,6 +833,17 @@ const styles = StyleSheet.create({
|
||||
gap: 12,
|
||||
minWidth: 240,
|
||||
},
|
||||
coverProgressWrapper: {
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
width: 220,
|
||||
},
|
||||
coverProgressWrapperWeb: {
|
||||
width: 260,
|
||||
},
|
||||
coverProgressBar: {
|
||||
width: "100%",
|
||||
},
|
||||
customInputWrapper: {
|
||||
alignSelf: "stretch",
|
||||
borderRadius: 18,
|
||||
|
||||
@@ -414,7 +414,9 @@ const SongDownload = ({ route }) => {
|
||||
size={22}
|
||||
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>
|
||||
</View>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user