439 lines
11 KiB
JavaScript
439 lines
11 KiB
JavaScript
import React from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Image,
|
|
Linking,
|
|
Modal,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
import { BlurView } from "expo-blur";
|
|
import { icons } from "../../assets";
|
|
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 { getFunctionsClient } from "../../config/firebase";
|
|
import { isWeb } from "../../hooks/useLayoutType";
|
|
|
|
const WEB_MODAL_MAX_WIDTH = 1000;
|
|
const FUNCTIONS_REGION = "europe-west1";
|
|
const STRIPE_SUCCESS_URL =
|
|
"https://dashboard.stripe.com/test/billing/starter-guide/checkout-success";
|
|
const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL;
|
|
|
|
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}`;
|
|
};
|
|
|
|
function CoinPackCard({ pack, selected, onSelect }) {
|
|
const handleSelect = React.useCallback(() => {
|
|
if (typeof onSelect !== "function" || !pack?.productId) {
|
|
return;
|
|
}
|
|
onSelect(pack.productId);
|
|
}, [onSelect, pack?.productId]);
|
|
|
|
const formattedPrice = formatCurrency(pack?.unitAmount, pack?.currency);
|
|
|
|
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}>
|
|
<View style={styles.coinRow}>
|
|
<Image source={icons.coin} style={styles.coinIcon} />
|
|
<Text style={styles.coinAmount}>{pack?.coinAmount} pièces</Text>
|
|
</View>
|
|
{pack?.name ? <Text style={styles.packName}>{pack.name}</Text> : null}
|
|
</View>
|
|
{pack?.description ? (
|
|
<Text style={styles.packDescription}>{pack.description}</Text>
|
|
) : null}
|
|
{formattedPrice ? (
|
|
<Text style={styles.packPrice}>{formattedPrice}</Text>
|
|
) : null}
|
|
</BlurView>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
const CoinPackModal = ({ visible, onClose }) => {
|
|
const [coinPacks, setCoinPacks] = React.useState([]);
|
|
const [selectedPackId, setSelectedPackId] = React.useState(null);
|
|
const [isLoading, setIsLoading] = React.useState(false);
|
|
const [isProcessing, setIsProcessing] = React.useState(false);
|
|
const [errorMessage, setErrorMessage] = React.useState(null);
|
|
const modalMaxWidth = isWeb ? WEB_MODAL_MAX_WIDTH : undefined;
|
|
|
|
const fetchCoinPacks = React.useCallback(async () => {
|
|
setIsLoading(true);
|
|
setErrorMessage(null);
|
|
try {
|
|
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
|
"subscription-listCoinPacks",
|
|
);
|
|
const { data } = await callable();
|
|
const packs = Array.isArray(data?.packs) ? data.packs : [];
|
|
setCoinPacks(packs);
|
|
setSelectedPackId((current) => {
|
|
if (
|
|
current &&
|
|
packs.some((pack) => pack?.productId && pack.productId === current)
|
|
) {
|
|
return current;
|
|
}
|
|
return packs[0]?.productId || null;
|
|
});
|
|
} catch (error) {
|
|
console.error("[CoinPackModal] fetchCoinPacks error", error);
|
|
setErrorMessage(
|
|
error?.message ||
|
|
"Impossible de charger les packs de pièces. Réessaie plus tard.",
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (!visible) {
|
|
return;
|
|
}
|
|
fetchCoinPacks();
|
|
}, [visible, fetchCoinPacks]);
|
|
|
|
const handleCheckout = React.useCallback(async () => {
|
|
if (!selectedPackId) {
|
|
return;
|
|
}
|
|
|
|
setIsProcessing(true);
|
|
setErrorMessage(null);
|
|
|
|
try {
|
|
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
|
"subscription-createCoinPackCheckoutSession",
|
|
);
|
|
const { data } = await callable({
|
|
productId: selectedPackId,
|
|
returnUrls: {
|
|
successUrl: STRIPE_SUCCESS_URL,
|
|
cancelUrl: STRIPE_CANCEL_URL,
|
|
},
|
|
});
|
|
|
|
const checkoutUrl = data?.url;
|
|
if (!checkoutUrl) {
|
|
throw new Error("Session Stripe introuvable.");
|
|
}
|
|
|
|
if (isWeb) {
|
|
if (typeof window !== "undefined") {
|
|
window.location.assign(checkoutUrl);
|
|
}
|
|
} else {
|
|
const canOpen = await Linking.canOpenURL(checkoutUrl);
|
|
if (!canOpen) {
|
|
throw new Error("Impossible d'ouvrir l'URL de paiement.");
|
|
}
|
|
await Linking.openURL(checkoutUrl);
|
|
}
|
|
} 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, isWeb]);
|
|
|
|
const handleClose = React.useCallback(() => {
|
|
if (isProcessing) {
|
|
return;
|
|
}
|
|
onClose?.();
|
|
}, [isProcessing, onClose]);
|
|
|
|
const renderContent = () => {
|
|
if (isLoading) {
|
|
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) => (
|
|
<CoinPackCard
|
|
key={pack.productId}
|
|
pack={pack}
|
|
selected={selectedPackId === pack.productId}
|
|
onSelect={setSelectedPackId}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
animationType="slide"
|
|
transparent
|
|
visible={visible}
|
|
onRequestClose={handleClose}
|
|
>
|
|
<View style={styles.overlay}>
|
|
<CreateLyricsHeader
|
|
containerStyle={{
|
|
width: "100%",
|
|
maxWidth: modalMaxWidth,
|
|
alignSelf: "center",
|
|
}}
|
|
>
|
|
<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>
|
|
|
|
{errorMessage ? (
|
|
<Text style={styles.errorText}>{errorMessage}</Text>
|
|
) : null}
|
|
|
|
<View style={styles.content}>{renderContent()}</View>
|
|
|
|
<View style={styles.actions}>
|
|
<GradientButton
|
|
title={isProcessing ? "Redirection..." : "Acheter ce pack"}
|
|
onPress={handleCheckout}
|
|
disabled={
|
|
!selectedPackId ||
|
|
isProcessing ||
|
|
isLoading ||
|
|
!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,
|
|
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 ? 8 : 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,
|
|
},
|
|
coinIcon: {
|
|
width: 26,
|
|
height: 26,
|
|
resizeMode: "contain",
|
|
},
|
|
coinAmount: {
|
|
fontFamily: FONT_FAMILY.InterBold,
|
|
fontSize: 20,
|
|
color: Palette.white,
|
|
},
|
|
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",
|
|
},
|
|
packPrice: {
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
fontSize: 18,
|
|
color: Palette.white,
|
|
textAlign: "center",
|
|
},
|
|
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,
|
|
},
|
|
});
|