clear more tickets

This commit is contained in:
Thomas Demirdjian
2025-11-20 17:44:09 +01:00
parent 70c94c97b4
commit 83ced20dcf
24 changed files with 506 additions and 378 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 681 KiB

After

Width:  |  Height:  |  Size: 677 KiB

+40 -37
View File
@@ -67,6 +67,25 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
onClose?.();
}, [onClose]);
const evaluateShouldClose = useCallback(() => {
const video = videoRef.current;
if (!video || hasClosedRef.current) {
return;
}
if (video.ended) {
handleClose();
return;
}
const remaining = video.duration - video.currentTime;
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
handleClose();
}
}, [handleClose]);
useEffect(() => {
let isMounted = true;
@@ -103,44 +122,22 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
}, [url]);
useEffect(() => {
if (!visible) {
if (!visible || !uri) {
return undefined;
}
hasClosedRef.current = false;
let rafId;
const video = videoRef.current;
if (!video || !uri) {
return undefined;
}
const shouldClose = () => {
if (hasClosedRef.current) {
return false;
}
if (video.ended) {
return true;
}
const remaining = video.duration - video.currentTime;
return Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS;
};
const tryHandleClose = () => {
if (shouldClose()) {
handleClose();
}
};
const handleEnded = tryHandleClose;
const handleTimeUpdate = tryHandleClose;
const handlePause = tryHandleClose;
video.addEventListener("ended", handleEnded);
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("pause", handlePause);
const pollId = setInterval(tryHandleClose, CLOSE_POLL_INTERVAL_MS);
video.currentTime = 0;
const attemptPlay = () => {
const video = videoRef.current;
if (!video) {
rafId = requestAnimationFrame(attemptPlay);
return;
}
video.currentTime = 0;
const result = video.play();
if (result?.catch) {
@@ -153,15 +150,17 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
};
attemptPlay();
const pollId = setInterval(evaluateShouldClose, CLOSE_POLL_INTERVAL_MS);
return () => {
video.pause();
video.removeEventListener("ended", handleEnded);
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("pause", handlePause);
if (rafId) {
cancelAnimationFrame(rafId);
}
clearInterval(pollId);
const video = videoRef.current;
video?.pause();
};
}, [handleClose, muted, uri, visible]);
}, [evaluateShouldClose, muted, uri, visible]);
useEffect(() => {
const video = videoRef.current;
@@ -194,6 +193,10 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
loop={false}
muted={muted}
controls={false}
onEnded={handleClose}
onPause={evaluateShouldClose}
onTimeUpdate={evaluateShouldClose}
onError={handleClose}
/>
) : null}
<Pressable onPress={handleClose} style={closeButtonStyle}>
+243 -3
View File
@@ -43,7 +43,119 @@ const formatCurrency = (amount, currency = "eur") => {
return `${normalized.toFixed(2)} ${upperCurrency}`;
};
function CoinPackCard({ pack, selected, onSelect }) {
const FALLBACK_BASE_PRICE_PER_COIN = 12; // ~0,12€ par jeton (valeur indicatif pour l'affichage)
const FALLBACK_DISCOUNT_STEPS = [0, 12, 18, 26, 32];
const computeCoinPackPricing = (packs = []) => {
if (!Array.isArray(packs) || !packs.length) {
return {};
}
const packsWithPrice = packs
.filter((pack) => pack?.productId)
.map((pack) => {
const hasValidPrice =
typeof pack.unitAmount === "number" &&
typeof pack.coinAmount === "number" &&
pack.coinAmount > 0;
return {
...pack,
hasValidPrice,
pricePerCoin: hasValidPrice
? pack.unitAmount / pack.coinAmount
: null,
};
});
const basePack = packsWithPrice.reduce((current, pack) => {
if (!pack.hasValidPrice) {
return current;
}
if (!current) {
return pack;
}
if (
typeof pack.coinAmount === "number" &&
typeof current.coinAmount === "number" &&
pack.coinAmount < current.coinAmount
) {
return pack;
}
return current;
}, null);
const basePricePerCoin =
basePack?.pricePerCoin && isFinite(basePack.pricePerCoin)
? basePack.pricePerCoin
: null;
const bestDiscountPack = packsWithPrice.reduce((best, pack) => {
if (!pack.hasValidPrice || !basePricePerCoin) {
return best;
}
const discount =
((basePricePerCoin - pack.pricePerCoin) / basePricePerCoin) * 100;
if (!best || discount > best.discount) {
return { productId: pack.productId, discount };
}
return best;
}, null);
const fallbackBase = basePricePerCoin || FALLBACK_BASE_PRICE_PER_COIN;
const fallbackBestId =
!bestDiscountPack && packs.length
? packs[packs.length - 1]?.productId
: null;
return packs.reduce((acc, pack, index) => {
if (!pack?.productId) {
return acc;
}
const currentPack = packsWithPrice.find(
(item) => item.productId === pack.productId,
);
const computedPricePerCoin = currentPack?.pricePerCoin;
const fallbackDiscount =
FALLBACK_DISCOUNT_STEPS[
Math.min(index, FALLBACK_DISCOUNT_STEPS.length - 1)
] || 0;
const pricePerCoin =
typeof computedPricePerCoin === "number" && isFinite(computedPricePerCoin)
? computedPricePerCoin
: fallbackBase * (1 - fallbackDiscount / 100);
const baseReference = fallbackBase || 1;
const rawDiscount =
baseReference && pricePerCoin
? ((baseReference - pricePerCoin) / baseReference) * 100
: 0;
const discountPercent = Math.max(
0,
Number.isFinite(rawDiscount) ? Math.round(rawDiscount) : 0,
);
const isBasePack =
(basePack && basePack.productId === pack.productId) ||
(!basePack && index === 0);
acc[pack.productId] = {
currency: pack.currency,
pricePerCoin,
formattedPricePerCoin: formatCurrency(pricePerCoin, pack.currency),
discountPercent,
isBasePack,
isBestValue:
(bestDiscountPack &&
bestDiscountPack.productId === pack.productId) ||
(!bestDiscountPack && fallbackBestId === pack.productId),
};
return acc;
}, {});
};
function CoinPackCard({ pack, selected, pricingDetail, onSelect }) {
const handleSelect = React.useCallback(() => {
if (typeof onSelect !== "function" || !pack?.productId) {
return;
@@ -52,6 +164,13 @@ function CoinPackCard({ pack, selected, onSelect }) {
}, [onSelect, pack?.productId]);
const formattedPrice = formatCurrency(pack?.unitAmount, pack?.currency);
const perCoinPrice = pricingDetail?.formattedPricePerCoin;
const discountPercent = pricingDetail?.discountPercent;
const discountLabel = pricingDetail?.isBasePack
? "Pack de base (référence)"
: typeof discountPercent === "number"
? `-${discountPercent}% vs pack de base`
: null;
return (
<Pressable
@@ -73,12 +192,53 @@ function CoinPackCard({ pack, selected, onSelect }) {
iconSize={26}
/>
{pack?.name ? <Text style={styles.packName}>{pack.name}</Text> : null}
{pricingDetail?.isBestValue ? (
<View style={styles.tagBestValue}>
<Text style={styles.tagBestValueText}>Meilleure offre</Text>
</View>
) : null}
</View>
{pack?.description ? (
<Text style={styles.packDescription}>{pack.description}</Text>
) : null}
{formattedPrice ? (
<Text style={styles.packPrice}>{formattedPrice}</Text>
<View style={styles.priceBlock}>
{formattedPrice ? (
<Text style={styles.packPrice}>{formattedPrice}</Text>
) : null}
<Text style={styles.pricePerCoin} numberOfLines={1}>
{perCoinPrice
? `${perCoinPrice} / jeton`
: "Valeurs indicatives / jeton"}
</Text>
</View>
{discountLabel ? (
<View style={styles.discountRow}>
<View
style={[
styles.discountBadge,
pricingDetail?.isBestValue && styles.discountBadgeBest,
pricingDetail?.isBasePack && styles.discountBadgeBase,
]}
>
<Text
style={[
styles.discountText,
pricingDetail?.isBestValue && styles.discountTextBest,
]}
>
{discountLabel}
</Text>
</View>
{!pricingDetail?.isBasePack && typeof discountPercent === "number" ? (
<Text style={styles.discountHelper}>
Économie estimée vs pack de base
</Text>
) : (
<Text style={styles.discountHelperMuted}>
Référence prix/jeton
</Text>
)}
</View>
) : null}
</BlurView>
</Pressable>
@@ -98,6 +258,10 @@ const CoinPackModal = ({ visible, onClose }) => {
refreshCatalog,
createCoinPackCheckout,
} = useStripe();
const packPricingById = React.useMemo(
() => computeCoinPackPricing(coinPacks),
[coinPacks],
);
React.useEffect(() => {
if (!coinPacks.length) {
@@ -200,6 +364,7 @@ const CoinPackModal = ({ visible, onClose }) => {
key={pack.productId}
pack={pack}
selected={selectedPackId === pack.productId}
pricingDetail={packPricingById[pack.productId]}
onSelect={setSelectedPackId}
/>
))}
@@ -236,6 +401,12 @@ const CoinPackModal = ({ visible, onClose }) => {
<View style={styles.content}>{renderContent()}</View>
<Text style={styles.indicativeNote}>
Tarifs indicatifs : les prix et quantités de jetons sont amenés à
évoluer. Les remises sont affichées vs le pack de base pour mieux
valoriser les offres volumineuses.
</Text>
<View style={styles.actions}>
<GradientButton
title={isProcessing ? "Redirection..." : "Acheter ce pack"}
@@ -393,12 +564,74 @@ const styles = StyleSheet.create({
color: "rgba(255, 255, 255, 0.72)",
textAlign: "center",
},
priceBlock: {
gap: 2,
alignItems: "center",
},
packPrice: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
color: Palette.white,
textAlign: "center",
},
pricePerCoin: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: "rgba(255, 255, 255, 0.7)",
textAlign: "center",
},
discountRow: {
alignItems: "center",
gap: 6,
},
discountBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: "rgba(255, 255, 255, 0.06)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
},
discountBadgeBase: {
borderColor: Palette.primary,
backgroundColor: Palette.transparentPrimary,
},
discountBadgeBest: {
borderColor: Palette.green,
backgroundColor: Palette.transparentGreen,
},
discountText: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: Palette.white,
textAlign: "center",
},
discountTextBest: {
color: Palette.white,
},
discountHelper: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
color: "rgba(255, 255, 255, 0.72)",
textAlign: "center",
},
discountHelperMuted: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
color: "rgba(255, 255, 255, 0.56)",
textAlign: "center",
},
tagBestValue: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 10,
backgroundColor: Palette.transparentGreen,
},
tagBestValueText: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 12,
color: Palette.white,
},
actions: {
width: "85%",
alignSelf: "center",
@@ -411,4 +644,11 @@ const styles = StyleSheet.create({
textAlign: "center",
paddingHorizontal: 16,
},
indicativeNote: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
color: "rgba(255, 255, 255, 0.62)",
textAlign: "center",
paddingHorizontal: 24,
},
});
+1 -1
View File
@@ -42,7 +42,7 @@ export const BottomTabScreen = () => {
name={Routes.HomeStack}
component={HomeStack}
options={{
tabBarLabel: "Créer",
tabBarLabel: "Menu",
headerShown: false,
tabBarShowLabel: true,
tabBarIcon: ({ focused }) => renderIcon(tabs.addTab, focused),
+5 -12
View File
@@ -94,7 +94,6 @@ const STAGE_CARD_CONTENT = [
},
];
const CLUB_CARD_IMAGE = icons.clubIcon;
const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
"trialing",
"active",
@@ -114,6 +113,7 @@ const Home = ({ navigation, route }) => {
videos,
} = useUser();
const { setTooltip } = useMinuit();
const [videoUrl, setVideoUrl] = useState(null);
const hasActiveSubscription = useMemo(() => {
if (!currentUserData) {
@@ -208,7 +208,6 @@ const Home = ({ navigation, route }) => {
const [menuAnchor, setMenuAnchor] = useState(null);
const [menuProject, setMenuProject] = useState(null);
const menuAnchorRef = useRef(null);
const [isIntroVideoVisible, setIsIntroVideoVisible] = useState(false);
const [hasLocalAdventureFlag, setHasLocalAdventureFlag] = useState(false);
const handleSelectProject = useCallback(
@@ -404,18 +403,17 @@ const Home = ({ navigation, route }) => {
}, [hasLocalAdventureFlag, persistAdventureStarted, setTooltip]);
const handleStartVisit = useCallback(() => {
setIsIntroVideoVisible(true);
setVideoUrl(isWeb ? videos?.landingWeb : videos?.landing);
}, []);
const handleIntroVideoClose = useCallback(() => {
setIsIntroVideoVisible(false);
setVideoUrl(null);
markAdventureStarted();
}, [markAdventureStarted]);
const adventureStarted =
hasLocalAdventureFlag || !!currentUserData?.adventureStarted;
const videoUrl = isWeb ? videos?.landingWeb : videos?.landing;
const homeBackgroundImage = background.bgTrans;
const landingBackgroundImage = homeBackgroundImage;
@@ -523,10 +521,7 @@ const Home = ({ navigation, route }) => {
iconPosition="left"
/>
</Pressable>
<ShareBtn
style={styles.mobileShareButton}
iconOnly
/>
<ShareBtn style={styles.mobileShareButton} label="Partager" />
</View>
) : null;
@@ -590,7 +585,6 @@ const Home = ({ navigation, route }) => {
{stageCardsList}
<ClubCard
image={CLUB_CARD_IMAGE}
onPress={handleClubPress}
hasActiveSubscription={hasActiveSubscription}
/>
@@ -654,7 +648,6 @@ const Home = ({ navigation, route }) => {
}}
>
<GradientButton
url={videoUrl}
title="Commencer l'aventure MusicLand"
onPress={handleStartVisit}
/>
@@ -662,7 +655,7 @@ const Home = ({ navigation, route }) => {
</Page>
<FullscreenIntroVideo
url={videoUrl}
visible={isIntroVideoVisible}
visible={!!videoUrl}
onClose={handleIntroVideoClose}
/>
</>
+5 -21
View File
@@ -19,7 +19,6 @@ const StageCard = ({
}) => {
const isMobileVariant = variant === "mobile";
const isImageOnLeft = !isMobileVariant && imagePosition !== "right";
const isTextRight = !isMobileVariant && textAlign === "right";
const basePressableStyle =
variant === "web"
@@ -72,7 +71,7 @@ const StageCard = ({
{
width: "100%",
height: 190,
minHeight: 190,
minHeight: 210,
borderRadius: 0,
},
isMobileVariant
@@ -96,9 +95,7 @@ const StageCard = ({
},
isMobileVariant
? { justifyContent: "center" }
: isTextRight
? { justifyContent: "flex-end" }
: { justifyContent: "flex-start" },
: { justifyContent: "flex-start" },
]}
>
<Text
@@ -109,11 +106,7 @@ const StageCard = ({
color: Palette.white,
flexShrink: 1,
},
isMobileVariant
? { textAlign: "center" }
: isTextRight
? { textAlign: "right" }
: { textAlign: "left" },
isMobileVariant ? { textAlign: "center" } : { textAlign: "left" },
]}
numberOfLines={1}
>
@@ -153,9 +146,6 @@ const StageCard = ({
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
}),
...(isTextRight
? { alignItems: "flex-end" }
: { alignItems: "flex-start" }),
};
const textBlock = (
@@ -177,12 +167,8 @@ const StageCard = ({
alignItems: "center",
justifyContent: "center",
zIndex: 2,
right: 10,
},
isMobileVariant
? { right: 10 }
: isTextRight
? { left: 10 }
: { right: 10 },
]}
>
<FontAwesome name="lock" size={18} color={Palette.primary} />
@@ -193,9 +179,7 @@ const StageCard = ({
style={[
isMobileVariant
? { textAlign: "center" }
: isTextRight
? { textAlign: "right" }
: { textAlign: "left" },
: { textAlign: "left", marginRight: 10 },
{
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13,
+9 -2
View File
@@ -18,6 +18,7 @@ import { Routes } from "../navigation/Routes";
import { useUser } from "../providers/UserDataProvider";
import { Palette } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { isMobile } from "../hooks/useLayoutType";
const LANGUAGE_STORAGE_KEY = "preferredLanguage";
@@ -103,13 +104,19 @@ export default function LandingPage() {
title="Présentation de Musicland"
onPress={() =>
setVideoUrl(
Platform.OS === "web" ? video?.landingWeb : video?.landing
Platform.OS === "web"
? video?.landingWeb
: video?.landing,
)
}
containerStyle={styles.actionButton}
/>
<GradientButton
title="Je me lance dans l'aventure"
title={
Platform.OS === "web"
? "Je me lance dans l'aventure"
: "Commencer"
}
onPress={handleLaunchAdventure}
containerStyle={styles.actionButton}
/>
+52 -2
View File
@@ -14,11 +14,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import GradientButton from "../components/GradientButton";
import CreditAmount from "../components/CreditAmount";
import Page from "../layouts/Page";
import { background, subBadges } from "../assets";
import { background, icons, subBadges } from "../assets";
import { Palette, gutters } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { isWeb } from "../hooks/useLayoutType";
import { useStripe } from "../providers/StripeProvider";
import { goBack } from "../navigation/NavigationService";
const formatCurrency = (amount, currency = "eur") => {
if (typeof amount !== "number") {
@@ -489,6 +490,9 @@ export default function Payments() {
}),
[mobileActionSafePadding]
);
const handleBackPress = React.useCallback(() => {
goBack();
}, []);
const renderHeaderSection = React.useCallback(() => {
return (
@@ -615,6 +619,25 @@ export default function Payments() {
isActionDisabled,
mobileActionSafePadding,
]);
const renderWebBackButton = React.useCallback(() => {
if (isMobile) return null;
return (
<View style={styles.webBackContainer}>
<Pressable
accessibilityRole="button"
onPress={handleBackPress}
style={styles.webBackButton}
>
<ExpoImage
source={icons.chevronDown}
contentFit="contain"
style={styles.webBackIcon}
/>
<Text style={styles.webBackText}>Retour</Text>
</Pressable>
</View>
);
}, [handleBackPress, isMobile]);
return (
<View style={styles.root}>
@@ -629,7 +652,8 @@ export default function Payments() {
isMobile && styles.pageContentMobile,
]}
backgroundColor={PAGE_BACKGROUND_COLOR}
topStickyContent={isMobile ? renderMobileTopSticky : undefined}
hideBackButton={!isMobile}
topStickyContent={isMobile ? renderMobileTopSticky : renderWebBackButton}
>
<View style={[styles.inner, isMobile && styles.mobileInner]}>
<ExpoImage
@@ -771,6 +795,32 @@ const styles = StyleSheet.create({
gap: 12,
alignItems: "center",
},
webBackContainer: {
alignSelf: "flex-start",
marginBottom: 12,
},
webBackButton: {
flexDirection: "row",
alignItems: "center",
gap: 8,
alignSelf: "flex-start",
paddingHorizontal: 14,
paddingVertical: 7,
borderRadius: 999,
borderWidth: 1,
borderColor: Palette.ultraLightWhite,
backgroundColor: Palette.ultraLightWhite,
},
webBackIcon: {
width: 16,
height: 16,
transform: [{ rotate: "90deg" }],
},
webBackText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
title: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: isWeb ? 32 : 24,
+71 -38
View File
@@ -154,6 +154,7 @@ const ManageSubscription = ({ navigation }) => {
currentUserData?.stripeSubscription?.id ||
currentUserData?.stripeSubscription?.subscriptionId ||
null;
const localSubscription = currentUserData?.stripeSubscription || null;
const triggerRefresh = useCallback(() => {
setRefreshToken((value) => value + 1);
@@ -219,8 +220,9 @@ const ManageSubscription = ({ navigation }) => {
}, [stripeCustomerId, localSubscriptionId, refreshToken]);
const subscriptionInfo = useMemo(() => {
const rawSubscription =
remoteSubscription || currentUserData?.stripeSubscription || null;
const subscriptionSources = [remoteSubscription, localSubscription].filter(
Boolean,
);
const pickString = (value) => {
if (typeof value !== "string") {
@@ -230,40 +232,71 @@ const ManageSubscription = ({ navigation }) => {
return trimmed ? trimmed : null;
};
const pickFromSources = (resolver) => {
for (const source of subscriptionSources) {
if (!source) {
continue;
}
const value = resolver(source);
if (value !== undefined && value !== null) {
return value;
}
}
return null;
};
const pickDateFromSources = (...resolvers) => {
for (const source of subscriptionSources) {
if (!source) {
continue;
}
for (const resolveValue of resolvers) {
const date = toDate(resolveValue(source));
if (date) {
return date;
}
}
}
return null;
};
const statusSource =
pickString(currentUserData?.stripeSubscriptionStatus) ||
pickString(remoteSubscription?.status) ||
pickString(currentUserData?.stripeSubscription?.status) ||
pickString(
currentUserData?.stripeSubscription?.stripeSubscriptionStatus,
) ||
pickString(rawSubscription?.status) ||
pickString(pickFromSources((source) => source?.status)) ||
pickString(pickFromSources((source) => source?.stripeSubscriptionStatus)) ||
pickString(pickFromSources((source) => source?.metadata?.status)) ||
null;
const status = statusSource ? statusSource.toLowerCase() : null;
const cancelAtPeriodEnd =
remoteSubscription?.cancelAtPeriodEnd === true ||
rawSubscription?.cancelAtPeriodEnd === true ||
rawSubscription?.cancel_at_period_end === true ||
false;
const cancelAtPeriodEnd = subscriptionSources.some(
(source) =>
source?.cancelAtPeriodEnd === true ||
source?.cancel_at_period_end === true,
);
const resolveLevelSource = () => {
const candidates = [
pickString(remoteSubscription?.level),
pickString(pickFromSources((source) => source?.level)),
pickString(currentUserData?.premiumLevel),
pickString(rawSubscription?.metadata?.level),
pickString(rawSubscription?.metadata?.subscriptionLevel),
pickString(pickFromSources((source) => source?.metadata?.level)),
pickString(
pickFromSources((source) => source?.metadata?.subscriptionLevel),
),
];
return candidates.find(Boolean) || null;
};
const resolvePeriodSource = () => {
const candidates = [
pickString(remoteSubscription?.billingPeriod),
pickString(pickFromSources((source) => source?.billingPeriod)),
pickString(currentUserData?.premiumBillingPeriod),
pickString(rawSubscription?.metadata?.billingPeriod),
pickString(rawSubscription?.metadata?.subscriptionBillingPeriod),
pickString(pickFromSources((source) => source?.metadata?.billingPeriod)),
pickString(
pickFromSources(
(source) => source?.metadata?.subscriptionBillingPeriod,
),
),
];
return candidates.find(Boolean) || null;
};
@@ -292,14 +325,15 @@ const ManageSubscription = ({ navigation }) => {
? planLabelParts.join(" · ")
: "Abonnement Musicland";
const currentPeriodEndDate =
toDate(remoteSubscription?.currentPeriodEnd) ||
toDate(rawSubscription?.currentPeriodEnd) ||
toDate(rawSubscription?.current_period_end);
const createdAtDate =
toDate(remoteSubscription?.created) ||
toDate(rawSubscription?.createdAt) ||
toDate(rawSubscription?.created_at);
const currentPeriodEndDate = pickDateFromSources(
(source) => source?.currentPeriodEnd,
(source) => source?.current_period_end,
);
const createdAtDate = pickDateFromSources(
(source) => source?.created,
(source) => source?.createdAt,
(source) => source?.created_at,
);
const statusLabelBase =
STATUS_LABELS[status] ||
@@ -307,7 +341,13 @@ const ManageSubscription = ({ navigation }) => {
const statusLabel = statusLabelBase ? capitalize(statusLabelBase) : null;
const statusColors = getStatusColors(status);
const hasAnySubscription = Boolean(rawSubscription?.id);
const subscriptionId =
pickString(pickFromSources((source) => source?.id)) ||
pickString(pickFromSources((source) => source?.subscriptionId)) ||
pickString(currentUserData?.stripeSubscription?.subscriptionId) ||
null;
const hasAnySubscription = Boolean(subscriptionId);
const hasActiveSubscription =
hasAnySubscription && ACTIVE_SUBSCRIPTION_STATUSES.has(status);
const canCancel = hasActiveSubscription && !cancelAtPeriodEnd;
@@ -317,10 +357,7 @@ const ManageSubscription = ({ navigation }) => {
: "—";
const createdAtLabel = createdAtDate ? formatDate(createdAtDate) : null;
let coinsPerMonth =
typeof remoteSubscription?.coinsPerMonth === "number"
? remoteSubscription.coinsPerMonth
: null;
let coinsPerMonth = pickFromSources((source) => source?.coinsPerMonth);
if (
coinsPerMonth === null &&
@@ -395,17 +432,13 @@ const ManageSubscription = ({ navigation }) => {
createdAtLabel,
helperMessage: helperMessage || null,
level: level || null,
subscriptionId:
remoteSubscription?.id ||
rawSubscription?.id ||
currentUserData?.stripeSubscription?.subscriptionId ||
null,
subscriptionId,
coinsPerMonth: normalizedCoins,
isAnnual,
nextGrantDate: resolvedNextGrantDate,
nextGrantLabel,
};
}, [currentUserData, remoteSubscription]);
}, [currentUserData, localSubscription, remoteSubscription]);
const handleOpenPlans = useCallback(() => {
const params =
+20 -232
View File
@@ -1,26 +1,21 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as AppleAuthentication from "expo-apple-authentication";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { BlurView } from "expo-blur";
import Svg, { Path } from "react-native-svg";
import { background } from "../assets";
import BorderGradientButton from "../components/BorderGradientButton";
import { Input } from "../components/Input";
import ItemContainer from "../components/ItemContainer/ItemContainer";
import COUNTRIES from "../constants/countries";
import firebase from "../config/firebase";
import { isWeb } from "../hooks/useLayoutType";
import useSocialAuth from "../hooks/useSocialAuth";
@@ -35,11 +30,6 @@ const LANGUAGE_STORAGE_KEY = "preferredLanguage";
const Register = () => {
const [email, setEmail] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [city, setCity] = useState("");
const [selectedCountry, setSelectedCountry] = useState(null);
const [countrySearch, setCountrySearch] = useState("");
const [isCountryModalVisible, setIsCountryModalVisible] = useState(false);
const [preferredLanguage, setPreferredLanguage] = useState(null);
const afterSocialAuth = useCallback(async () => {
@@ -71,33 +61,7 @@ const Register = () => {
});
}, []);
const filteredCountries = useMemo(() => {
const query = countrySearch.trim().toLowerCase();
if (!query) {
return COUNTRIES;
}
return COUNTRIES.filter((country) =>
country.name.toLowerCase().includes(query),
);
}, [countrySearch]);
const isFormValid =
email.trim().length > 0 &&
firstName.trim().length > 0 &&
lastName.trim().length > 0 &&
city.trim().length > 0 &&
selectedCountry;
const handleSelectCountry = (country) => {
setSelectedCountry(country);
setIsCountryModalVisible(false);
setCountrySearch("");
};
const handleCloseModal = () => {
setIsCountryModalVisible(false);
setCountrySearch("");
};
const isFormValid = email.trim().length > 0 && firstName.trim().length > 0;
const renderFormContent = () => (
<View style={styles.formContent}>
@@ -111,58 +75,13 @@ const Register = () => {
</Text>
) : null}
<View style={{ gap: 16 }}>
<View style={{ gap: 12 }}>
<Input
placeholder="Prénom"
label="Prénom"
isBlur
value={firstName}
setValue={setFirstName}
/>
<Input
placeholder="Nom"
label="Nom"
isBlur
value={lastName}
setValue={setLastName}
/>
<View style={styles.countryField}>
<Text style={styles.inputLabel}>Pays</Text>
<BlurView
tint="dark"
style={[
styles.countryBlurWrapper,
selectedCountry ? styles.countryButtonFilled : null,
]}
>
<Pressable
style={({ pressed }) => [
styles.countryButton,
pressed ? styles.countryButtonPressed : null,
]}
onPress={() => setIsCountryModalVisible(true)}
>
<Text
style={[
styles.countryButtonText,
!selectedCountry && styles.countryButtonPlaceholder,
]}
>
{selectedCountry
? selectedCountry.name
: "Sélectionne ton pays"}
</Text>
</Pressable>
</BlurView>
</View>
<Input
placeholder="Ville"
label="Ville"
isBlur
value={city}
setValue={setCity}
/>
</View>
<Input
placeholder="Prénom"
label="Prénom"
isBlur
value={firstName}
setValue={setFirstName}
/>
<Input
placeholder="Adresse mail"
label="Adresse mail"
@@ -182,9 +101,6 @@ const Register = () => {
navigate(Routes.CreatePassword, {
email: email.trim(),
firstName: firstName.trim(),
lastName: lastName.trim(),
city: city.trim(),
country: selectedCountry,
preferredLanguage,
})
}
@@ -239,63 +155,22 @@ const Register = () => {
keyboardShouldPersistTaps="handled"
>
{renderFormContent()}
<View style={styles.bottomLoginPrompt}>
<Text style={styles.bottomFooterText}>
Tu as déjà un compte ?{" "}
<Text
style={styles.bottomFooterLink}
onPress={() => navigate(Routes.Login)}
>
Se connecter
</Text>
</Text>
</View>
</KeyboardAwareScrollView>
</KeyboardAvoidingView>
)}
</ItemContainer>
<View style={styles.bottomLoginPrompt}>
<Text style={styles.bottomFooterText}>
Tu as déjà un compte ?{" "}
<Text
style={styles.bottomFooterLink}
onPress={() => navigate(Routes.Login)}
>
Se connecter
</Text>
</Text>
</View>
</View>
<Modal
transparent
visible={isCountryModalVisible}
animationType="fade"
onRequestClose={handleCloseModal}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContainer}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Sélectionne ton pays</Text>
<Pressable onPress={handleCloseModal}>
<Text style={styles.closeText}>Fermer</Text>
</Pressable>
</View>
<TextInput
value={countrySearch}
onChangeText={setCountrySearch}
placeholder="Rechercher un pays"
placeholderTextColor={Palette.gray}
style={styles.searchInput}
/>
<FlatList
data={filteredCountries}
keyExtractor={(item) => item.code}
renderItem={({ item }) => (
<Pressable
style={styles.countryItem}
onPress={() => handleSelectCountry(item)}
>
<Text style={styles.countryItemText}>{item.name}</Text>
</Pressable>
)}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<Text style={styles.emptyListText}>Aucun pays trouvé</Text>
}
/>
</View>
</View>
</Modal>
</Page>
);
};
@@ -413,36 +288,6 @@ const styles = StyleSheet.create({
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
countryBlurWrapper: {
borderRadius: 12,
backgroundColor: Palette.glass,
overflow: "hidden",
},
countryButton: {
minHeight: 50,
justifyContent: "center",
paddingHorizontal: 16,
width: "100%",
},
countryButtonPressed: {
opacity: 0.85,
},
countryButtonText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
countryButtonPlaceholder: {
color: Palette.gray,
},
countryField: {
gap: 4,
},
inputLabel: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
},
footer: {
alignItems: "center",
gap: 4,
@@ -542,61 +387,4 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "center",
},
modalOverlay: {
flex: 1,
backgroundColor: Palette.transparentBlack,
justifyContent: "center",
alignItems: "center",
padding: 16,
},
modalContainer: {
width: "100%",
maxWidth: 420,
maxHeight: "80%",
backgroundColor: Palette.ultraLightBlack,
borderRadius: 20,
padding: 16,
gap: 12,
},
modalHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
modalTitle: {
fontSize: 18,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
closeText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
searchInput: {
borderRadius: 12,
borderWidth: 1,
borderColor: Palette.ultraLightWhite,
paddingHorizontal: 12,
paddingVertical: 10,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
countryItem: {
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: Palette.ultraLightWhite,
},
countryItemText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
emptyListText: {
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
paddingVertical: 20,
},
});
+8 -2
View File
@@ -9,9 +9,11 @@ import ListSelection from "../../components/ListSelection/ListSelection";
const SECTION_INSTRUCTIONS = {
BASE: "Choisis ta base",
SENSIBILITE: "Choisis ta sensibilité",
TECHNIQUE: "Choisis ta technique (Facultatif)",
TECHNIQUE: "Choisis ta technique",
};
const OPTIONAL_SECTION_CATEGORIES = new Set(["SENSIBILITE", "TECHNIQUE"]);
const normalizeCategory = (value) => {
if (typeof value !== "string") return "";
return value
@@ -76,6 +78,10 @@ const CustomizeVoice = ({
const subtitle =
SECTION_INSTRUCTIONS[normalizedCategory] ||
"Choisis la voix pour ta chanson";
const isOptionalSection = OPTIONAL_SECTION_CATEGORIES.has(normalizedCategory);
const title = `Personnalise la voix que tu veux pour ta chanson${
isOptionalSection ? " (Facultatif)" : ""
}`;
const voiceObject = useMemo(
() => selectionToObject(selected),
@@ -105,7 +111,7 @@ const CustomizeVoice = ({
return (
<View style={{ flex: 1, gap: 10, paddingTop: 16 }}>
<CreateLyricsHeader
title="Personnalise la voix que tu veux pour ta chanson"
title={title}
subTitle={subtitle}
/>
<View
-1
View File
@@ -396,7 +396,6 @@ const Lyrics = ({ navigation }) => {
<MusicLandHeader
onPressBack={() => navigate(Routes.Home)}
progress={95}
logo={icons.musicLandWriting}
/>
<View
style={{
+17 -11
View File
@@ -11,6 +11,7 @@ const SongStructure = ({
selected: selectedProp,
setSelected: setSelectedProp,
}) => {
const [containerLayout, setContainerLayout] = useState(null);
const [internalSelected, setInternalSelected] = useState(null);
const selected = selectedProp ?? internalSelected;
const setSelected = setSelectedProp ?? setInternalSelected;
@@ -23,17 +24,22 @@ const SongStructure = ({
title="Comment veux-tu structurer ta chanson ?"
subTitle="Sélectionne une structure."
/>
<ItemContainer>
<ListSelection
options={SONG_STRUCTURE}
variant="simple"
selected={selected}
setSelected={setSelected}
contentContainerStyle={styles.contentContainer}
itemContainerStyle={styles.itemContainer}
itemTextStyle={styles.itemText}
/>
</ItemContainer>
<View
style={{ flex: 1 }}
onLayout={(event) => setContainerLayout(event.nativeEvent.layout)}
>
<ItemContainer height={containerLayout?.height}>
<ListSelection
options={SONG_STRUCTURE}
variant="simple"
selected={selected}
setSelected={setSelected}
contentContainerStyle={styles.contentContainer}
itemContainerStyle={styles.itemContainer}
itemTextStyle={styles.itemText}
/>
</ItemContainer>
</View>
</View>
);
};
+17 -11
View File
@@ -25,6 +25,7 @@ const SongStyle = ({
const [previousOtherStyle, setPreviousOtherStyle] = useState(
normalizedOtherStyle
);
const [containerLayout, setContainerLayout] = useState(null);
const selected = selectedProp ?? internalSelected;
const setSelectedBase = setSelectedProp ?? setInternalSelected;
@@ -97,21 +98,26 @@ const SongStyle = ({
return (
<>
<View style={{ flex: 1, gap: 16, marginTop: 16 }}>
<View style={{ gap: 10 }}>
<View style={{ flex: 1, gap: 10 }}>
<CreateLyricsHeader
title={`Quel est le style de ta chanson ?`}
subTitle="Choisis l'ambiance émotions que tu veux faire passer."
/>
<ItemContainer>
<ListSelection
options={songStyleOptions}
variant="titleDescription"
selected={selected}
setSelected={handleStyleSelect}
contentContainerStyle={styles.contentContainer}
itemContainerStyle={styles.itemContainer}
/>
</ItemContainer>
<View
style={{ flex: 1 }}
onLayout={(event) => setContainerLayout(event.nativeEvent.layout)}
>
<ItemContainer height={containerLayout?.height}>
<ListSelection
options={songStyleOptions}
variant="titleDescription"
selected={selected}
setSelected={handleStyleSelect}
contentContainerStyle={styles.contentContainer}
itemContainerStyle={styles.itemContainer}
/>
</ItemContainer>
</View>
</View>
{selected === OTHER_STYLE_OPTION ? (
<View style={styles.otherStyleSummary}>
+2 -1
View File
@@ -25,7 +25,8 @@ const WritingLyrics = () => {
const [videoUrl, setVideoUrl] = useState(null);
useEffect(() => {
setVideoUrl(isWeb ? videos.celineWeb : videos?.celine);
// setVideoUrl(isWeb ? videos.celineWeb : videos?.celine);
setVideoUrl(videos?.test);
}, []);
const startWriting = React.useCallback(async () => {