fix empty project creation and use credit to generate
This commit is contained in:
@@ -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={{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user