clear last tickets

This commit is contained in:
Thomas Demirdjian
2025-11-25 14:58:06 +01:00
parent 72852ad92b
commit ee9cc5dccd
31 changed files with 1009 additions and 531 deletions
+94
View File
@@ -0,0 +1,94 @@
import React, { useCallback, useMemo, useState } from "react";
import { Platform, Pressable, StyleSheet } from "react-native";
import CreditAmount from "./CreditAmount";
import CoinPackModal from "./modal/CoinPackModal";
import { useUser } from "../providers/UserDataProvider";
import { Palette } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { openCoinPackModal } from "../utils/coinPackModal";
const MobileCoinBadge = ({
style,
textStyle,
iconSize = 20,
iconPosition = "left",
}) => {
const { currentUID, currentUserData } = useUser() || {};
const [isModalVisible, setModalVisible] = useState(false);
const coinBalance = useMemo(() => {
const value = currentUserData?.coins;
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return 0;
}, [currentUserData?.coins]);
const handlePress = useCallback(() => {
if (Platform.OS === "web") {
openCoinPackModal();
return;
}
setModalVisible(true);
}, []);
const handleClose = useCallback(() => {
setModalVisible(false);
}, []);
if (!currentUID || Platform.OS === "web") {
return null;
}
return (
<>
<Pressable
onPress={handlePress}
accessibilityRole="button"
style={[styles.container, style]}
>
<CreditAmount
value={coinBalance}
style={styles.content}
textStyle={[styles.text, textStyle]}
iconSize={iconSize}
iconPosition={iconPosition}
/>
</Pressable>
<CoinPackModal visible={isModalVisible} onClose={handleClose} />
</>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: "rgba(12, 14, 18, 0.72)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
flexShrink: 1,
},
content: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
text: {
fontSize: 18,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
});
export default MobileCoinBadge;
+69 -57
View File
@@ -45,6 +45,7 @@ const formatCurrency = (amount, currency = "eur") => {
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) {
@@ -61,9 +62,7 @@ const computeCoinPackPricing = (packs = []) => {
return {
...pack,
hasValidPrice,
pricePerCoin: hasValidPrice
? pack.unitAmount / pack.coinAmount
: null,
pricePerCoin: hasValidPrice ? pack.unitAmount / pack.coinAmount : null,
};
});
@@ -146,8 +145,7 @@ const computeCoinPackPricing = (packs = []) => {
discountPercent,
isBasePack,
isBestValue:
(bestDiscountPack &&
bestDiscountPack.productId === pack.productId) ||
(bestDiscountPack && bestDiscountPack.productId === pack.productId) ||
(!bestDiscountPack && fallbackBestId === pack.productId),
};
@@ -155,7 +153,13 @@ const computeCoinPackPricing = (packs = []) => {
}, {});
};
function CoinPackCard({ pack, selected, pricingDetail, onSelect }) {
function CoinPackCard({
pack,
selected,
pricingDetail,
onSelect,
staticDiscountPercent,
}) {
const handleSelect = React.useCallback(() => {
if (typeof onSelect !== "function" || !pack?.productId) {
return;
@@ -166,11 +170,20 @@ function CoinPackCard({ pack, selected, pricingDetail, onSelect }) {
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;
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;
return (
<Pressable
@@ -184,6 +197,11 @@ function CoinPackCard({ pack, selected, pricingDetail, onSelect }) {
accessibilityState={{ selected }}
>
<BlurView intensity={20} tint="dark" style={styles.cardBlur}>
{/*{pricingDetail?.isBestValue ? (*/}
{/* <View style={styles.tagBestValue}>*/}
{/* <Text style={styles.tagBestValueText}>Meilleure offre</Text>*/}
{/* </View>*/}
{/*) : null}*/}
<View style={styles.cardHeader}>
<CreditAmount
value={pack?.coinAmount}
@@ -191,55 +209,39 @@ function CoinPackCard({ pack, selected, pricingDetail, onSelect }) {
textStyle={styles.coinAmount}
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>
{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}
{pack?.name ? <Text style={styles.packName}>{pack.name}</Text> : null}
</View>
<View style={styles.perksList}>
<Text style={styles.perkItem}>
- Diffusion sur mes plates formes de streaming
</Text>
<Text style={styles.perkItem}>
*Eligible au Hit parade Chanson/Video
</Text>
</View>
{pack?.description ? (
<Text style={styles.packDescription}>{pack.description}</Text>
) : null}
<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>
);
@@ -359,13 +361,14 @@ const CoinPackModal = ({ visible, onClose }) => {
]}
showsVerticalScrollIndicator={false}
>
{coinPacks.map((pack) => (
{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>
@@ -564,6 +567,16 @@ const styles = StyleSheet.create({
color: "rgba(255, 255, 255, 0.72)",
textAlign: "center",
},
perksList: {
gap: 6,
alignItems: "center",
},
perkItem: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13,
color: "rgba(255, 255, 255, 0.72)",
textAlign: "center",
},
priceBlock: {
gap: 2,
alignItems: "center",
@@ -580,10 +593,6 @@ const styles = StyleSheet.create({
color: "rgba(255, 255, 255, 0.7)",
textAlign: "center",
},
discountRow: {
alignItems: "center",
gap: 6,
},
discountBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
@@ -626,6 +635,9 @@ const styles = StyleSheet.create({
paddingVertical: 4,
borderRadius: 10,
backgroundColor: Palette.transparentGreen,
alignSelf: "center",
position: "absolute",
top: 10,
},
tagBestValueText: {
fontFamily: FONT_FAMILY.InterSemiBold,
+92
View File
@@ -0,0 +1,92 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import Slider from "../Slider";
const clamp01 = (value) => Math.min(1, Math.max(0, value || 0));
const formatTime = (ms) => {
const totalSeconds = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
const minutes = Math.floor(totalSeconds / 60)
.toString()
.padStart(1, "0");
const seconds = (totalSeconds % 60).toString().padStart(2, "0");
return `${minutes}:${seconds}`;
};
const ProgressSlider = ({
positionMs = 0,
durationMs = 0,
isPlaying = false,
onSeek, // (targetMs) => void | Promise<void>
onPause, // () => void | Promise<void>
onPlay, // () => void | Promise<void>
onSeekStart, // () => void | Promise<void>
onSeekEnd, // () => void | Promise<void>
disabled = false,
}) => {
const wasPlayingRef = useRef(false);
const [isSeeking, setIsSeeking] = useState(false);
const [previewRatio, setPreviewRatio] = useState(null);
const { safePosition, safeDuration, progress } = useMemo(() => {
const dur = Math.max(0, Number(durationMs) || 0);
const pos = Math.max(0, Math.min(dur, Number(positionMs) || 0));
const ratio = dur > 0 ? clamp01(pos / dur) : 0;
return { safePosition: pos, safeDuration: dur, progress: ratio };
}, [durationMs, positionMs]);
const handleSeekStart = useCallback(() => {
if (disabled || !safeDuration) return;
wasPlayingRef.current = !!isPlaying;
setIsSeeking(true);
setPreviewRatio(progress);
if (typeof onSeekStart === "function") {
onSeekStart();
}
if (isPlaying && typeof onPause === "function") {
onPause();
}
}, [disabled, safeDuration, isPlaying, onPause, onSeekStart]);
const handleSeek = useCallback(
(ratio) => {
if (disabled || !safeDuration || typeof onSeek !== "function") return;
const bounded = clamp01(ratio);
setPreviewRatio(bounded);
const targetMs = safeDuration * bounded;
onSeek(targetMs);
},
[disabled, onSeek, safeDuration],
);
const handleSeekEnd = useCallback(() => {
if (disabled || !safeDuration) return;
setIsSeeking(false);
setPreviewRatio(null);
if (typeof onSeekEnd === "function") {
onSeekEnd();
}
if (wasPlayingRef.current && typeof onPlay === "function") {
onPlay();
}
wasPlayingRef.current = false;
}, [disabled, onPlay, onSeekEnd, safeDuration]);
return (
<Slider
value={formatTime(
isSeeking && previewRatio != null
? safeDuration * previewRatio
: safePosition,
)}
maxValue={formatTime(safeDuration)}
progress={progress}
seekEnabled={!disabled && safeDuration > 0}
onSeekStart={handleSeekStart}
onSeek={handleSeek}
onSeekEnd={handleSeekEnd}
/>
);
};
export default ProgressSlider;