fix empty project creation and use credit to generate

This commit is contained in:
Thomas Demirdjian
2025-11-04 15:59:49 +01:00
parent 7d93be845b
commit 4a45924ee0
10 changed files with 1096 additions and 487 deletions
+11 -6
View File
@@ -28,9 +28,11 @@ const BorderGradientButton = ({
disabled = false,
maxWidth = null,
size = "medium",
height = null,
}) => {
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
const buttonHeight = HEIGHT_BY_SIZE[resolvedSize];
const buttonHeight =
typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize];
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
const iconSize = resolvedSize === "small" ? 14 : 16;
@@ -38,11 +40,14 @@ const BorderGradientButton = ({
<Pressable
onPress={onPress}
disabled={disabled}
style={{
maxWidth: maxWidth ? maxWidth : null,
...containerStyle,
opacity: disabled ? 0.6 : 1,
}}
style={[
{
maxWidth: maxWidth ? maxWidth : null,
height: buttonHeight,
opacity: disabled ? 0.6 : 1,
},
containerStyle,
]}
>
<BorderGradient
gradientProps={{
+21 -14
View File
@@ -29,31 +29,38 @@ const GradientButton = ({
size = "medium",
textStyle = {},
gradientStyle = {},
height = null,
}) => {
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
const buttonHeight = HEIGHT_BY_SIZE[resolvedSize];
const buttonHeight =
typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize];
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
return (
<Pressable
onPress={onPress}
disabled={disabled}
style={{
...containerStyle,
opacity: disabled ? 0.6 : 1,
maxWidth: maxWidth ? maxWidth : null,
}}
style={[
{
opacity: disabled ? 0.6 : 1,
maxWidth: maxWidth ? maxWidth : null,
height: buttonHeight,
},
containerStyle,
]}
>
<LinearGradient
colors={colors}
style={{
...Style.containerCenter,
...Style.containerRow,
height: buttonHeight,
borderRadius: 14,
gap: 10,
...gradientStyle,
}}
style={[
{
...Style.containerCenter,
...Style.containerRow,
height: buttonHeight,
borderRadius: 14,
gap: 10,
},
gradientStyle,
]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
{...props}
+438
View File
@@ -0,0 +1,438 @@
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,
},
});