clear more tickets
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user