637 lines
18 KiB
JavaScript
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,
|
|
},
|
|
})
|