Files
musicland/src/screens/Profile/ManageSubscription.js
T
2025-11-17 14:27:15 +01:00

781 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useFocusEffect } from "@react-navigation/native";
import React, { useCallback, useMemo, useState } from "react";
import { Alert, Platform, StyleSheet, Text, View } from "react-native";
import BorderGradientButton from "../../components/BorderGradientButton";
import CreditAmount from "../../components/CreditAmount";
import { background } from "../../assets";
import { getFunctionsClient } from "../../config/firebase";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation/Routes";
import { useUserData } from "../../providers/UserDataProvider";
import { gutters, Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { formatDate, toDate } from "../../utils/dateFormatting";
import { Image as ExpoImage } from "expo-image";
import { isWeb } from "../../hooks/useLayoutType";
const FUNCTIONS_REGION = "europe-west1";
const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
"trialing",
"active",
"past_due",
"unpaid",
]);
const STATUS_LABELS = {
trialing: "Période d'essai",
active: "Actif",
past_due: "Paiement en attente",
unpaid: "Impaye",
canceled: "Annulé",
incomplete: "Incomplet",
incomplete_expired: "Expiré",
paused: "En pause",
};
const PLAN_LABELS = {
starter: "Starter",
pro: "Pro",
premium: "Premium",
};
const PERIOD_LABELS = {
monthly: "Mensuel",
annual: "Annuel",
};
const PAGE_BACKGROUND_COLOR = "#303438";
const getStatusColors = (status) => {
switch (status) {
case "trialing":
case "active":
return {
text: Palette.green,
background: Palette.transparentGreen,
};
case "past_due":
case "unpaid":
return {
text: Palette.orange,
background: Palette.transparentOrange,
};
case "canceled":
case "incomplete_expired":
return {
text: Palette.red,
background: Palette.transparentRed,
};
default:
return {
text: Palette.grayMid,
background: Palette.ultraLightWhite,
};
}
};
const capitalize = (value) => {
if (typeof value !== "string" || !value) {
return null;
}
return value.charAt(0).toUpperCase() + value.slice(1);
};
const SCHEDULE_COMPARISON_HOUR = 2;
const SCHEDULE_COMPARISON_MINUTE = 30;
const SCHEDULE_DISPLAY_HOUR = 3;
const SCHEDULE_DISPLAY_MINUTE = 30;
const addMonthsSafe = (date, months = 1) => {
if (!(date instanceof Date) || !Number.isFinite(months)) {
return null;
}
const result = new Date(date.getTime());
const initialDay = result.getDate();
result.setMonth(result.getMonth() + months);
if (result.getDate() !== initialDay) {
result.setDate(0);
}
return result;
};
const addDaysSafe = (date, days = 1) => {
if (!(date instanceof Date) || !Number.isFinite(days)) {
return null;
}
const result = new Date(date.getTime());
result.setDate(result.getDate() + days);
return result;
};
const alignToScheduleTime = (date) => {
if (!(date instanceof Date)) {
return null;
}
const aligned = new Date(date.getTime());
aligned.setHours(SCHEDULE_DISPLAY_HOUR, SCHEDULE_DISPLAY_MINUTE, 0, 0);
return aligned;
};
const computeFirstAnnualGrantFromCreation = (creationDate) => {
if (!(creationDate instanceof Date)) {
return null;
}
const cutoff = new Date(creationDate.getTime());
cutoff.setHours(SCHEDULE_COMPARISON_HOUR, SCHEDULE_COMPARISON_MINUTE, 0, 0);
let base = null;
if (creationDate <= cutoff) {
base = addMonthsSafe(creationDate, 1);
} else {
base = addDaysSafe(creationDate, 1);
}
if (!base) {
return null;
}
return alignToScheduleTime(base);
};
const ManageSubscription = ({ navigation }) => {
const { currentUserData } = useUserData() || {};
const [isCancelling, setIsCancelling] = useState(false);
const [errorMessage, setErrorMessage] = useState(null);
const [successMessage, setSuccessMessage] = useState(null);
const [remoteSubscription, setRemoteSubscription] = useState(null);
const [remoteError, setRemoteError] = useState(null);
const [isFetchingRemote, setIsFetchingRemote] = useState(false);
const [refreshToken, setRefreshToken] = useState(0);
const stripeCustomerId = currentUserData?.stripeCustomerId || null;
const localSubscriptionId =
currentUserData?.stripeSubscription?.id ||
currentUserData?.stripeSubscription?.subscriptionId ||
null;
const triggerRefresh = useCallback(() => {
setRefreshToken((value) => value + 1);
setIsFetchingRemote(true);
}, []);
useFocusEffect(
useCallback(() => {
triggerRefresh();
}, [triggerRefresh]),
);
React.useEffect(() => {
let isMounted = true;
const run = async () => {
const hasLookupContext =
Boolean(stripeCustomerId) || Boolean(localSubscriptionId);
if (!hasLookupContext) {
if (isMounted) {
setRemoteSubscription(null);
setRemoteError(null);
setIsFetchingRemote(false);
}
return;
}
setIsFetchingRemote(true);
setRemoteError(null);
try {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
"subscription-getActiveSubscription",
);
const { data } = await callable();
if (!isMounted) {
return;
}
setRemoteSubscription(data?.subscription || null);
} catch (error) {
console.warn(
"[ManageSubscription] fetch subscription error",
error?.message || error,
);
if (isMounted) {
const message =
error?.message ||
"Impossible de mettre à jour les informations d'abonnement.";
setRemoteError(message);
}
} finally {
if (isMounted) {
setIsFetchingRemote(false);
}
}
};
run();
return () => {
isMounted = false;
};
}, [stripeCustomerId, localSubscriptionId, refreshToken]);
const subscriptionInfo = useMemo(() => {
const rawSubscription =
remoteSubscription || currentUserData?.stripeSubscription || null;
const pickString = (value) => {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
};
const statusSource =
pickString(currentUserData?.stripeSubscriptionStatus) ||
pickString(remoteSubscription?.status) ||
pickString(currentUserData?.stripeSubscription?.status) ||
pickString(
currentUserData?.stripeSubscription?.stripeSubscriptionStatus,
) ||
pickString(rawSubscription?.status) ||
null;
const status = statusSource ? statusSource.toLowerCase() : null;
const cancelAtPeriodEnd =
remoteSubscription?.cancelAtPeriodEnd === true ||
rawSubscription?.cancelAtPeriodEnd === true ||
rawSubscription?.cancel_at_period_end === true ||
false;
const resolveLevelSource = () => {
const candidates = [
pickString(remoteSubscription?.level),
pickString(currentUserData?.premiumLevel),
pickString(rawSubscription?.metadata?.level),
pickString(rawSubscription?.metadata?.subscriptionLevel),
];
return candidates.find(Boolean) || null;
};
const resolvePeriodSource = () => {
const candidates = [
pickString(remoteSubscription?.billingPeriod),
pickString(currentUserData?.premiumBillingPeriod),
pickString(rawSubscription?.metadata?.billingPeriod),
pickString(rawSubscription?.metadata?.subscriptionBillingPeriod),
];
return candidates.find(Boolean) || null;
};
const levelSource = resolveLevelSource();
const periodSource = resolvePeriodSource();
const level = levelSource ? levelSource.toLowerCase() : null;
const billingPeriod = periodSource ? periodSource.toLowerCase() : null;
const isAnnual = billingPeriod === "annual";
const planLabelParts = [];
if (PLAN_LABELS[level]) {
planLabelParts.push(PLAN_LABELS[level]);
} else if (level) {
planLabelParts.push(capitalize(level));
}
if (PERIOD_LABELS[billingPeriod]) {
planLabelParts.push(PERIOD_LABELS[billingPeriod]);
} else if (billingPeriod) {
planLabelParts.push(capitalize(billingPeriod));
}
const planLabel =
planLabelParts.length > 0
? 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 statusLabelBase =
STATUS_LABELS[status] ||
(status ? status.replace(/_/g, " ").toLowerCase() : null);
const statusLabel = statusLabelBase ? capitalize(statusLabelBase) : null;
const statusColors = getStatusColors(status);
const hasAnySubscription = Boolean(rawSubscription?.id);
const hasActiveSubscription =
hasAnySubscription && ACTIVE_SUBSCRIPTION_STATUSES.has(status);
const canCancel = hasActiveSubscription && !cancelAtPeriodEnd;
const periodEndLabel = currentPeriodEndDate
? formatDate(currentPeriodEndDate)
: "—";
const createdAtLabel = createdAtDate ? formatDate(createdAtDate) : null;
let coinsPerMonth =
typeof remoteSubscription?.coinsPerMonth === "number"
? remoteSubscription.coinsPerMonth
: null;
if (
coinsPerMonth === null &&
typeof currentUserData?.subscriptionCoinsPerMonth === "number"
) {
const userCoins = currentUserData.subscriptionCoinsPerMonth;
coinsPerMonth = Number.isFinite(userCoins) ? userCoins : null;
}
const normalizedCoins =
typeof coinsPerMonth === "number" && Number.isFinite(coinsPerMonth)
? Math.round(coinsPerMonth)
: null;
const nextGrantTimestamp =
currentUserData?.subscriptionNextGrantAt ||
currentUserData?.subscriptionGrantNextAt ||
null;
const nextGrantRawDate = toDate(nextGrantTimestamp);
const now = new Date();
const alignedStoredNextGrant = alignToScheduleTime(nextGrantRawDate);
const futureStoredNextGrant =
alignedStoredNextGrant &&
alignedStoredNextGrant.getTime() >= now.getTime()
? alignedStoredNextGrant
: null;
const fallbackInitialGrant =
isAnnual && createdAtDate
? computeFirstAnnualGrantFromCreation(createdAtDate)
: null;
const futureFallbackGrant =
fallbackInitialGrant && fallbackInitialGrant.getTime() >= now.getTime()
? fallbackInitialGrant
: null;
let resolvedNextGrantDate =
futureStoredNextGrant || futureFallbackGrant || null;
if (futureStoredNextGrant && futureFallbackGrant) {
resolvedNextGrantDate =
futureFallbackGrant.getTime() < futureStoredNextGrant.getTime()
? futureFallbackGrant
: futureStoredNextGrant;
}
const nextGrantLabel =
resolvedNextGrantDate && isAnnual
? formatDate(resolvedNextGrantDate)
: null;
const helperMessages = [];
if (!hasActiveSubscription && hasAnySubscription) {
helperMessages.push(
"Ton abonnement n'est plus actif. Tu peux souscrire à nouveau à tout moment.",
);
}
const helperMessage = helperMessages.join("\n");
return {
hasAnySubscription,
hasActiveSubscription,
canCancel,
cancelAtPeriodEnd,
status,
statusLabel,
statusColors,
planLabel,
billingPeriodLabel:
PERIOD_LABELS[billingPeriod] || capitalize(billingPeriod),
periodEndLabel,
createdAtLabel,
helperMessage: helperMessage || null,
level: level || null,
subscriptionId:
remoteSubscription?.id ||
rawSubscription?.id ||
currentUserData?.stripeSubscription?.subscriptionId ||
null,
coinsPerMonth: normalizedCoins,
isAnnual,
nextGrantDate: resolvedNextGrantDate,
nextGrantLabel,
};
}, [currentUserData, remoteSubscription]);
const handleOpenPlans = useCallback(() => {
const params =
subscriptionInfo.level && typeof subscriptionInfo.level === "string"
? { pack: subscriptionInfo.level }
: undefined;
navigation.navigate(Routes.Payments, params);
}, [navigation, subscriptionInfo.level]);
const performCancellation = useCallback(async () => {
setIsCancelling(true);
setErrorMessage(null);
setSuccessMessage(null);
try {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
"subscription-cancelActiveSubscription",
);
const payload = subscriptionInfo.subscriptionId
? { subscriptionId: subscriptionInfo.subscriptionId }
: {};
const { data } = await callable(payload);
if (data?.alreadyCanceled) {
setSuccessMessage(
"Ton abonnement est déjà en cours d'annulation. L'accès premium restera actif jusqu'à la fin de la période en cours.",
);
} else if (data?.cancelAtPeriodEnd) {
setSuccessMessage(
"Ton abonnement sera résilié à la fin de la période en cours.",
);
} else {
setSuccessMessage(
"La demande d'annulation a été prise en compte. Vérifie ton abonnement dans quelques instants.",
);
}
triggerRefresh();
} catch (error) {
console.warn(
"[ManageSubscription] cancel subscription error",
error?.message || error,
);
const message =
error?.message || "Impossible d'annuler l'abonnement pour le moment.";
setErrorMessage(message);
} finally {
setIsCancelling(false);
}
}, [subscriptionInfo.subscriptionId, triggerRefresh]);
const handleCancel = useCallback(() => {
if (!subscriptionInfo.canCancel || isCancelling) {
return;
}
const confirm = () => {
performCancellation();
};
if (Platform.OS === "web" && typeof window !== "undefined") {
const confirmed = window.confirm(
"Confirmer l'annulation ? Ton accès premium restera actif jusqu'à la fin de la période en cours.",
);
if (confirmed) {
confirm();
}
return;
}
Alert.alert(
"Confirmer l'annulation",
"Ton accès premium restera actif jusqu'à la fin de la période en cours.",
[
{ text: "Conserver mon abonnement", style: "cancel" },
{
text: "Annuler l'abonnement",
style: "destructive",
onPress: confirm,
},
],
);
}, [isCancelling, performCancellation, subscriptionInfo.canCancel]);
const cancelButtonTitle = subscriptionInfo.cancelAtPeriodEnd
? "Annulation programmée"
: isCancelling
? "Annulation..."
: "Annuler l'abonnement";
const cancelTitleColor = subscriptionInfo.cancelAtPeriodEnd
? Palette.grayMid
: Palette.red;
const HOME_BACKGROUND_WIDTH = isWeb ? 1280 : 520;
const HOME_BACKGROUND_HEIGHT = isWeb ? 760 : 360;
const HOME_BACKGROUND_STYLE_WIDTH = HOME_BACKGROUND_WIDTH + 120;
const HOME_BACKGROUND_STYLE_HEIGHT = HOME_BACKGROUND_HEIGHT + 80;
return (
<Page
headerType="NAVIGATE"
title="Mon abonnement"
scrollEnabled
backgroundColor={PAGE_BACKGROUND_COLOR}
// backgroundImg={backgroundImage}
>
<ExpoImage
source={background.bgTrans}
contentFit="cover"
style={{
width: HOME_BACKGROUND_STYLE_WIDTH,
height: HOME_BACKGROUND_STYLE_HEIGHT,
borderRadius: 22,
overflow: "hidden",
position: "absolute",
top: isWeb ? "45%" : "50%",
left: "50%",
transform: [
{ translateX: -HOME_BACKGROUND_STYLE_WIDTH / 2 },
{ translateY: -HOME_BACKGROUND_STYLE_HEIGHT / 2 },
],
pointerEvents: "none",
zIndex: 0,
}}
/>
<View style={styles.container}>
{subscriptionInfo.hasAnySubscription ? (
<>
<View style={styles.card}>
<Text style={styles.cardTitle}>{subscriptionInfo.planLabel}</Text>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Statut</Text>
{subscriptionInfo.statusLabel ? (
<View
style={[
styles.statusBadge,
{
backgroundColor:
subscriptionInfo.statusColors.background,
},
]}
>
<Text
style={[
styles.statusText,
{ color: subscriptionInfo.statusColors.text },
]}
>
{subscriptionInfo.statusLabel}
</Text>
</View>
) : (
<Text style={styles.detailValue}></Text>
)}
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Cycle de facturation</Text>
<Text style={styles.detailValue}>
{subscriptionInfo.billingPeriodLabel || "—"}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Crédits mensuels</Text>
{typeof subscriptionInfo.coinsPerMonth === "number" ? (
<CreditAmount
value={subscriptionInfo.coinsPerMonth}
textStyle={styles.detailValue}
iconSize={18}
/>
) : (
<Text style={styles.detailValue}></Text>
)}
</View>
{subscriptionInfo.isAnnual && subscriptionInfo.nextGrantLabel ? (
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Prochain versement</Text>
<Text style={styles.detailValue}>
{subscriptionInfo.nextGrantLabel}
</Text>
</View>
) : null}
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>
{subscriptionInfo.cancelAtPeriodEnd
? "Fin d'accès"
: "Prochain renouvellement"}
</Text>
<Text style={styles.detailValue}>
{subscriptionInfo.periodEndLabel}
</Text>
</View>
{subscriptionInfo.createdAtLabel ? (
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Abonné depuis</Text>
<Text style={styles.detailValue}>
{subscriptionInfo.createdAtLabel}
</Text>
</View>
) : null}
{subscriptionInfo.cancelAtPeriodEnd ? (
<Text style={styles.helperText}>
Ton abonnement restera actif jusqu'à cette date.
</Text>
) : null}
{subscriptionInfo.helperMessage ? (
<Text style={styles.helperText}>
{subscriptionInfo.helperMessage}
</Text>
) : null}
{subscriptionInfo.isAnnual &&
typeof subscriptionInfo.coinsPerMonth === "number" ? (
<View style={styles.helperInlineRow}>
<Text style={styles.helperText}>
Tes pièces sont versées chaque mois (
</Text>
<CreditAmount
value={subscriptionInfo.coinsPerMonth}
textStyle={styles.helperText}
iconSize={14}
/>
<Text style={styles.helperText}>)</Text>
</View>
) : null}
</View>
{successMessage ? (
<Text style={styles.successMessage}>{successMessage}</Text>
) : null}
{errorMessage ? (
<Text style={styles.errorMessage}>{errorMessage}</Text>
) : null}
{remoteError ? (
<Text style={styles.errorMessage}>{remoteError}</Text>
) : null}
<BorderGradientButton
title="Changer d'abonnement"
onPress={handleOpenPlans}
containerStyle={styles.buttonSpacing}
/>
<BorderGradientButton
title={cancelButtonTitle}
onPress={handleCancel}
disabled={
!subscriptionInfo.canCancel ||
isCancelling ||
subscriptionInfo.cancelAtPeriodEnd ||
isFetchingRemote
}
tint="dark"
containerStyle={styles.cancelButton}
titleStyle={{ color: cancelTitleColor }}
/>
</>
) : (
<View style={styles.card}>
<Text style={styles.cardTitle}>Aucun abonnement actif</Text>
<Text style={styles.infoText}>
Souscris à lune de nos offres pour profiter des fonctionnalités
premium de Musicland.
</Text>
<BorderGradientButton
title="Découvrir les offres"
onPress={handleOpenPlans}
containerStyle={styles.emptyButton}
/>
</View>
)}
</View>
</Page>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: gutters * 1.5,
paddingBottom: gutters * 1.5,
paddingTop: gutters * 1.2,
gap: gutters,
},
card: {
padding: gutters * 1.5,
borderRadius: 24,
backgroundColor: "rgba(255, 255, 255, 0.04)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.08)",
gap: gutters * 0.75,
},
cardTitle: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 20,
color: Palette.white,
},
detailRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: gutters,
},
detailLabel: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
color: Palette.grayMid,
flexShrink: 1,
},
detailValue: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 15,
color: Palette.white,
flexShrink: 0,
textAlign: "right",
},
statusBadge: {
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 4,
},
statusText: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 12,
letterSpacing: 0.3,
},
helperText: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13,
lineHeight: 18,
color: Palette.grayMid,
},
helperInlineRow: {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: 4,
},
successMessage: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: Palette.green,
},
errorMessage: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: Palette.red,
},
infoText: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 15,
lineHeight: 22,
color: Palette.white,
},
buttonSpacing: {
marginTop: gutters * 0.5,
},
cancelButton: {
marginTop: gutters * 0.5,
},
emptyButton: {
marginTop: gutters * 1.5,
},
});
export default ManageSubscription;