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 ( [ styles.cardWrapper, selected && styles.cardWrapperSelected, pressed && styles.cardWrapperPressed, ]} accessibilityRole="button" accessibilityState={{ selected }} > {creditsText ? ( {creditsText} ) : ( )} {discountLabel ? ( {discountLabel} ) : null} {displayName} {details?.disclaimer ? ( {details.disclaimer} ) : null} {formattedPrice ? {formattedPrice} : null} ) } 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 ( ) } if (!coinPacks.length) { return ( Aucun pack de pièces n'est disponible pour le moment. ) } return ( {coinPacks.map((pack, index) => ( ))} ) } return ( Acheter des pièces Choisis un pack et finalise ton achat pour continuer. {combinedErrorMessage ? ( {combinedErrorMessage} ) : null} {renderContent()} 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. Les pièces sont créditées dès que le paiement Stripe est validé. ) } 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, }, })