feat: modal & payments

This commit is contained in:
2025-11-17 14:51:04 +01:00
parent 93f2711574
commit c7cbd85088
3 changed files with 144 additions and 72 deletions
+98 -49
View File
@@ -1,17 +1,21 @@
import { PortalProvider } from "@gorhom/portal"; import { PortalProvider } from "@gorhom/portal";
import React, { useState } from "react"; import React, { useState } from "react";
import { Alert, Platform, Text } from "react-native"; import { Alert, Platform, StyleSheet, Text, View } from "react-native";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { View } from "react-native"; import { Fonts, Palette, gutters } from "../styles";
import { Fonts, gutters } from "../styles";
import GradientButton from "./GradientButton"; import GradientButton from "./GradientButton";
import { LinearGradient } from "./LinearGradient/LinearGradient";
import Overlay from "./Overlay"; import Overlay from "./Overlay";
const WebAlertModal = ({ title, description, options }) => { const WebAlertModal = ({ title, description, options }) => {
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
const confirmOption = options?.find(({ style }) => style !== "cancel"); const confirmOption = options?.find(({ style }) => style !== "cancel");
const cancelOption = options?.find(({ style }) => style === "cancel"); const cancelOption = options?.find(({ style }) => style === "cancel");
const hasSecondaryAction = Boolean(cancelOption);
const buttonContainerStyle = hasSecondaryAction
? styles.actionButton
: styles.singleActionButton;
const onConfirm = () => { const onConfirm = () => {
setVisible(false); setVisible(false);
@@ -25,63 +29,46 @@ const WebAlertModal = ({ title, description, options }) => {
return ( return (
<PortalProvider> <PortalProvider>
<Overlay isVisible={visible}> <Overlay
<BlurView isVisible={visible}
intensity={100} contentContainerStyle={styles.overlayContent}
tint="dark"
style={{
width: "60%",
maxWidth: 450,
minWidth: 300,
padding: "2.5%",
borderRadius: 12,
// ...Style.containerModal,
}}
> >
<Text <View style={styles.modalWrapper}>
style={{ <LinearGradient
...Fonts({ colors={["rgba(255,255,255,0.18)", "rgba(255,255,255,0.04)"]}
type: "default", start={{ x: 0, y: 0 }}
style: { end={{ x: 1, y: 1 }}
textAlign: "center", style={styles.modalBorder}
marginBottom: gutters / 2,
},
fontSize: 3,
}),
}}
> >
{title} <BlurView intensity={100} tint="dark" style={styles.modalContainer}>
</Text> <Text style={styles.title}>{title}</Text>
<Text <Text style={styles.description}>{description}</Text>
style={{ <View style={styles.divider} />
...Fonts({ <View
type: "default", style={hasSecondaryAction ? styles.actionsRow : styles.actions}
style: {
textAlign: "center",
marginBottom: gutters / 2,
},
}),
}}
> >
{description}
</Text>
<View style={{ flexDirection: "row", gap: 10 }}>
<GradientButton
title={confirmOption?.text || "OK"}
onPress={onConfirm}
containerStyle={{ flex: 1 }}
/>
{cancelOption && ( {cancelOption && (
<GradientButton <GradientButton
title={cancelOption.text} title={cancelOption.text}
onPress={onCancel} onPress={onCancel}
colors={["#666666", "#333333"]} colors={[
containerStyle={{ flex: 1 }} "rgba(255,255,255,0.16)",
"rgba(255,255,255,0.08)",
]}
textStyle={styles.secondaryButtonText}
gradientStyle={styles.secondaryButtonGradient}
containerStyle={buttonContainerStyle}
/> />
)} )}
<GradientButton
title={confirmOption?.text || "OK"}
onPress={onConfirm}
containerStyle={buttonContainerStyle}
/>
</View> </View>
</BlurView> </BlurView>
</LinearGradient>
</View>
</Overlay> </Overlay>
</PortalProvider> </PortalProvider>
); );
@@ -126,3 +113,65 @@ export const showPremiumRequiredAlert = () =>
); );
export default alert; export default alert;
const styles = StyleSheet.create({
overlayContent: {
justifyContent: "center",
alignItems: "center",
},
modalWrapper: {
width: "90%",
maxWidth: 460,
paddingHorizontal: 12,
},
modalBorder: {
borderRadius: 24,
padding: 1,
},
modalContainer: {
borderRadius: 23,
paddingHorizontal: gutters * 1.8,
paddingVertical: gutters,
backgroundColor: "rgba(15, 12, 20, 0.9)",
overflow: "hidden",
gap: gutters * 0.75,
},
title: Fonts({
type: "mainTitle",
fontSize: 3,
style: { textAlign: "center" },
}),
description: Fonts({
type: "default",
color: Palette.gray,
fontSize: 2,
style: { lineHeight: 22, textAlign: "center" },
}),
divider: {
height: 1,
backgroundColor: "rgba(255,255,255,0.08)",
marginVertical: 0,
},
actionsRow: {
flexDirection: "row",
gap: gutters,
width: "100%",
},
actions: {
width: "100%",
gap: gutters,
},
actionButton: {
flex: 1,
},
singleActionButton: {
width: "100%",
},
secondaryButtonGradient: {
borderWidth: 1,
borderColor: "rgba(255,255,255,0.2)",
},
secondaryButtonText: {
color: Palette.gray,
},
});
+32 -11
View File
@@ -79,6 +79,18 @@ function SubscriptionCard({ plan, selected, onSelect }) {
Number.isFinite(plan.coinsPerMonth) Number.isFinite(plan.coinsPerMonth)
? Math.round(plan.coinsPerMonth) ? Math.round(plan.coinsPerMonth)
: null; : null;
const songsPerMonth =
coinsPerMonth !== null
? Math.floor(coinsPerMonth / CREDITS_PER_MUSIC)
: null;
const songsPerMonthLabel =
songsPerMonth === null
? null
: songsPerMonth >= 1
? `${songsPerMonth} ${
songsPerMonth > 1 ? "musiques" : "musique"
} / mois`
: "Moins d'une musique / mois";
const planBadgeKey = getPlanKeyForBadge(plan); const planBadgeKey = getPlanKeyForBadge(plan);
const planBadgeSource = const planBadgeSource =
planBadgeKey && subBadges[planBadgeKey] ? subBadges[planBadgeKey] : null; planBadgeKey && subBadges[planBadgeKey] ? subBadges[planBadgeKey] : null;
@@ -145,6 +157,9 @@ function SubscriptionCard({ plan, selected, onSelect }) {
<Text style={styles.coinsPerMonthSuffix}>/ mois</Text> <Text style={styles.coinsPerMonthSuffix}>/ mois</Text>
</View> </View>
) : null} ) : null}
{songsPerMonthLabel ? (
<Text style={styles.songsPerMonth}>{songsPerMonthLabel}</Text>
) : null}
</View> </View>
) : null} ) : null}
@@ -225,7 +240,7 @@ const getPlanKeyForBadge = (plan) => {
} }
const match = Object.entries(PLAN_SYNONYMS).find(([, variants]) => const match = Object.entries(PLAN_SYNONYMS).find(([, variants]) =>
variants.some((variant) => label.includes(variant)), variants.some((variant) => label.includes(variant))
); );
return match ? match[0] : null; return match ? match[0] : null;
@@ -270,7 +285,7 @@ const normalizePlans = (plans = [], periodKey) => {
})) }))
.sort( .sort(
(planA, planB) => (planA, planB) =>
getPlanPriority(planA, periodKey) - getPlanPriority(planB, periodKey), getPlanPriority(planA, periodKey) - getPlanPriority(planB, periodKey)
); );
}; };
@@ -285,6 +300,7 @@ const PAGE_BACKGROUND_COLOR = "#303438";
const SUBSCRIPTION_DISCLAIMER = const SUBSCRIPTION_DISCLAIMER =
"Résiliable en un clic à tout moment. Les prix sont indiqués TTC. Contacte-nous pour des besoins spécifiques (facturation annuelle, volume, offres éducation)."; "Résiliable en un clic à tout moment. Les prix sont indiqués TTC. Contacte-nous pour des besoins spécifiques (facturation annuelle, volume, offres éducation).";
const CARD_MIN_HEIGHT = isWeb ? 320 : 240; const CARD_MIN_HEIGHT = isWeb ? 320 : 240;
const CREDITS_PER_MUSIC = 8;
export default function Payments() { export default function Payments() {
const route = useRoute(); const route = useRoute();
@@ -319,7 +335,7 @@ export default function Payments() {
monthly: normalizePlans(subscriptions?.monthly, "monthly"), monthly: normalizePlans(subscriptions?.monthly, "monthly"),
annual: normalizePlans(subscriptions?.annual, "annual"), annual: normalizePlans(subscriptions?.annual, "annual"),
}), }),
[subscriptions?.monthly, subscriptions?.annual], [subscriptions?.monthly, subscriptions?.annual]
); );
const hasAnyPlan = const hasAnyPlan =
(normalizedPlans?.monthly?.length || 0) > 0 || (normalizedPlans?.monthly?.length || 0) > 0 ||
@@ -341,12 +357,12 @@ export default function Payments() {
if (shouldApplyPack && normalizedPlans) { if (shouldApplyPack && normalizedPlans) {
const desired = initialSubscriptionPack.trim().toLowerCase(); const desired = initialSubscriptionPack.trim().toLowerCase();
for (const [periodKey, mapping] of Object.entries( for (const [periodKey, mapping] of Object.entries(
PACK_PRICE_ID_BY_PERIOD, PACK_PRICE_ID_BY_PERIOD
)) { )) {
const candidateId = mapping[desired]; const candidateId = mapping[desired];
if (!candidateId) continue; if (!candidateId) continue;
const exists = (normalizedPlans[periodKey] || []).some( const exists = (normalizedPlans[periodKey] || []).some(
(plan) => plan?.priceId === candidateId, (plan) => plan?.priceId === candidateId
); );
if (exists) { if (exists) {
matchedPeriod = periodKey; matchedPeriod = periodKey;
@@ -367,7 +383,7 @@ export default function Payments() {
} }
const alreadySelected = periodPlans.some( const alreadySelected = periodPlans.some(
(plan) => plan.priceId === next[key], (plan) => plan.priceId === next[key]
); );
if (shouldApplyPack && matchedPeriod === null) { if (shouldApplyPack && matchedPeriod === null) {
@@ -413,7 +429,7 @@ export default function Payments() {
})); }));
setProcessingPriceId(null); setProcessingPriceId(null);
}, },
[billingPeriod], [billingPeriod]
); );
const handleCheckout = React.useCallback(async () => { const handleCheckout = React.useCallback(async () => {
@@ -429,7 +445,7 @@ export default function Payments() {
console.error("[Payments] checkout error", error); console.error("[Payments] checkout error", error);
setErrorMessage( setErrorMessage(
error?.message || error?.message ||
"Une erreur est survenue lors de la création de la session Stripe.", "Une erreur est survenue lors de la création de la session Stripe."
); );
} finally { } finally {
setProcessingPriceId(null); setProcessingPriceId(null);
@@ -464,14 +480,14 @@ export default function Payments() {
const currentPlanCount = currentPlans.length; const currentPlanCount = currentPlans.length;
const mobileActionSafePadding = React.useMemo( const mobileActionSafePadding = React.useMemo(
() => Math.max(insets.bottom, gutters), () => Math.max(insets.bottom, gutters),
[insets.bottom], [insets.bottom]
); );
const mobileListContentInset = React.useMemo( const mobileListContentInset = React.useMemo(
() => ({ () => ({
paddingBottom: mobileActionSafePadding + gutters * 3, paddingBottom: mobileActionSafePadding + gutters * 3,
}), }),
[mobileActionSafePadding], [mobileActionSafePadding]
); );
const renderHeaderSection = React.useCallback(() => { const renderHeaderSection = React.useCallback(() => {
@@ -535,7 +551,7 @@ export default function Payments() {
/> />
</View> </View>
), ),
[handleSelect, selectedPriceId], [handleSelect, selectedPriceId]
); );
const renderMobileEmptyComponent = React.useCallback(() => { const renderMobileEmptyComponent = React.useCallback(() => {
@@ -909,6 +925,11 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
color: Palette.primary, color: Palette.primary,
}, },
songsPerMonth: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
color: "rgba(255, 255, 255, 0.75)",
},
priceValue: { priceValue: {
fontFamily: FONT_FAMILY.InterBold, fontFamily: FONT_FAMILY.InterBold,
fontSize: isWeb ? 22 : 20, fontSize: isWeb ? 22 : 20,
+4 -2
View File
@@ -31,7 +31,6 @@ import { gutters, size } from "../../styles/Style";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import CreditAmount from "../../components/CreditAmount";
const MUSIC_GENERATION_COIN_COST = 8; const MUSIC_GENERATION_COIN_COST = 8;
const SONG_OPTIONS_PER_GENERATION = 2; const SONG_OPTIONS_PER_GENERATION = 2;
@@ -528,7 +527,10 @@ const RegenerateModal = ({ visible, onClose, onConfirm }) => {
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions();
const isCompactWidth = width < 420; const isCompactWidth = width < 420;
const horizontalPadding = Math.max(isCompactWidth ? 16 : gutters, 12); const horizontalPadding = Math.max(isCompactWidth ? 16 : gutters, 12);
const verticalPadding = Math.max(isCompactWidth ? gutters : gutters * 1.5, 12); const verticalPadding = Math.max(
isCompactWidth ? gutters : gutters * 1.5,
12
);
const availableWidth = Math.max(width - horizontalPadding * 2, 0); const availableWidth = Math.max(width - horizontalPadding * 2, 0);
const contentWidth = const contentWidth =
availableWidth > 0 availableWidth > 0