feat: fixes and formatter

This commit is contained in:
2026-01-12 16:01:32 +01:00
parent 85c6084351
commit 11e632acff
353 changed files with 23315 additions and 27361 deletions
+190 -255
View File
@@ -1,4 +1,4 @@
import React from "react";
import React from 'react'
import {
ActivityIndicator,
Modal,
@@ -7,136 +7,119 @@ import {
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";
} 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 WEB_MODAL_MAX_WIDTH = 820
const formatCurrency = (amount, currency = 'eur') => {
if (typeof amount !== 'number') {
return null
}
const normalized = amount / 100;
const normalized = amount / 100
const upperCurrency =
typeof currency === "string" && currency.trim()
? currency.trim().toUpperCase()
: "EUR";
typeof currency === 'string' && currency.trim() ? currency.trim().toUpperCase() : 'EUR'
if (typeof Intl !== "undefined" && Intl.NumberFormat) {
if (typeof Intl !== 'undefined' && Intl.NumberFormat) {
try {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: upperCurrency,
minimumFractionDigits: 2,
}).format(normalized);
}).format(normalized)
} catch (_error) {}
}
return `${normalized.toFixed(2)} ${upperCurrency}`;
};
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 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 computeCoinPackPricing = (packs = []) => {
if (!Array.isArray(packs) || !packs.length) {
return {};
return {}
}
const packsWithPrice = packs
.filter((pack) => pack?.productId)
.map((pack) => {
const hasValidPrice =
typeof pack.unitAmount === "number" &&
typeof pack.coinAmount === "number" &&
pack.coinAmount > 0;
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;
return current
}
if (!current) {
return pack;
return pack
}
if (
typeof pack.coinAmount === "number" &&
typeof current.coinAmount === "number" &&
typeof pack.coinAmount === 'number' &&
typeof current.coinAmount === 'number' &&
pack.coinAmount < current.coinAmount
) {
return pack;
return pack
}
return current;
}, null);
return current
}, null)
const basePricePerCoin =
basePack?.pricePerCoin && isFinite(basePack.pricePerCoin)
? basePack.pricePerCoin
: null;
basePack?.pricePerCoin && isFinite(basePack.pricePerCoin) ? basePack.pricePerCoin : null
const bestDiscountPack = packsWithPrice.reduce((best, pack) => {
if (!pack.hasValidPrice || !basePricePerCoin) {
return best;
return best
}
const discount =
((basePricePerCoin - pack.pricePerCoin) / basePricePerCoin) * 100;
const discount = ((basePricePerCoin - pack.pricePerCoin) / basePricePerCoin) * 100
if (!best || discount > best.discount) {
return { productId: pack.productId, discount };
return { productId: pack.productId, discount }
}
return best;
}, null);
return best
}, null)
const fallbackBase = basePricePerCoin || FALLBACK_BASE_PRICE_PER_COIN;
const fallbackBase = basePricePerCoin || FALLBACK_BASE_PRICE_PER_COIN
const fallbackBestId =
!bestDiscountPack && packs.length
? packs[packs.length - 1]?.productId
: null;
!bestDiscountPack && packs.length ? packs[packs.length - 1]?.productId : null
return packs.reduce((acc, pack, index) => {
if (!pack?.productId) {
return acc;
return acc
}
const currentPack = packsWithPrice.find(
(item) => item.productId === pack.productId,
);
const computedPricePerCoin = currentPack?.pricePerCoin;
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;
FALLBACK_DISCOUNT_STEPS[Math.min(index, FALLBACK_DISCOUNT_STEPS.length - 1)] || 0
const pricePerCoin =
typeof computedPricePerCoin === "number" && isFinite(computedPricePerCoin)
typeof computedPricePerCoin === 'number' && isFinite(computedPricePerCoin)
? computedPricePerCoin
: fallbackBase * (1 - fallbackDiscount / 100);
: fallbackBase * (1 - fallbackDiscount / 100)
const baseReference = fallbackBase || 1;
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,
);
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);
(basePack && basePack.productId === pack.productId) || (!basePack && index === 0)
acc[pack.productId] = {
currency: pack.currency,
@@ -147,43 +130,35 @@ const computeCoinPackPricing = (packs = []) => {
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;
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"
? 'Pack de base (référence)'
: typeof discountPercent === 'number'
? `-${discountPercent}% vs pack de base`
: null;
: null
return (
<Pressable
@@ -212,17 +187,12 @@ function CoinPackCard({
{discountLabel ? (
<View
style={{
position: "absolute",
position: 'absolute',
right: 0,
top: -5,
}}
>
<View
style={[
styles.discountBadge,
isBaseReference && styles.discountBadgeBase,
]}
>
<View style={[styles.discountBadge, isBaseReference && styles.discountBadgeBase]}>
<Text style={[styles.discountText]}>{discountLabel}</Text>
</View>
</View>
@@ -230,103 +200,83 @@ function CoinPackCard({
{pack?.name ? <Text style={styles.packName}>{pack.name}</Text> : null}
</View>
<View style={styles.priceBlock}>
{formattedPrice ? (
<Text style={styles.packPrice}>{formattedPrice}</Text>
) : null}
{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 [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],
);
const { coinPacks, isCatalogLoading, catalogError, refreshCatalog, createCoinPackCheckout } =
useStripe()
const packPricingById = React.useMemo(() => computeCoinPackPricing(coinPacks), [coinPacks])
React.useEffect(() => {
if (!coinPacks.length) {
setSelectedPackId(null);
return;
setSelectedPackId(null)
return
}
setSelectedPackId((current) => {
if (
current &&
coinPacks.some((pack) => pack?.productId && pack.productId === current)
) {
return current;
if (current && coinPacks.some((pack) => pack?.productId && pack.productId === current)) {
return current
}
return coinPacks[0]?.productId || null;
});
}, [coinPacks]);
return coinPacks[0]?.productId || null
})
}, [coinPacks])
React.useEffect(() => {
if (!visible) {
return;
return
}
if (!coinPacks.length && !isCatalogLoading && !catalogError) {
refreshCatalog();
refreshCatalog()
}
}, [
visible,
coinPacks.length,
isCatalogLoading,
catalogError,
refreshCatalog,
]);
}, [visible, coinPacks.length, isCatalogLoading, catalogError, refreshCatalog])
const closeModal = React.useCallback(
({ force = false } = {}) => {
if (isProcessing && !force) {
return;
return
}
setIsProcessing(false);
onClose?.();
setIsProcessing(false)
onClose?.()
},
[isProcessing, onClose],
);
[isProcessing, onClose]
)
const handleCheckout = React.useCallback(async () => {
if (!selectedPackId) {
return;
return
}
setIsProcessing(true);
setErrorMessage(null);
setIsProcessing(true)
setErrorMessage(null)
try {
await createCoinPackCheckout(selectedPackId);
closeModal({ force: true });
await createCoinPackCheckout(selectedPackId)
closeModal({ force: true })
} catch (error) {
console.error("[CoinPackModal] checkout error", error);
console.error('[CoinPackModal] checkout error', error)
setErrorMessage(
error?.message ||
"Une erreur est survenue lors de la création de la session Stripe.",
);
error?.message || 'Une erreur est survenue lors de la création de la session Stripe.'
)
} finally {
setIsProcessing(false);
setIsProcessing(false)
}
}, [selectedPackId, createCoinPackCheckout, closeModal]);
}, [selectedPackId, createCoinPackCheckout, closeModal])
const handleClose = React.useCallback(() => {
closeModal();
}, [closeModal]);
closeModal()
}, [closeModal])
const isLoadingCoinPacks = isCatalogLoading && !coinPacks.length;
const combinedErrorMessage = errorMessage || catalogError;
const isLoadingCoinPacks = isCatalogLoading && !coinPacks.length
const combinedErrorMessage = errorMessage || catalogError
const renderContent = () => {
if (isLoadingCoinPacks) {
@@ -334,15 +284,13 @@ const CoinPackModal = ({ visible, onClose }) => {
<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>
);
<Text style={styles.emptyState}>Aucun pack de pièces n'est disponible pour le moment.</Text>
)
}
return (
@@ -364,25 +312,20 @@ const CoinPackModal = ({ visible, onClose }) => {
/>
))}
</ScrollView>
);
};
)
}
return (
<Modal
animationType="slide"
transparent
visible={visible}
onRequestClose={handleClose}
>
<Modal animationType="slide" transparent visible={visible} onRequestClose={handleClose}>
<View style={styles.overlay}>
<CreateLyricsHeader
containerStyle={{
width: "100%",
width: '100%',
maxWidth: modalMaxWidth,
alignSelf: "center",
alignSelf: 'center',
borderWidth: isWeb ? 1 : 0,
borderColor: "rgba(255,255,255,0.16)",
backgroundColor: isWeb ? "rgba(12, 10, 18, 0.85)" : undefined,
borderColor: 'rgba(255,255,255,0.16)',
backgroundColor: isWeb ? 'rgba(12, 10, 18, 0.85)' : undefined,
}}
>
<View style={styles.container}>
@@ -400,27 +343,19 @@ const CoinPackModal = ({ visible, onClose }) => {
<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.
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"}
title={isProcessing ? 'Redirection...' : 'Acheter ce pack'}
onPress={handleCheckout}
disabled={
!selectedPackId ||
isProcessing ||
isLoadingCoinPacks ||
!coinPacks.length
!selectedPackId || isProcessing || isLoadingCoinPacks || !coinPacks.length
}
/>
<BorderGradientButton
title="Fermer"
onPress={handleClose}
disabled={isProcessing}
/>
<BorderGradientButton title="Fermer" onPress={handleClose} disabled={isProcessing} />
</View>
<Text style={styles.disclaimer}>
@@ -430,81 +365,81 @@ const CoinPackModal = ({ visible, onClose }) => {
</CreateLyricsHeader>
</View>
</Modal>
);
};
)
}
export default CoinPackModal;
export default CoinPackModal
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: gutters,
paddingVertical: gutters * 1.5,
},
container: {
gap: 24,
alignItems: "center",
alignItems: 'center',
paddingBottom: gutters,
paddingHorizontal: isWeb ? gutters * 1.5 : 0,
width: "100%",
width: '100%',
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : undefined,
},
header: {
gap: 8,
alignItems: "center",
alignItems: 'center',
paddingHorizontal: 16,
},
title: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 22,
color: Palette.white,
textAlign: "center",
textAlign: 'center',
},
subtitle: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 16,
color: Palette.white,
textAlign: "center",
textAlign: 'center',
},
errorText: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: Palette.red,
textAlign: "center",
textAlign: 'center',
paddingHorizontal: 16,
},
content: {
width: "100%",
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : "100%",
alignSelf: "center",
width: '100%',
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : '100%',
alignSelf: 'center',
maxHeight: isWeb ? 420 : 360,
},
loaderContainer: {
width: "100%",
alignItems: "center",
justifyContent: "center",
width: '100%',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 32,
},
emptyState: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: "rgba(255, 255, 255, 0.72)",
textAlign: "center",
color: 'rgba(255, 255, 255, 0.72)',
textAlign: 'center',
paddingHorizontal: 24,
},
packList: {
width: "100%",
maxWidth: isWeb ? WEB_MODAL_MAX_WIDTH : "100%",
alignSelf: "center",
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",
flexDirection: 'row',
justifyContent: 'center',
flexWrap: 'wrap',
},
packListMobile: {
paddingBottom: 12,
@@ -512,15 +447,15 @@ const styles = StyleSheet.create({
cardWrapper: {
flex: 1,
borderRadius: 20,
overflow: "hidden",
overflow: 'hidden',
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
backgroundColor: "rgba(12, 14, 18, 0.45)",
borderColor: 'rgba(255, 255, 255, 0.12)',
backgroundColor: 'rgba(12, 14, 18, 0.45)',
minWidth: isWeb ? 220 : undefined,
},
cardWrapperSelected: {
borderColor: Palette.primary,
shadowColor: "#000",
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.3,
shadowRadius: 18,
@@ -534,16 +469,16 @@ const styles = StyleSheet.create({
gap: 16,
paddingVertical: 20,
paddingHorizontal: 24,
justifyContent: "space-between",
backgroundColor: "rgba(48, 52, 56, 0.55)",
justifyContent: 'space-between',
backgroundColor: 'rgba(48, 52, 56, 0.55)',
},
cardHeader: {
gap: 12,
alignItems: "center",
alignItems: 'center',
},
coinRow: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
coinAmount: {
@@ -555,37 +490,37 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 16,
color: Palette.white,
textAlign: "center",
textAlign: 'center',
},
packDescription: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
color: "rgba(255, 255, 255, 0.72)",
textAlign: "center",
color: 'rgba(255, 255, 255, 0.72)',
textAlign: 'center',
},
priceBlock: {
gap: 2,
alignItems: "center",
alignItems: 'center',
},
packPrice: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
color: Palette.white,
textAlign: "center",
textAlign: 'center',
},
pricePerCoin: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: "rgba(255, 255, 255, 0.7)",
textAlign: "center",
color: 'rgba(255, 255, 255, 0.7)',
textAlign: 'center',
},
discountBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: "rgba(255, 255, 255, 0.06)",
backgroundColor: 'rgba(255, 255, 255, 0.06)',
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
borderColor: 'rgba(255, 255, 255, 0.12)',
},
discountBadgeBase: {
borderColor: Palette.primary,
@@ -599,7 +534,7 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: Palette.white,
textAlign: "center",
textAlign: 'center',
},
discountTextBest: {
color: Palette.white,
@@ -607,22 +542,22 @@ const styles = StyleSheet.create({
discountHelper: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
color: "rgba(255, 255, 255, 0.72)",
textAlign: "center",
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",
color: 'rgba(255, 255, 255, 0.56)',
textAlign: 'center',
},
tagBestValue: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 10,
backgroundColor: Palette.transparentGreen,
alignSelf: "center",
position: "absolute",
alignSelf: 'center',
position: 'absolute',
top: 10,
},
tagBestValueText: {
@@ -631,22 +566,22 @@ const styles = StyleSheet.create({
color: Palette.white,
},
actions: {
width: "85%",
alignSelf: "center",
width: '85%',
alignSelf: 'center',
gap: 16,
},
disclaimer: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: "rgba(255, 255, 255, 0.7)",
textAlign: "center",
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",
color: 'rgba(255, 255, 255, 0.62)',
textAlign: 'center',
paddingHorizontal: 24,
},
});
})