feat: modal & payments
This commit is contained in:
+98
-49
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user