clear more ticket, sub and design

This commit is contained in:
Thomas Demirdjian
2025-12-10 15:39:32 +01:00
parent b1ab6a549f
commit aacbd5411e
15 changed files with 1001 additions and 145 deletions
+258 -56
View File
@@ -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 labonnement 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%",