continue subscriptions and coins

This commit is contained in:
Thomas Demirdjian
2025-11-05 15:39:15 +01:00
parent a58eeac079
commit 5f777f3b6d
25 changed files with 1994 additions and 222 deletions
+699
View File
@@ -0,0 +1,699 @@
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 { 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";
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 formatCoinsAmount = (value) => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
try {
return new Intl.NumberFormat("fr-FR", {
maximumFractionDigits: 0,
}).format(value);
} catch (_error) {
return `${Math.round(value)}`;
}
};
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 toDate = (value) => {
if (!value) {
return null;
}
if (typeof value.toDate === "function") {
try {
return value.toDate();
} catch (_error) {
return null;
}
}
if (value instanceof Date) {
return value;
}
if (typeof value === "number" && Number.isFinite(value)) {
if (value > 1e12) {
return new Date(value);
}
return new Date(value * 1000);
}
if (typeof value === "object" && Number.isFinite(value.seconds)) {
return new Date(value.seconds * 1000);
}
return null;
};
const formatDate = (date) => {
if (!date) {
return "À déterminer";
}
try {
return new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
} catch (_error) {
return date.toString();
}
};
const capitalize = (value) => {
if (typeof value !== "string" || !value) {
return null;
}
return value.charAt(0).toUpperCase() + value.slice(1);
};
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 coinsPerMonthLabel =
normalizedCoins !== null
? `${formatCoinsAmount(normalizedCoins)} coins`
: null;
const nextGrantTimestamp =
currentUserData?.subscriptionNextGrantAt ||
currentUserData?.subscriptionGrantNextAt ||
null;
const nextGrantDate = toDate(nextGrantTimestamp);
const nextGrantLabel =
nextGrantDate && isAnnual ? formatDate(nextGrantDate) : null;
const helperMessages = [];
if (!hasActiveSubscription && hasAnySubscription) {
helperMessages.push(
"Ton abonnement n'est plus actif. Tu peux souscrire à nouveau à tout moment.",
);
}
if (isAnnual && coinsPerMonthLabel) {
helperMessages.push(
`Tes crédits sont versés chaque mois (${coinsPerMonthLabel}).`,
);
}
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,
coinsPerMonthLabel,
isAnnual,
nextGrantDate,
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 ||
error?.codeMessage ||
"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;
return (
<Page headerType="NAVIGATE" title="Mon abonnement" scrollEnabled>
<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>
<Text style={styles.detailValue}>
{subscriptionInfo.coinsPerMonthLabel || "—"}
</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}
</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,
},
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;