1369 lines
40 KiB
JavaScript
1369 lines
40 KiB
JavaScript
import React from 'react'
|
|
import { ActivityIndicator, FlatList, Image, 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, planBadges, 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 { useUserData } from '../providers/UserDataProvider'
|
|
import { goBack, navigate } from '../navigation/NavigationService'
|
|
import { Routes } from '../navigation/Routes'
|
|
|
|
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, isAnnual }) {
|
|
const planKey = getPlanKeyForBadge(plan)
|
|
const planName =
|
|
(planKey && PLAN_DISPLAY_NAME_BY_KEY[planKey]) ||
|
|
plan?.product?.name ||
|
|
plan?.nickname ||
|
|
plan?.priceId ||
|
|
'Abonnement'
|
|
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 details = planKey ? PLAN_DETAILS[planKey] : null
|
|
const isPopular = details?.popular
|
|
|
|
const planBadgeKey = planKey
|
|
const getPlanBadge = (plan) => {
|
|
switch (plan) {
|
|
case 'starter':
|
|
return <Image source={planBadges.starter} style={styles.planBadgeImage} />
|
|
case 'pro':
|
|
return <Image source={planBadges.pro} style={styles.planBadgeImage} />
|
|
case 'premium':
|
|
return <Image source={planBadges.premium} style={styles.planBadgeImage} />
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
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,
|
|
isPopular && styles.cardWrapperPopular,
|
|
]}
|
|
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>
|
|
{getPlanBadge(planKey)}
|
|
|
|
</View>
|
|
{plan?.nickname ? <Text style={styles.cardSubtitle}>{plan.nickname}</Text> : null}
|
|
</View>
|
|
|
|
{formattedPrice || coinsPerMonth !== null ? (
|
|
<View style={styles.priceBlock}>
|
|
{formattedPrice ? (
|
|
<View style={styles.priceRow}>
|
|
<Text style={styles.priceValue}>{formattedPrice}</Text>
|
|
{intervalLabel ? <Text style={styles.period}>{intervalLabel}</Text> : null}
|
|
</View>
|
|
) : null}
|
|
|
|
{details?.creditsLabel ? (
|
|
<Text style={styles.coinsPromoText}>{details.creditsLabel}</Text>
|
|
) : coinsPerMonth !== null ? (
|
|
<View style={styles.coinsPerMonthRow}>
|
|
<CreditAmount
|
|
value={coinsPerMonth}
|
|
showPlus
|
|
textStyle={styles.coinsPerMonth}
|
|
iconSize={16}
|
|
accessibilityLabel={`+${coinsPerMonth} crédits par mois`}
|
|
/>
|
|
<Text style={styles.coinsPerMonthSuffix}>/ mois</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
|
|
<View style={styles.cardBenefits}>
|
|
<Text style={styles.benefitsTitle}>Privilège Membre :</Text>
|
|
{details?.privileges?.map((item, index) => (
|
|
<View key={index} style={styles.benefitBlock}>
|
|
<Text style={[styles.benefitText, item.bold && styles.benefitTextBold]}>
|
|
{item.bold ? `• ${item.text}` : item.text}
|
|
</Text>
|
|
{item.subItems ? (
|
|
<View style={styles.benefitSubList}>
|
|
<Text style={styles.benefitSubText}>{item.subItems.join(' - ')}</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
)) || (
|
|
<>
|
|
<Text style={styles.cardBenefitsItem}>
|
|
• Eligible au concours mensuel chanson/Vidéo
|
|
</Text>
|
|
<Text style={styles.cardBenefitsItem}>• Crédits gratuits tous les mois</Text>
|
|
</>
|
|
)}
|
|
</View>
|
|
|
|
{isPopular ? (
|
|
<View style={styles.popularBadge}>
|
|
<Text style={styles.popularBadgeText}>Le plus populaire !</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
|
|
'price_1SPgkqCzf2o5bDRdIcUwTDrm', // Premium
|
|
],
|
|
}
|
|
|
|
const PACK_PRICE_ID_BY_PERIOD = {
|
|
monthly: {
|
|
starter: 'price_1SPgitCzf2o5bDRdbnhLFx6f',
|
|
pro: 'price_1SPgjCCzf2o5bDRdr08Xzp8u',
|
|
premium: 'price_1SPgjaCzf2o5bDRdd9Xo2u26',
|
|
},
|
|
annual: {
|
|
starter: 'price_1SPgkDCzf2o5bDRdNGLVNeQ3',
|
|
pro: '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
|
|
})
|
|
})
|
|
|
|
const PLAN_FALLBACK_ORDER = ['starter', 'pro', 'premium']
|
|
|
|
const PLAN_SYNONYMS = {
|
|
starter: ['starter'],
|
|
pro: ['pro'],
|
|
premium: ['premium'],
|
|
}
|
|
|
|
const PLAN_DISPLAY_NAME_BY_KEY = {
|
|
starter: 'Play Backer Blue',
|
|
pro: 'Play Backer Silver',
|
|
premium: 'Play Backer Gold',
|
|
}
|
|
|
|
const COIN_PACK_DETAILS = {
|
|
starter: {
|
|
label: 'Pack starter',
|
|
creditsDisplay: '50 Crédits',
|
|
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
|
|
},
|
|
medium: {
|
|
label: 'Pack Medium',
|
|
creditsDisplay: '100 Crédits + 50 offerts',
|
|
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
|
|
},
|
|
premium: {
|
|
label: 'Pack Premium',
|
|
creditsDisplay: '150 Crédits + 100 offerts',
|
|
disclaimer: '*Non-Eligible au concours Chanson/Vidéo',
|
|
},
|
|
}
|
|
|
|
const getCoinPackKey = (pack) => {
|
|
const name = (pack?.name || '').toLowerCase()
|
|
if (name.includes('starter')) return 'starter'
|
|
if (name.includes('medium')) return 'medium'
|
|
if (name.includes('premium') || name.includes('prenium') || name.includes('creator'))
|
|
return 'premium'
|
|
return null
|
|
}
|
|
|
|
const PLAN_DETAILS = {
|
|
starter: {
|
|
creditsLabel: '50 Crédits / Mois',
|
|
popular: false,
|
|
privileges: [
|
|
{ text: 'Eligible au concours mensuel chanson/Vidéo', bold: true },
|
|
{ text: 'Catégorie Chanson :', subItems: ['Top 1: 500 €', 'Top 2: 250 €', 'Top 3: 125 €'] },
|
|
{ text: 'Catégorie Vidéo :', subItems: ['Top 1: 1000 €', 'Top 2: 500 €', 'Top 3: 250 €'] },
|
|
],
|
|
},
|
|
pro: {
|
|
creditsLabel: '100 crédits + 50 offerts / Mois',
|
|
popular: true,
|
|
privileges: [
|
|
{ text: 'Eligible au concours mensuel chanson/Vidéo', bold: true },
|
|
{ text: 'Catégorie Chanson :', subItems: ['Top 1: 500 €', 'Top 2: 250 €', 'Top 3: 125 €'] },
|
|
{ text: 'Catégorie Vidéo :', subItems: ['Top 1: 1000 €', 'Top 2: 500 €', 'Top 3: 250 €'] },
|
|
],
|
|
},
|
|
premium: {
|
|
creditsLabel: '150 Crédits + 100 offerts / Mois',
|
|
popular: false,
|
|
privileges: [
|
|
{ text: 'Eligible au concours mensuel chanson/Vidéo', bold: true },
|
|
{ text: 'Catégorie Chanson :', subItems: ['Top 1: 500 €', 'Top 2: 250 €', 'Top 3: 125 €'] },
|
|
{ text: 'Catégorie Vidéo :', subItems: ['Top 1: 1000 €', 'Top 2: 500 €', 'Top 3: 250 €'] },
|
|
],
|
|
},
|
|
}
|
|
|
|
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 ? 260 : 180
|
|
|
|
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,
|
|
coinPacks,
|
|
isCatalogLoading,
|
|
catalogError,
|
|
createSubscriptionCheckout,
|
|
createCoinPackCheckout,
|
|
} = useStripe()
|
|
const { currentUserData } = useUserData()
|
|
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 normalizedPlansByPeriod = React.useMemo(
|
|
() => ({
|
|
monthly: normalizePlans(subscriptions?.monthly, 'monthly'),
|
|
annual: normalizePlans(subscriptions?.annual, 'annual'),
|
|
}),
|
|
[subscriptions?.annual, subscriptions?.monthly]
|
|
)
|
|
|
|
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
|
|
|
|
React.useEffect(() => {
|
|
initialPackHandledRef.current = false
|
|
}, [initialSubscriptionPack])
|
|
|
|
React.useEffect(() => {
|
|
const periodEntries = Object.entries(normalizedPlansByPeriod).filter(
|
|
([, plans]) => (plans?.length || 0) > 0
|
|
)
|
|
|
|
if (!periodEntries.length) {
|
|
// Don't reset selectedPeriodKey here so it behaves more stably
|
|
setSelectedPriceId(null)
|
|
return
|
|
}
|
|
|
|
const shouldApplyPack = Boolean(initialSubscriptionPack) && !initialPackHandledRef.current
|
|
|
|
let matchedPriceId = null
|
|
let matchedPeriodKey = selectedPeriodKey
|
|
let nextPeriodKey = selectedPeriodKey
|
|
// Only force switch if current selected period is empty and another is not
|
|
if ((normalizedPlansByPeriod[selectedPeriodKey]?.length || 0) === 0) {
|
|
nextPeriodKey = periodEntries[0]?.[0] || 'annual'
|
|
}
|
|
|
|
if (shouldApplyPack) {
|
|
const desired = initialSubscriptionPack.trim().toLowerCase()
|
|
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
|
|
}
|
|
}
|
|
|
|
const matched = plans.find((plan) => {
|
|
const label = (plan?.product?.name || plan?.nickname || '').toString().toLowerCase()
|
|
return label.includes(desired)
|
|
})
|
|
|
|
if (matched?.priceId) {
|
|
matchedPriceId = matched.priceId
|
|
matchedPeriodKey = periodKey
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if (matchedPriceId) {
|
|
nextPeriodKey = matchedPeriodKey
|
|
}
|
|
|
|
const plansForPeriod = normalizedPlansByPeriod?.[nextPeriodKey] || []
|
|
|
|
const nextPriceId = (() => {
|
|
if (matchedPriceId) {
|
|
return matchedPriceId
|
|
}
|
|
const hasCurrent = plansForPeriod.some((plan) => plan.priceId === selectedPriceId)
|
|
if (hasCurrent) {
|
|
return selectedPriceId
|
|
}
|
|
return plansForPeriod?.[0]?.priceId || null
|
|
})()
|
|
|
|
if (nextPeriodKey !== selectedPeriodKey) {
|
|
setSelectedPeriodKey(nextPeriodKey)
|
|
}
|
|
|
|
if (nextPriceId !== selectedPriceId) {
|
|
setSelectedPriceId(nextPriceId)
|
|
}
|
|
|
|
if (matchedPriceId || (shouldApplyPack && !isCatalogLoading)) {
|
|
initialPackHandledRef.current = true
|
|
}
|
|
}, [
|
|
initialSubscriptionPack,
|
|
isCatalogLoading,
|
|
normalizedPlansByPeriod,
|
|
selectedPeriodKey,
|
|
selectedPriceId,
|
|
])
|
|
|
|
const currentPlans = normalizedPlansByPeriod?.[selectedPeriodKey] || []
|
|
|
|
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 handlePeriodChange = React.useCallback(
|
|
(periodKey) => {
|
|
if (!periodKey || periodKey === selectedPeriodKey) {
|
|
return
|
|
}
|
|
setSelectedPeriodKey(periodKey)
|
|
setSelectedPriceId(null)
|
|
setProcessingPriceId(null)
|
|
},
|
|
[selectedPeriodKey]
|
|
)
|
|
|
|
const handleBuyCredits = React.useCallback(
|
|
async (pack) => {
|
|
const targetId = pack?.productId
|
|
if (!targetId) return
|
|
setProcessingPriceId(targetId)
|
|
setErrorMessage(null)
|
|
try {
|
|
await createCoinPackCheckout(targetId)
|
|
} catch (error) {
|
|
console.error('[Subscriptions] coin checkout error', error)
|
|
setErrorMessage(
|
|
error?.message ||
|
|
'Une erreur est survenue lors de la création de la session Stripe pour les crédits.'
|
|
)
|
|
} finally {
|
|
setProcessingPriceId(null)
|
|
}
|
|
},
|
|
[createCoinPackCheckout]
|
|
)
|
|
|
|
const renderHeaderSection = React.useCallback(() => {
|
|
return (
|
|
<>
|
|
<View style={styles.header}>
|
|
<Text style={styles.title}>Rejoignez le club MusicLand</Text>
|
|
<View style={styles.balanceContainer}>
|
|
<Text style={styles.balanceLabel}>Votre solde actuel :</Text>
|
|
<CreditAmount value={currentUserData?.coins || 0} iconSize={20} showPlus={false} />
|
|
</View>
|
|
</View>
|
|
{combinedErrorMessage ? <Text style={styles.errorText}>{combinedErrorMessage}</Text> : null}
|
|
</>
|
|
)
|
|
}, [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}>
|
|
<SubscriptionCard
|
|
plan={item}
|
|
selected={selectedPriceId === item.priceId}
|
|
onSelect={handleSelect}
|
|
isAnnual={selectedPeriodKey === 'annual'}
|
|
/>
|
|
</View>
|
|
),
|
|
[handleSelect, selectedPeriodKey, 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={isWeb}
|
|
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()}
|
|
|
|
{renderSegmentedControl()}
|
|
|
|
{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}
|
|
|
|
<View style={[styles.packs, isWeb && styles.packsWeb]}>
|
|
{currentPlans.map((plan) => (
|
|
<SubscriptionCard
|
|
key={plan.priceId}
|
|
plan={plan}
|
|
selected={selectedPriceId === plan.priceId}
|
|
onSelect={handleSelect}
|
|
isAnnual={selectedPeriodKey === 'annual'}
|
|
/>
|
|
))}
|
|
</View>
|
|
|
|
{coinPacks && coinPacks.length > 0 ? (
|
|
<View style={styles.creditsSection}>
|
|
<Text style={styles.sectionTitle}>Recharger vos crédits</Text>
|
|
<View style={styles.creditsGrid}>
|
|
{coinPacks.map((pack) => {
|
|
// Attempt to find amount in commonly used Stripe fields
|
|
const rawAmount = pack.unitAmount ?? pack.amount ?? pack.price ?? 0
|
|
const price = formatCurrency(rawAmount, pack.currency)
|
|
// Assuming pack structure: { id, name, unitAmount/amount, currency, metadata: { coins: 100 } }
|
|
const coins = Number(pack.metadata?.coins || 0)
|
|
|
|
const packKey = getCoinPackKey(pack)
|
|
const details = packKey ? COIN_PACK_DETAILS[packKey] : null
|
|
const displayName = details?.label || pack.name
|
|
const creditsText = details?.creditsDisplay
|
|
|
|
return (
|
|
<View key={pack.productId} style={styles.creditCard}>
|
|
<View style={styles.creditInfoContainer}>
|
|
<Text style={styles.creditName}>{displayName}</Text>
|
|
{creditsText ? (
|
|
<Text style={styles.creditAmountCustom}>{creditsText}</Text>
|
|
) : (
|
|
<CreditAmount
|
|
value={coins}
|
|
iconSize={24}
|
|
textStyle={styles.creditAmountText}
|
|
/>
|
|
)}
|
|
{details?.disclaimer ? (
|
|
<Text style={styles.packDisclaimer}>{details.disclaimer}</Text>
|
|
) : null}
|
|
</View>
|
|
<GradientButton
|
|
title={
|
|
processingPriceId === pack.productId
|
|
? '...'
|
|
: price || `${rawAmount / 100}€`
|
|
}
|
|
onPress={() => handleBuyCredits(pack)}
|
|
disabled={Boolean(processingPriceId)}
|
|
style={styles.creditButton}
|
|
textStyle={styles.creditButtonText}
|
|
gradientStyle={{ paddingVertical: 8, paddingHorizontal: 16 }}
|
|
/>
|
|
</View>
|
|
)
|
|
})}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{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',
|
|
},
|
|
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,
|
|
},
|
|
|
|
cardBenefits: {
|
|
marginTop: 12,
|
|
borderTopWidth: 1,
|
|
borderTopColor: 'rgba(255, 255, 255, 0.1)',
|
|
paddingTop: 12,
|
|
gap: 6,
|
|
},
|
|
cardBenefitsItem: {
|
|
fontFamily: FONT_FAMILY.InterMedium,
|
|
fontSize: 12,
|
|
color: 'rgba(255, 255, 255, 0.7)',
|
|
},
|
|
benefitsTitle: {
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
fontSize: 13,
|
|
color: Palette.white,
|
|
marginBottom: 2,
|
|
},
|
|
benefitBlock: {
|
|
marginBottom: 4,
|
|
},
|
|
benefitText: {
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
fontSize: 13,
|
|
color: 'rgba(255, 255, 255, 0.85)',
|
|
},
|
|
benefitTextBold: {
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
color: Palette.white,
|
|
},
|
|
benefitSubList: {
|
|
marginTop: 2,
|
|
paddingLeft: 0,
|
|
},
|
|
benefitSubText: {
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
fontSize: 12,
|
|
color: 'rgba(255, 255, 255, 0.65)',
|
|
fontStyle: 'italic',
|
|
},
|
|
coinsPromoText: {
|
|
fontFamily: FONT_FAMILY.InterBold,
|
|
fontSize: 15,
|
|
color: Palette.primary,
|
|
},
|
|
balanceContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 8,
|
|
borderRadius: 20,
|
|
gap: 10,
|
|
marginTop: 8,
|
|
},
|
|
balanceLabel: {
|
|
fontFamily: FONT_FAMILY.InterMedium,
|
|
fontSize: 14,
|
|
color: Palette.white,
|
|
},
|
|
creditsSection: {
|
|
width: '100%',
|
|
marginTop: 40,
|
|
gap: 16,
|
|
alignItems: 'center',
|
|
},
|
|
sectionTitle: {
|
|
fontFamily: FONT_FAMILY.InterBold,
|
|
fontSize: 20,
|
|
color: Palette.white,
|
|
},
|
|
creditsGrid: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: 16,
|
|
justifyContent: 'center',
|
|
},
|
|
creditCard: {
|
|
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
|
borderRadius: 16,
|
|
padding: 16,
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
width: isWeb ? 200 : '45%',
|
|
gap: 12,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255, 255, 255, 0.1)',
|
|
},
|
|
creditName: {
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
fontSize: 14,
|
|
color: Palette.white,
|
|
textAlign: 'center',
|
|
marginBottom: 4,
|
|
},
|
|
creditInfoContainer: {
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
},
|
|
creditAmountCustom: {
|
|
fontFamily: FONT_FAMILY.InterBold,
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
textAlign: 'center',
|
|
},
|
|
packDisclaimer: {
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
fontSize: 10,
|
|
color: 'rgba(255, 255, 255, 0.6)',
|
|
fontStyle: 'italic',
|
|
textAlign: 'center',
|
|
marginTop: 4,
|
|
},
|
|
creditAmountText: {
|
|
fontSize: 20,
|
|
},
|
|
creditButton: {
|
|
width: '100%',
|
|
height: 36,
|
|
minHeight: 36,
|
|
},
|
|
creditButtonText: {
|
|
fontSize: 13,
|
|
},
|
|
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,
|
|
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 + "80",
|
|
},
|
|
annualBadgeText: {
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
fontSize: 12,
|
|
color: Palette.white + "80",
|
|
},
|
|
actions: {
|
|
width: '100%',
|
|
alignItems: 'center',
|
|
},
|
|
actionButtonGradient: {
|
|
width: isWeb ? 320 : '90%',
|
|
},
|
|
cardWrapper: {
|
|
flex: 1,
|
|
width: '100%',
|
|
minWidth: 0,
|
|
minHeight: CARD_MIN_HEIGHT,
|
|
position: 'relative',
|
|
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,
|
|
},
|
|
cardWrapperPopular: {
|
|
borderColor: Palette.primary, // Highlight border for popular
|
|
backgroundColor: 'rgba(112, 35, 247, 0.1)', // Slight tint
|
|
},
|
|
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,
|
|
},
|
|
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)',
|
|
},
|
|
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',
|
|
},
|
|
popularBadge: {
|
|
alignSelf: 'center',
|
|
marginTop: 12,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 4,
|
|
borderRadius: 999,
|
|
backgroundColor: Palette.primary,
|
|
},
|
|
popularBadgeText: {
|
|
fontFamily: FONT_FAMILY.InterBold,
|
|
fontSize: 11,
|
|
color: Palette.white,
|
|
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%',
|
|
},
|
|
})
|