clear last tickets
This commit is contained in:
@@ -0,0 +1,989 @@
|
||||
import React from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Image as ExpoImage } from "expo-image";
|
||||
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, 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") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = amount / 100;
|
||||
const upperCurrency =
|
||||
typeof currency === "string" && currency.trim()
|
||||
? currency.trim().toUpperCase()
|
||||
: "EUR";
|
||||
|
||||
if (typeof Intl !== "undefined" && Intl.NumberFormat) {
|
||||
try {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: upperCurrency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(normalized);
|
||||
} catch (_error) {
|
||||
// Ignore format error and fall back to manual format.
|
||||
}
|
||||
}
|
||||
|
||||
return `${normalized.toFixed(2)} ${upperCurrency}`;
|
||||
};
|
||||
|
||||
const getIntervalLabel = (recurring) => {
|
||||
if (!recurring || typeof recurring !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const interval = recurring.interval || "month";
|
||||
const count = recurring.interval_count || 1;
|
||||
|
||||
const vocabulary = {
|
||||
day: { singular: "jour", plural: "jours" },
|
||||
week: { singular: "semaine", plural: "semaines" },
|
||||
month: { singular: "mois", plural: "mois" },
|
||||
year: { singular: "an", plural: "ans" },
|
||||
};
|
||||
|
||||
const terms = vocabulary[interval] || vocabulary.month;
|
||||
if (count <= 1) {
|
||||
return `par ${terms.singular}`;
|
||||
}
|
||||
return `tous les ${count} ${count > 1 ? terms.plural : terms.singular}`;
|
||||
};
|
||||
|
||||
function SubscriptionCard({ plan, selected, onSelect }) {
|
||||
const planName =
|
||||
plan?.product?.name || plan?.nickname || plan?.priceId || "Abonnement";
|
||||
const planDescription = plan?.product?.description || "";
|
||||
const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency);
|
||||
const intervalLabel = getIntervalLabel(plan?.recurring);
|
||||
const coinsPerMonth =
|
||||
typeof plan?.coinsPerMonth === "number" &&
|
||||
Number.isFinite(plan.coinsPerMonth)
|
||||
? Math.round(plan.coinsPerMonth)
|
||||
: null;
|
||||
const songsPerMonth =
|
||||
coinsPerMonth !== null
|
||||
? Math.floor(coinsPerMonth / CREDITS_PER_MUSIC)
|
||||
: null;
|
||||
const songsPerMonthLabel =
|
||||
songsPerMonth === null
|
||||
? null
|
||||
: songsPerMonth >= 1
|
||||
? `≈ ${songsPerMonth} ${
|
||||
songsPerMonth > 1 ? "musiques" : "musique"
|
||||
} / mois`
|
||||
: "Moins d'une musique / mois";
|
||||
const planBadgeKey = getPlanKeyForBadge(plan);
|
||||
const planBadgeSource =
|
||||
planBadgeKey && subBadges[planBadgeKey] ? subBadges[planBadgeKey] : null;
|
||||
const handleSelect = React.useCallback(() => {
|
||||
if (typeof onSelect === "function" && plan?.priceId) {
|
||||
onSelect(plan.priceId);
|
||||
}
|
||||
}, [onSelect, plan?.priceId]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handleSelect}
|
||||
style={({ pressed }) => [
|
||||
styles.cardWrapper,
|
||||
selected && styles.cardWrapperSelected,
|
||||
pressed && styles.cardWrapperPressed,
|
||||
plan?.active === false && styles.cardWrapperInactive,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected, disabled: plan?.active === false }}
|
||||
disabled={plan?.active === false}
|
||||
>
|
||||
<BlurView intensity={18} tint="dark" style={styles.cardBlur}>
|
||||
<View style={styles.cardContent}>
|
||||
{plan?.badge ? (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{plan.badge}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.planName}>{planName}</Text>
|
||||
{planBadgeSource ? (
|
||||
<ExpoImage
|
||||
source={planBadgeSource}
|
||||
style={styles.planBadgeImage}
|
||||
contentFit="contain"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{plan?.nickname ? (
|
||||
<Text style={styles.cardSubtitle}>{plan.nickname}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{formattedPrice ? (
|
||||
<View style={styles.priceBlock}>
|
||||
<View style={styles.priceRow}>
|
||||
<Text style={styles.priceValue}>{formattedPrice}</Text>
|
||||
{intervalLabel ? (
|
||||
<Text style={styles.period}>{intervalLabel}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{coinsPerMonth !== null ? (
|
||||
<View style={styles.coinsPerMonthRow}>
|
||||
<CreditAmount
|
||||
value={coinsPerMonth}
|
||||
showPlus
|
||||
textStyle={styles.coinsPerMonth}
|
||||
iconSize={16}
|
||||
accessibilityLabel={`+${coinsPerMonth} pièces par mois`}
|
||||
/>
|
||||
<Text style={styles.coinsPerMonthSuffix}>/ mois</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{songsPerMonthLabel ? (
|
||||
<Text style={styles.songsPerMonth}>{songsPerMonthLabel}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{planDescription ? (
|
||||
<Text style={styles.description}>{planDescription}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</BlurView>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const PRICE_PRIORITY_BY_PERIOD = {
|
||||
monthly: [
|
||||
"price_1SPgitCzf2o5bDRdbnhLFx6f", // Starter
|
||||
"price_1SPgjCCzf2o5bDRdr08Xzp8u", // Pro
|
||||
"price_1SPgjaCzf2o5bDRdd9Xo2u26", // Premium
|
||||
],
|
||||
annual: [
|
||||
"price_1SPgkDCzf2o5bDRdNGLVNeQ3", // Starter
|
||||
"price_1SPgkXCzf2o5bDRdejBVxEBY", // Pro
|
||||
"price_1SPgkqCzf2o5bDRdIcUwTDrm", // Premium
|
||||
],
|
||||
};
|
||||
|
||||
const PACK_PRICE_ID_BY_PERIOD = {
|
||||
monthly: {
|
||||
starter: "price_1SPgitCzf2o5bDRdbnhLFx6f",
|
||||
pro: "price_1SPgjCCzf2o5bDRdr08Xzp8u",
|
||||
prop: "price_1SPgjCCzf2o5bDRdr08Xzp8u",
|
||||
premium: "price_1SPgjaCzf2o5bDRdd9Xo2u26",
|
||||
},
|
||||
annual: {
|
||||
starter: "price_1SPgkDCzf2o5bDRdNGLVNeQ3",
|
||||
pro: "price_1SPgkXCzf2o5bDRdejBVxEBY",
|
||||
prop: "price_1SPgkXCzf2o5bDRdejBVxEBY",
|
||||
premium: "price_1SPgkqCzf2o5bDRdIcUwTDrm",
|
||||
},
|
||||
};
|
||||
|
||||
const PRICE_ID_TO_PLAN_KEY = {};
|
||||
|
||||
Object.values(PACK_PRICE_ID_BY_PERIOD).forEach((mapping) => {
|
||||
Object.entries(mapping).forEach(([planKey, priceId]) => {
|
||||
if (!priceId) {
|
||||
return;
|
||||
}
|
||||
PRICE_ID_TO_PLAN_KEY[priceId] = planKey === "prop" ? "pro" : planKey;
|
||||
});
|
||||
});
|
||||
|
||||
const PLAN_FALLBACK_ORDER = ["starter", "pro", "premium"];
|
||||
|
||||
const PLAN_SYNONYMS = {
|
||||
starter: ["starter"],
|
||||
pro: ["pro", "prop"],
|
||||
premium: ["premium"],
|
||||
};
|
||||
|
||||
const getPlanKeyForBadge = (plan) => {
|
||||
if (!plan || typeof plan !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const priceId =
|
||||
typeof plan?.priceId === "string" ? plan.priceId : plan?.id || null;
|
||||
|
||||
if (priceId && PRICE_ID_TO_PLAN_KEY[priceId]) {
|
||||
return PRICE_ID_TO_PLAN_KEY[priceId];
|
||||
}
|
||||
|
||||
const label = (plan?.product?.name || plan?.nickname || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = Object.entries(PLAN_SYNONYMS).find(([, variants]) =>
|
||||
variants.some((variant) => label.includes(variant))
|
||||
);
|
||||
|
||||
return match ? match[0] : null;
|
||||
};
|
||||
|
||||
const getPlanPriority = (plan, periodKey) => {
|
||||
const priceId =
|
||||
typeof plan?.priceId === "string" ? plan.priceId : plan?.id || null;
|
||||
const priceOrder = PRICE_PRIORITY_BY_PERIOD[periodKey] || [];
|
||||
|
||||
if (priceId) {
|
||||
const priceIndex = priceOrder.indexOf(priceId);
|
||||
if (priceIndex !== -1) {
|
||||
return priceIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const label = (plan?.product?.name || plan?.nickname || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
|
||||
const fallbackIndex = PLAN_FALLBACK_ORDER.findIndex((candidate) => {
|
||||
const variants = PLAN_SYNONYMS[candidate] || [candidate];
|
||||
return variants.some((variant) => label.includes(variant));
|
||||
});
|
||||
|
||||
const baseOffset = priceOrder.length;
|
||||
return fallbackIndex === -1
|
||||
? baseOffset + PLAN_FALLBACK_ORDER.length
|
||||
: baseOffset + fallbackIndex;
|
||||
};
|
||||
|
||||
const normalizePlans = (plans = [], periodKey) => {
|
||||
if (!Array.isArray(plans)) {
|
||||
return [];
|
||||
}
|
||||
return plans
|
||||
.filter((plan) => plan && typeof plan === "object" && plan.priceId)
|
||||
.map((plan) => ({
|
||||
...plan,
|
||||
badge: null,
|
||||
}))
|
||||
.sort(
|
||||
(planA, planB) =>
|
||||
getPlanPriority(planA, periodKey) - getPlanPriority(planB, periodKey)
|
||||
);
|
||||
};
|
||||
|
||||
const HERO_IMAGE_WIDTH = isWeb ? 1280 : 700;
|
||||
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 CREDITS_PER_MUSIC = 8;
|
||||
|
||||
export default function Subscriptions() {
|
||||
const route = useRoute();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = !isWeb;
|
||||
const initialPack = React.useMemo(() => {
|
||||
const rawPack = route?.params?.pack;
|
||||
return typeof rawPack === "string" ? rawPack.toLowerCase() : null;
|
||||
}, [route?.params?.pack]);
|
||||
|
||||
const initialSubscriptionPack =
|
||||
initialPack && initialPack !== "packs" ? initialPack : null;
|
||||
|
||||
const {
|
||||
subscriptions,
|
||||
isCatalogLoading,
|
||||
catalogError,
|
||||
createSubscriptionCheckout,
|
||||
} = useStripe();
|
||||
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 hasAnyPlan = (normalizedPlans?.length || 0) > 0;
|
||||
const isLoadingPlans = isCatalogLoading && !hasAnyPlan;
|
||||
const combinedErrorMessage = errorMessage || catalogError;
|
||||
|
||||
React.useEffect(() => {
|
||||
initialPackHandledRef.current = false;
|
||||
}, [initialSubscriptionPack]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const shouldApplyPack =
|
||||
Boolean(initialSubscriptionPack) && !initialPackHandledRef.current;
|
||||
|
||||
let matchedPriceId = null;
|
||||
|
||||
if (shouldApplyPack && normalizedPlans?.length) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedPriceId) {
|
||||
const matched = normalizedPlans.find((plan) => {
|
||||
const label = (plan?.product?.name || plan?.nickname || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
return label.includes(desired);
|
||||
});
|
||||
|
||||
if (matched?.priceId) {
|
||||
matchedPriceId = matched.priceId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedPriceId((current) => {
|
||||
if (matchedPriceId) {
|
||||
return matchedPriceId;
|
||||
}
|
||||
|
||||
const hasCurrent = normalizedPlans?.some(
|
||||
(plan) => plan.priceId === current
|
||||
);
|
||||
|
||||
if (hasCurrent) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return normalizedPlans?.[0]?.priceId || null;
|
||||
});
|
||||
|
||||
if (matchedPriceId || (shouldApplyPack && !isCatalogLoading)) {
|
||||
initialPackHandledRef.current = true;
|
||||
}
|
||||
}, [
|
||||
initialSubscriptionPack,
|
||||
isCatalogLoading,
|
||||
normalizedPlans,
|
||||
]);
|
||||
|
||||
const currentPlans = normalizedPlans || [];
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(priceId) => {
|
||||
setSelectedPriceId(priceId);
|
||||
setProcessingPriceId(null);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCheckout = React.useCallback(async () => {
|
||||
if (!selectedPriceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessingPriceId(selectedPriceId);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await createSubscriptionCheckout(selectedPriceId);
|
||||
} catch (error) {
|
||||
console.error("[Subscriptions] checkout error", error);
|
||||
setErrorMessage(
|
||||
error?.message ||
|
||||
"Une erreur est survenue lors de la création de la session Stripe."
|
||||
);
|
||||
} finally {
|
||||
setProcessingPriceId(null);
|
||||
}
|
||||
}, [selectedPriceId, createSubscriptionCheckout]);
|
||||
|
||||
const isProcessing = Boolean(processingPriceId);
|
||||
const isActionDisabled = !selectedPriceId || isProcessing || isLoadingPlans;
|
||||
const actionButtonTitle = isProcessing
|
||||
? "Redirection..."
|
||||
: "Choisir cet abonnement";
|
||||
const currentPlanCount = currentPlans.length;
|
||||
const mobileActionSafePadding = React.useMemo(
|
||||
() => Math.max(insets.bottom, gutters),
|
||||
[insets.bottom]
|
||||
);
|
||||
|
||||
const mobileListContentInset = React.useMemo(
|
||||
() => ({
|
||||
paddingBottom: mobileActionSafePadding + gutters * 3,
|
||||
}),
|
||||
[mobileActionSafePadding]
|
||||
);
|
||||
const handleBackPress = React.useCallback(() => {
|
||||
goBack();
|
||||
}, []);
|
||||
|
||||
const renderHeaderSection = React.useCallback(() => {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>
|
||||
Choisissez l’abonnement qui vous correspond
|
||||
</Text>
|
||||
</View>
|
||||
{combinedErrorMessage ? (
|
||||
<Text style={styles.errorText}>{combinedErrorMessage}</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}, [combinedErrorMessage]);
|
||||
|
||||
const renderSegmentedControl = React.useCallback(() => null, []);
|
||||
|
||||
const renderMobilePlanItem = React.useCallback(
|
||||
({ item }) => (
|
||||
<View style={styles.mobileCard}>
|
||||
<SubscriptionCard
|
||||
plan={item}
|
||||
selected={selectedPriceId === item.priceId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[handleSelect, selectedPriceId]
|
||||
);
|
||||
|
||||
const renderMobileEmptyComponent = React.useCallback(() => {
|
||||
return (
|
||||
<View style={styles.mobileEmptyWrapper}>
|
||||
{isLoadingPlans ? (
|
||||
<ActivityIndicator color={Palette.white} />
|
||||
) : (
|
||||
<Text style={styles.emptyState}>
|
||||
Aucun abonnement Stripe disponible pour le moment.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}, [isLoadingPlans]);
|
||||
|
||||
const renderMobileFooterComponent = React.useCallback(() => {
|
||||
return (
|
||||
<View style={styles.mobileFooter}>
|
||||
{isLoadingPlans && currentPlanCount > 0 ? (
|
||||
<View style={styles.inlineLoader}>
|
||||
<ActivityIndicator color={Palette.white} size="small" />
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text>
|
||||
</View>
|
||||
);
|
||||
}, [currentPlanCount, isLoadingPlans]);
|
||||
|
||||
const renderMobileTopSticky = React.useCallback(() => {
|
||||
return (
|
||||
<View style={styles.mobileStickyHeader}>
|
||||
{renderHeaderSection()}
|
||||
{renderSegmentedControl()}
|
||||
</View>
|
||||
);
|
||||
}, [renderHeaderSection, renderSegmentedControl]);
|
||||
|
||||
const renderMobileBottomActions = React.useCallback(() => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.mobileActions,
|
||||
{ paddingBottom: mobileActionSafePadding },
|
||||
]}
|
||||
>
|
||||
<GradientButton
|
||||
title={actionButtonTitle}
|
||||
onPress={handleCheckout}
|
||||
disabled={isActionDisabled}
|
||||
gradientStyle={[
|
||||
styles.actionButtonGradient,
|
||||
styles.mobileActionButtonGradient,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [
|
||||
actionButtonTitle,
|
||||
handleCheckout,
|
||||
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}>
|
||||
<Page
|
||||
headerType="NAVIGATE"
|
||||
title="Abonnements"
|
||||
scrollEnabled={false}
|
||||
width={isWeb ? 960 : undefined}
|
||||
containerStyle={styles.page}
|
||||
contentContainerStyle={[
|
||||
styles.pageContent,
|
||||
isMobile && styles.pageContentMobile,
|
||||
]}
|
||||
backgroundColor={PAGE_BACKGROUND_COLOR}
|
||||
hideBackButton={!isMobile}
|
||||
topStickyContent={isMobile ? renderMobileTopSticky : renderWebBackButton}
|
||||
>
|
||||
<View style={[styles.inner, isMobile && styles.mobileInner]}>
|
||||
<ExpoImage
|
||||
source={background.bgTrans}
|
||||
contentFit="cover"
|
||||
style={styles.centerImage}
|
||||
/>
|
||||
|
||||
<View style={[styles.content, isMobile && styles.mobileContent]}>
|
||||
{isMobile ? (
|
||||
<FlatList
|
||||
data={currentPlans}
|
||||
keyExtractor={(plan) => plan.priceId}
|
||||
renderItem={renderMobilePlanItem}
|
||||
style={styles.mobileList}
|
||||
contentContainerStyle={[
|
||||
styles.mobileListContent,
|
||||
mobileListContentInset,
|
||||
]}
|
||||
ListEmptyComponent={renderMobileEmptyComponent}
|
||||
ListFooterComponent={renderMobileFooterComponent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{renderHeaderSection()}
|
||||
|
||||
{isLoadingPlans && currentPlanCount === 0 ? (
|
||||
<View style={styles.loaderContainer}>
|
||||
<ActivityIndicator color={Palette.white} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!isLoadingPlans && currentPlanCount === 0 ? (
|
||||
<Text style={styles.emptyState}>
|
||||
Aucun abonnement Stripe disponible pour le moment.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{renderSegmentedControl()}
|
||||
|
||||
<View style={[styles.packs, isWeb && styles.packsWeb]}>
|
||||
{currentPlans.map((plan) => (
|
||||
<SubscriptionCard
|
||||
key={plan.priceId}
|
||||
plan={plan}
|
||||
selected={selectedPriceId === plan.priceId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{isLoadingPlans && currentPlanCount > 0 ? (
|
||||
<View style={styles.inlineLoader}>
|
||||
<ActivityIndicator color={Palette.white} size="small" />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<GradientButton
|
||||
title={actionButtonTitle}
|
||||
onPress={handleCheckout}
|
||||
disabled={isActionDisabled}
|
||||
gradientStyle={styles.actionButtonGradient}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
{isMobile ? renderMobileBottomActions() : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: PAGE_BACKGROUND_COLOR,
|
||||
},
|
||||
page: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
pageContent: {
|
||||
flexGrow: 1,
|
||||
paddingTop: 48,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
pageContentMobile: {
|
||||
paddingTop: gutters,
|
||||
paddingBottom: gutters * 2,
|
||||
},
|
||||
inner: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
maxWidth: 960,
|
||||
alignSelf: "center",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: gutters,
|
||||
position: "relative",
|
||||
},
|
||||
mobileInner: {
|
||||
alignItems: "stretch",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
content: {
|
||||
width: "100%",
|
||||
gap: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
},
|
||||
mobileContent: {
|
||||
flex: 1,
|
||||
gap: 0,
|
||||
alignItems: "stretch",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
centerImage: {
|
||||
width: HERO_IMAGE_WIDTH,
|
||||
height: HERO_IMAGE_HEIGHT,
|
||||
borderRadius: 28,
|
||||
overflow: "hidden",
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: [
|
||||
{ translateX: -HERO_IMAGE_WIDTH / 2 },
|
||||
{ translateY: -HERO_IMAGE_HEIGHT / 2 },
|
||||
],
|
||||
pointerEvents: "none",
|
||||
},
|
||||
header: {
|
||||
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,
|
||||
lineHeight: isWeb ? 40 : 28,
|
||||
color: Palette.white,
|
||||
textAlign: "center",
|
||||
},
|
||||
errorText: {
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
fontSize: 14,
|
||||
color: Palette.red,
|
||||
textAlign: "center",
|
||||
},
|
||||
loaderContainer: {
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyState: {
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
fontSize: 14,
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
textAlign: "center",
|
||||
},
|
||||
packs: {
|
||||
width: "100%",
|
||||
maxWidth: 960,
|
||||
gap: gutters,
|
||||
alignItems: "center",
|
||||
},
|
||||
packsWeb: {
|
||||
flexDirection: "row",
|
||||
gap: gutters,
|
||||
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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
actions: {
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
},
|
||||
actionButtonGradient: {
|
||||
width: isWeb ? 320 : "90%",
|
||||
},
|
||||
cardWrapper: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
minHeight: CARD_MIN_HEIGHT,
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.12)",
|
||||
backgroundColor: "rgba(12, 14, 18, 0.45)",
|
||||
},
|
||||
cardWrapperSelected: {
|
||||
borderColor: Palette.primary,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
cardWrapperPressed: {
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
cardWrapperInactive: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
cardBlur: {
|
||||
flex: 1,
|
||||
paddingHorizontal: gutters,
|
||||
paddingVertical: isWeb ? gutters : Math.max(gutters * 0.2, 10),
|
||||
gap: isWeb ? 18 : 10,
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(48, 52, 56, 0.55)",
|
||||
borderRadius: 24,
|
||||
},
|
||||
cardContent: {
|
||||
gap: isWeb ? 16 : 4,
|
||||
},
|
||||
cardHeader: {
|
||||
gap: 6,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
width: "100%",
|
||||
},
|
||||
planName: {
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
fontSize: isWeb ? 24 : 20,
|
||||
color: Palette.white,
|
||||
flexShrink: 1,
|
||||
},
|
||||
planBadgeImage: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
flexShrink: 0,
|
||||
},
|
||||
cardSubtitle: {
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: "rgba(255, 255, 255, 0.75)",
|
||||
},
|
||||
priceBlock: {
|
||||
gap: isWeb ? 4 : 1,
|
||||
},
|
||||
priceRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "baseline",
|
||||
gap: 8,
|
||||
},
|
||||
coinsPerMonthRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
},
|
||||
coinsPerMonth: {
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
fontSize: 13,
|
||||
color: Palette.primary,
|
||||
},
|
||||
coinsPerMonthSuffix: {
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
fontSize: 13,
|
||||
color: Palette.primary,
|
||||
},
|
||||
songsPerMonth: {
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 12,
|
||||
color: "rgba(255, 255, 255, 0.75)",
|
||||
},
|
||||
priceValue: {
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
fontSize: isWeb ? 22 : 20,
|
||||
color: Palette.white,
|
||||
},
|
||||
period: {
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 14,
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
},
|
||||
description: {
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
color: Palette.white,
|
||||
},
|
||||
badge: {
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: "rgba(112, 35, 247, 0.25)",
|
||||
},
|
||||
badgeText: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 12,
|
||||
color: Palette.primary,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
inlineLoader: {
|
||||
alignItems: "center",
|
||||
},
|
||||
disclaimer: {
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 12,
|
||||
color: "rgba(255, 255, 255, 0.6)",
|
||||
lineHeight: 18,
|
||||
textAlign: "center",
|
||||
},
|
||||
mobileStickyHeader: {
|
||||
width: "100%",
|
||||
gap: 6,
|
||||
paddingBottom: gutters * 0.2,
|
||||
backgroundColor: PAGE_BACKGROUND_COLOR,
|
||||
alignItems: "center",
|
||||
},
|
||||
segmentedControlMobile: {
|
||||
alignSelf: "center",
|
||||
marginBottom: 0,
|
||||
},
|
||||
mobileList: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
},
|
||||
mobileListContent: {
|
||||
flexGrow: 1,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
mobileCard: {
|
||||
marginBottom: gutters * 0.6,
|
||||
},
|
||||
mobileEmptyWrapper: {
|
||||
paddingVertical: gutters * 2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
mobileFooter: {
|
||||
width: "100%",
|
||||
paddingTop: gutters,
|
||||
gap: gutters,
|
||||
alignItems: "center",
|
||||
},
|
||||
mobileActions: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
paddingHorizontal: gutters,
|
||||
paddingTop: gutters * 0.4,
|
||||
backgroundColor: PAGE_BACKGROUND_COLOR,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: "rgba(255, 255, 255, 0.12)",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
elevation: 12,
|
||||
},
|
||||
mobileActionButtonGradient: {
|
||||
width: "100%",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user