Files
musicland/src/components/modal/CoinPackModal.js
T
2026-01-15 15:13:03 +01:00

637 lines
18 KiB
JavaScript

import React from 'react'
import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native'
import { BlurView } from 'expo-blur'
import BorderGradientButton from '../BorderGradientButton'
import GradientButton from '../GradientButton'
import CreateLyricsHeader from '../../screens/Writing/components/CreateLyricsHeader'
import { Palette, gutters } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import { isWeb } from '../../hooks/useLayoutType'
import CreditAmount from '../CreditAmount'
import { useStripe } from '../../providers/StripeProvider'
const WEB_MODAL_MAX_WIDTH = 820
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) { }
}
return `${normalized.toFixed(2)} ${upperCurrency}`
}
const FALLBACK_BASE_PRICE_PER_COIN = 12 // ~0,12€ par jeton (valeur indicatif pour l'affichage)
const FALLBACK_DISCOUNT_STEPS = [0, 12, 18, 26, 32]
const STATIC_DISCOUNTS = [0, 30, 50]
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 computeCoinPackPricing = (packs = []) => {
if (!Array.isArray(packs) || !packs.length) {
return {}
}
const packsWithPrice = packs
.filter((pack) => pack?.productId)
.map((pack) => {
const hasValidPrice =
typeof pack.unitAmount === 'number' &&
typeof pack.coinAmount === 'number' &&
pack.coinAmount > 0
return {
...pack,
hasValidPrice,
pricePerCoin: hasValidPrice ? pack.unitAmount / pack.coinAmount : null,
}
})
const basePack = packsWithPrice.reduce((current, pack) => {
if (!pack.hasValidPrice) {
return current
}
if (!current) {
return pack
}
if (
typeof pack.coinAmount === 'number' &&
typeof current.coinAmount === 'number' &&
pack.coinAmount < current.coinAmount
) {
return pack
}
return current
}, null)
const basePricePerCoin =
basePack?.pricePerCoin && isFinite(basePack.pricePerCoin) ? basePack.pricePerCoin : null
const bestDiscountPack = packsWithPrice.reduce((best, pack) => {
if (!pack.hasValidPrice || !basePricePerCoin) {
return best
}
const discount = ((basePricePerCoin - pack.pricePerCoin) / basePricePerCoin) * 100
if (!best || discount > best.discount) {
return { productId: pack.productId, discount }
}
return best
}, null)
const fallbackBase = basePricePerCoin || FALLBACK_BASE_PRICE_PER_COIN
const fallbackBestId =
!bestDiscountPack && packs.length ? packs[packs.length - 1]?.productId : null
return packs.reduce((acc, pack, index) => {
if (!pack?.productId) {
return acc
}
const currentPack = packsWithPrice.find((item) => item.productId === pack.productId)
const computedPricePerCoin = currentPack?.pricePerCoin
const fallbackDiscount =
FALLBACK_DISCOUNT_STEPS[Math.min(index, FALLBACK_DISCOUNT_STEPS.length - 1)] || 0
const pricePerCoin =
typeof computedPricePerCoin === 'number' && isFinite(computedPricePerCoin)
? computedPricePerCoin
: fallbackBase * (1 - fallbackDiscount / 100)
const baseReference = fallbackBase || 1
const rawDiscount =
baseReference && pricePerCoin ? ((baseReference - pricePerCoin) / baseReference) * 100 : 0
const discountPercent = Math.max(0, Number.isFinite(rawDiscount) ? Math.round(rawDiscount) : 0)
const isBasePack =
(basePack && basePack.productId === pack.productId) || (!basePack && index === 0)
acc[pack.productId] = {
currency: pack.currency,
pricePerCoin,
formattedPricePerCoin: formatCurrency(pricePerCoin, pack.currency),
discountPercent,
isBasePack,
isBestValue:
(bestDiscountPack && bestDiscountPack.productId === pack.productId) ||
(!bestDiscountPack && fallbackBestId === pack.productId),
}
return acc
}, {})
}
function CoinPackCard({ pack, selected, pricingDetail, onSelect, staticDiscountPercent }) {
const handleSelect = React.useCallback(() => {
if (typeof onSelect !== 'function' || !pack?.productId) {
return
}
onSelect(pack.productId)
}, [onSelect, pack?.productId])
const formattedPrice = formatCurrency(pack?.unitAmount, pack?.currency)
const perCoinPrice = pricingDetail?.formattedPricePerCoin
const discountPercent = pricingDetail?.discountPercent
const hasStaticDiscount = typeof staticDiscountPercent === 'number'
const effectiveDiscountPercent = hasStaticDiscount ? staticDiscountPercent : discountPercent
const isBaseReference = !hasStaticDiscount && pricingDetail?.isBasePack
const discountLabel = hasStaticDiscount
? staticDiscountPercent > 0
? `-${staticDiscountPercent}%`
: null
: isBaseReference
? 'Pack de base (référence)'
: typeof discountPercent === 'number'
? `-${discountPercent}% vs pack de base`
: null
const packKey = getCoinPackKey(pack)
const details = packKey ? COIN_PACK_DETAILS[packKey] : null
const displayName = details?.label || pack.name
const creditsText = details?.creditsDisplay
return (
<Pressable
onPress={handleSelect}
style={({ pressed }) => [
styles.cardWrapper,
selected && styles.cardWrapperSelected,
pressed && styles.cardWrapperPressed,
]}
accessibilityRole="button"
accessibilityState={{ selected }}
>
<BlurView intensity={20} tint="dark" style={styles.cardBlur}>
<View style={styles.cardHeader}>
{creditsText ? (
<Text style={styles.coinAmountCustom}>{creditsText}</Text>
) : (
<CreditAmount
value={pack?.coinAmount}
style={styles.coinRow}
textStyle={styles.coinAmount}
iconSize={26}
/>
)}
{discountLabel ? (
<View
style={{
position: 'absolute',
right: 0,
top: -5,
}}
>
<View style={[styles.discountBadge, isBaseReference && styles.discountBadgeBase]}>
<Text style={[styles.discountText]}>{discountLabel}</Text>
</View>
</View>
) : null}
<Text style={styles.packName}>{displayName}</Text>
{details?.disclaimer ? (
<Text style={styles.packDisclaimer}>{details.disclaimer}</Text>
) : null}
</View>
<View style={styles.priceBlock}>
{formattedPrice ? <Text style={styles.packPrice}>{formattedPrice}</Text> : null}
</View>
</BlurView>
</Pressable>
)
}
const CoinPackModal = ({ visible, onClose }) => {
const [selectedPackId, setSelectedPackId] = React.useState(null)
const [isProcessing, setIsProcessing] = React.useState(false)
const [errorMessage, setErrorMessage] = React.useState(null)
const modalMaxWidth = isWeb ? WEB_MODAL_MAX_WIDTH : undefined
const { coinPacks, isCatalogLoading, catalogError, refreshCatalog, createCoinPackCheckout } =
useStripe()
const packPricingById = React.useMemo(() => computeCoinPackPricing(coinPacks), [coinPacks])
React.useEffect(() => {
if (!coinPacks.length) {
setSelectedPackId(null)
return
}
setSelectedPackId((current) => {
if (current && coinPacks.some((pack) => pack?.productId && pack.productId === current)) {
return current
}
return coinPacks[0]?.productId || null
})
}, [coinPacks])
React.useEffect(() => {
if (!visible) {
return
}
if (!coinPacks.length && !isCatalogLoading && !catalogError) {
refreshCatalog()
}
}, [visible, coinPacks.length, isCatalogLoading, catalogError, refreshCatalog])
const closeModal = React.useCallback(
({ force = false } = {}) => {
if (isProcessing && !force) {
return
}
setIsProcessing(false)
onClose?.()
},
[isProcessing, onClose]
)
const handleCheckout = React.useCallback(async () => {
if (!selectedPackId) {
return
}
setIsProcessing(true)
setErrorMessage(null)
try {
await createCoinPackCheckout(selectedPackId)
closeModal({ force: true })
} catch (error) {
console.error('[CoinPackModal] checkout error', error)
setErrorMessage(
error?.message || 'Une erreur est survenue lors de la création de la session Stripe.'
)
} finally {
setIsProcessing(false)
}
}, [selectedPackId, createCoinPackCheckout, closeModal])
const handleClose = React.useCallback(() => {
closeModal()
}, [closeModal])
const isLoadingCoinPacks = isCatalogLoading && !coinPacks.length
const combinedErrorMessage = errorMessage || catalogError
const renderContent = () => {
if (isLoadingCoinPacks) {
return (
<View style={styles.loaderContainer}>
<ActivityIndicator color={Palette.white} />
</View>
)
}
if (!coinPacks.length) {
return (
<Text style={styles.emptyState}>Aucun pack de pièces n'est disponible pour le moment.</Text>
)
}
return (
<ScrollView
contentContainerStyle={[
styles.packList,
isWeb ? styles.packListWeb : styles.packListMobile,
]}
showsVerticalScrollIndicator={false}
>
{coinPacks.map((pack, index) => (
<CoinPackCard
key={pack.productId}
pack={pack}
selected={selectedPackId === pack.productId}
pricingDetail={packPricingById[pack.productId]}
onSelect={setSelectedPackId}
staticDiscountPercent={STATIC_DISCOUNTS[index]}
/>
))}
</ScrollView>
)
}
return (
<Modal animationType="slide" transparent visible={visible} onRequestClose={handleClose}>
<View style={styles.overlay}>
<CreateLyricsHeader
containerStyle={{
width: '100%',
maxWidth: modalMaxWidth,
alignSelf: 'center',
borderWidth: isWeb ? 1 : 0,
borderColor: 'rgba(255,255,255,0.16)',
backgroundColor: isWeb ? 'rgba(12, 10, 18, 0.85)' : undefined,
}}
>
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Acheter des pièces</Text>
<Text style={styles.subtitle}>
Choisis un pack et finalise ton achat pour continuer.
</Text>
</View>
{combinedErrorMessage ? (
<Text style={styles.errorText}>{combinedErrorMessage}</Text>
) : null}
<View style={styles.content}>{renderContent()}</View>
<Text style={styles.indicativeNote}>
Tarifs indicatifs : les prix et quantités de jetons sont amenés à évoluer. Les remises
sont affichées vs le pack de base pour mieux valoriser les offres volumineuses.
</Text>
<View style={styles.actions}>
<GradientButton
title={isProcessing ? 'Redirection...' : 'Acheter ce pack'}
onPress={handleCheckout}
disabled={
!selectedPackId || isProcessing || isLoadingCoinPacks || !coinPacks.length
}
/>
<BorderGradientButton title="Fermer" onPress={handleClose} disabled={isProcessing} />
</View>
<Text style={styles.disclaimer}>
Les pièces sont créditées dès que le paiement Stripe est validé.
</Text>
</View>
</CreateLyricsHeader>
</View>
</Modal>
)
}
export default CoinPackModal
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: gutters,
paddingVertical: gutters * 1.5,
},
container: {
gap: 24,
alignItems: 'center',
paddingBottom: gutters,
paddingHorizontal: isWeb ? gutters * 1.5 : 0,
width: '100%',
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : undefined,
},
header: {
gap: 8,
alignItems: 'center',
paddingHorizontal: 16,
},
title: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 22,
color: Palette.white,
textAlign: 'center',
},
subtitle: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 16,
color: Palette.white,
textAlign: 'center',
},
errorText: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: Palette.red,
textAlign: 'center',
paddingHorizontal: 16,
},
content: {
width: '100%',
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : '100%',
alignSelf: 'center',
maxHeight: isWeb ? 420 : 360,
},
loaderContainer: {
width: '100%',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 32,
},
emptyState: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: 'rgba(255, 255, 255, 0.72)',
textAlign: 'center',
paddingHorizontal: 24,
},
packList: {
width: '100%',
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : '100%',
alignSelf: 'center',
gap: gutters,
paddingHorizontal: isWeb ? gutters * 1.5 : 0,
},
packListWeb: {
flexDirection: 'row',
justifyContent: 'center',
flexWrap: 'wrap',
},
packListMobile: {
paddingBottom: 12,
},
cardWrapper: {
flex: 1,
borderRadius: 20,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
backgroundColor: 'rgba(12, 14, 18, 0.45)',
minWidth: isWeb ? 220 : undefined,
},
cardWrapperSelected: {
borderColor: Palette.primary,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.3,
shadowRadius: 18,
elevation: 6,
},
cardWrapperPressed: {
transform: [{ scale: 0.97 }],
},
cardBlur: {
flex: 1,
gap: 16,
paddingVertical: 20,
paddingHorizontal: 24,
justifyContent: 'space-between',
backgroundColor: 'rgba(48, 52, 56, 0.55)',
},
cardHeader: {
gap: 12,
alignItems: 'center',
},
coinRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
coinAmount: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 20,
color: Palette.white,
},
coinAmountCustom: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 20,
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,
},
packName: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 16,
color: Palette.white,
textAlign: 'center',
},
packDescription: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
color: 'rgba(255, 255, 255, 0.72)',
textAlign: 'center',
},
priceBlock: {
gap: 2,
alignItems: 'center',
},
packPrice: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
color: Palette.white,
textAlign: 'center',
},
pricePerCoin: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: 'rgba(255, 255, 255, 0.7)',
textAlign: 'center',
},
discountBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: 'rgba(255, 255, 255, 0.06)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
},
discountBadgeBase: {
borderColor: Palette.primary,
backgroundColor: Palette.transparentPrimary,
},
discountBadgeBest: {
borderColor: Palette.green,
backgroundColor: Palette.transparentGreen,
},
discountText: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: Palette.white,
textAlign: 'center',
},
discountTextBest: {
color: Palette.white,
},
discountHelper: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
color: 'rgba(255, 255, 255, 0.72)',
textAlign: 'center',
},
discountHelperMuted: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
color: 'rgba(255, 255, 255, 0.56)',
textAlign: 'center',
},
tagBestValue: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 10,
backgroundColor: Palette.transparentGreen,
alignSelf: 'center',
position: 'absolute',
top: 10,
},
tagBestValueText: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 12,
color: Palette.white,
},
actions: {
width: '85%',
alignSelf: 'center',
gap: 16,
},
disclaimer: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: 'rgba(255, 255, 255, 0.7)',
textAlign: 'center',
paddingHorizontal: 16,
},
indicativeNote: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
color: 'rgba(255, 255, 255, 0.62)',
textAlign: 'center',
paddingHorizontal: 24,
},
})