clear more tickets

This commit is contained in:
Thomas Demirdjian
2025-11-20 17:44:09 +01:00
parent 70c94c97b4
commit 83ced20dcf
24 changed files with 506 additions and 378 deletions
+40 -37
View File
@@ -67,6 +67,25 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
onClose?.();
}, [onClose]);
const evaluateShouldClose = useCallback(() => {
const video = videoRef.current;
if (!video || hasClosedRef.current) {
return;
}
if (video.ended) {
handleClose();
return;
}
const remaining = video.duration - video.currentTime;
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
handleClose();
}
}, [handleClose]);
useEffect(() => {
let isMounted = true;
@@ -103,44 +122,22 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
}, [url]);
useEffect(() => {
if (!visible) {
if (!visible || !uri) {
return undefined;
}
hasClosedRef.current = false;
let rafId;
const video = videoRef.current;
if (!video || !uri) {
return undefined;
}
const shouldClose = () => {
if (hasClosedRef.current) {
return false;
}
if (video.ended) {
return true;
}
const remaining = video.duration - video.currentTime;
return Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS;
};
const tryHandleClose = () => {
if (shouldClose()) {
handleClose();
}
};
const handleEnded = tryHandleClose;
const handleTimeUpdate = tryHandleClose;
const handlePause = tryHandleClose;
video.addEventListener("ended", handleEnded);
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("pause", handlePause);
const pollId = setInterval(tryHandleClose, CLOSE_POLL_INTERVAL_MS);
video.currentTime = 0;
const attemptPlay = () => {
const video = videoRef.current;
if (!video) {
rafId = requestAnimationFrame(attemptPlay);
return;
}
video.currentTime = 0;
const result = video.play();
if (result?.catch) {
@@ -153,15 +150,17 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
};
attemptPlay();
const pollId = setInterval(evaluateShouldClose, CLOSE_POLL_INTERVAL_MS);
return () => {
video.pause();
video.removeEventListener("ended", handleEnded);
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("pause", handlePause);
if (rafId) {
cancelAnimationFrame(rafId);
}
clearInterval(pollId);
const video = videoRef.current;
video?.pause();
};
}, [handleClose, muted, uri, visible]);
}, [evaluateShouldClose, muted, uri, visible]);
useEffect(() => {
const video = videoRef.current;
@@ -194,6 +193,10 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
loop={false}
muted={muted}
controls={false}
onEnded={handleClose}
onPause={evaluateShouldClose}
onTimeUpdate={evaluateShouldClose}
onError={handleClose}
/>
) : null}
<Pressable onPress={handleClose} style={closeButtonStyle}>
+243 -3
View File
@@ -43,7 +43,119 @@ const formatCurrency = (amount, currency = "eur") => {
return `${normalized.toFixed(2)} ${upperCurrency}`;
};
function CoinPackCard({ pack, selected, onSelect }) {
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 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 }) {
const handleSelect = React.useCallback(() => {
if (typeof onSelect !== "function" || !pack?.productId) {
return;
@@ -52,6 +164,13 @@ function CoinPackCard({ pack, selected, onSelect }) {
}, [onSelect, pack?.productId]);
const formattedPrice = formatCurrency(pack?.unitAmount, pack?.currency);
const perCoinPrice = pricingDetail?.formattedPricePerCoin;
const discountPercent = pricingDetail?.discountPercent;
const discountLabel = pricingDetail?.isBasePack
? "Pack de base (référence)"
: typeof discountPercent === "number"
? `-${discountPercent}% vs pack de base`
: null;
return (
<Pressable
@@ -73,12 +192,53 @@ function CoinPackCard({ pack, selected, onSelect }) {
iconSize={26}
/>
{pack?.name ? <Text style={styles.packName}>{pack.name}</Text> : null}
{pricingDetail?.isBestValue ? (
<View style={styles.tagBestValue}>
<Text style={styles.tagBestValueText}>Meilleure offre</Text>
</View>
) : null}
</View>
{pack?.description ? (
<Text style={styles.packDescription}>{pack.description}</Text>
) : null}
{formattedPrice ? (
<Text style={styles.packPrice}>{formattedPrice}</Text>
<View style={styles.priceBlock}>
{formattedPrice ? (
<Text style={styles.packPrice}>{formattedPrice}</Text>
) : null}
<Text style={styles.pricePerCoin} numberOfLines={1}>
{perCoinPrice
? `${perCoinPrice} / jeton`
: "Valeurs indicatives / jeton"}
</Text>
</View>
{discountLabel ? (
<View style={styles.discountRow}>
<View
style={[
styles.discountBadge,
pricingDetail?.isBestValue && styles.discountBadgeBest,
pricingDetail?.isBasePack && styles.discountBadgeBase,
]}
>
<Text
style={[
styles.discountText,
pricingDetail?.isBestValue && styles.discountTextBest,
]}
>
{discountLabel}
</Text>
</View>
{!pricingDetail?.isBasePack && typeof discountPercent === "number" ? (
<Text style={styles.discountHelper}>
Économie estimée vs pack de base
</Text>
) : (
<Text style={styles.discountHelperMuted}>
Référence prix/jeton
</Text>
)}
</View>
) : null}
</BlurView>
</Pressable>
@@ -98,6 +258,10 @@ const CoinPackModal = ({ visible, onClose }) => {
refreshCatalog,
createCoinPackCheckout,
} = useStripe();
const packPricingById = React.useMemo(
() => computeCoinPackPricing(coinPacks),
[coinPacks],
);
React.useEffect(() => {
if (!coinPacks.length) {
@@ -200,6 +364,7 @@ const CoinPackModal = ({ visible, onClose }) => {
key={pack.productId}
pack={pack}
selected={selectedPackId === pack.productId}
pricingDetail={packPricingById[pack.productId]}
onSelect={setSelectedPackId}
/>
))}
@@ -236,6 +401,12 @@ 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.
</Text>
<View style={styles.actions}>
<GradientButton
title={isProcessing ? "Redirection..." : "Acheter ce pack"}
@@ -393,12 +564,74 @@ const styles = StyleSheet.create({
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",
},
discountRow: {
alignItems: "center",
gap: 6,
},
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,
},
tagBestValueText: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 12,
color: Palette.white,
},
actions: {
width: "85%",
alignSelf: "center",
@@ -411,4 +644,11 @@ const styles = StyleSheet.create({
textAlign: "center",
paddingHorizontal: 16,
},
indicativeNote: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
color: "rgba(255, 255, 255, 0.62)",
textAlign: "center",
paddingHorizontal: 24,
},
});