diff --git a/functions/src/payouts.js b/functions/src/payouts.js index 91f2f16..d94f2d7 100644 --- a/functions/src/payouts.js +++ b/functions/src/payouts.js @@ -13,6 +13,33 @@ const { sendNotification } = require("./notifications"); const DISTRIBUTION_REVENUE_BASELINE = 1000; const DISTRIBUTION_RATIO = 0.3; +const ACTIVE_SUBSCRIPTION_STATUSES = new Set(["active"]); + +const hasActiveSubscription = (userData) => { + if (!userData || typeof userData !== "object") { + return false; + } + const isPremium = userData.isPremium === true; + if (!isPremium) { + return false; + } + const status = + typeof userData.stripeSubscriptionStatus === "string" + ? userData.stripeSubscriptionStatus.trim().toLowerCase() + : null; + if (status && ACTIVE_SUBSCRIPTION_STATUSES.has(status)) { + return true; + } + const billingPeriod = + typeof userData.premiumBillingPeriod === "string" + ? userData.premiumBillingPeriod.trim().toLowerCase() + : null; + if (billingPeriod === "monthly" || billingPeriod === "annual") { + // Fallback: billing period is set only for active subscribers. + return true; + } + return false; +}; exports.distributeMonthlyPayouts = onSchedule( { @@ -51,6 +78,51 @@ exports.distributeMonthlyPayouts = onSchedule( .orderBy(["streams"], ["desc"]) .value(); + const userEligibilityMap = {}; + const userIds = _.uniq( + entries.map((entry) => entry.userId).filter((userId) => !!userId), + ); + + if (userIds.length) { + const chunkSize = 300; + for (let index = 0; index < userIds.length; index += chunkSize) { + const chunk = userIds.slice(index, index + chunkSize); + const snapshots = await Promise.all( + chunk.map(async (userId) => { + try { + return await refList.users.doc(userId).get(); + } catch (error) { + console.warn( + "[distributeMonthlyPayouts] Unable to load user profile", + { + userId, + error: error?.message || String(error), + }, + ); + return null; + } + }), + ); + + snapshots.forEach((snapshot, snapshotIndex) => { + const userId = chunk[snapshotIndex]; + if (snapshot?.exists) { + userEligibilityMap[userId] = hasActiveSubscription( + snapshot.data(), + ); + } else { + userEligibilityMap[userId] = false; + } + }); + } + } + + const eligibleEntries = entries.filter( + (entry) => + !!entry.userId && userEligibilityMap[entry.userId] === true, + ); + const eligibleTotalStreams = _.sumBy(eligibleEntries, "streams"); + const payoutsTotalStreamsFromDocs = _.sumBy(entries, "streams"); const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null; let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0)); @@ -63,9 +135,9 @@ exports.distributeMonthlyPayouts = onSchedule( 2, ); - let allocations = _.map(entries, (entry) => { - if (!totalStreams) return 0; - const rawAmount = (payoutPool * entry.streams) / totalStreams; + let allocations = _.map(eligibleEntries, (entry) => { + if (!eligibleTotalStreams) return 0; + const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams; return _.round(rawAmount, 2); }); @@ -77,12 +149,14 @@ exports.distributeMonthlyPayouts = onSchedule( } } - const payouts = entries.map((entry, idx) => ({ + const payouts = eligibleEntries.map((entry, idx) => ({ rank: idx + 1, projectId: entry.projectId, userId: entry.userId, streams: entry.streams, - share: totalStreams ? _.round(entry.streams / totalStreams, 6) : 0, + share: eligibleTotalStreams + ? _.round(entry.streams / eligibleTotalStreams, 6) + : 0, amount: allocations[idx], statsDocPath: entry.statsDocPath, })); @@ -192,7 +266,10 @@ exports.distributeMonthlyPayouts = onSchedule( payoutRatio: DISTRIBUTION_RATIO, payoutPool, totalStreams, + eligibleTotalStreams, totalRecipients: payouts.length, + totalEntries: entries.length, + eligibleEntries: eligibleEntries.length, totalAllocated: _.round(_.sumBy(payouts, "amount"), 2), payouts, status: payouts.length ? "computed" : "no-data", diff --git a/functions/src/subscription.js b/functions/src/subscription.js index 9988d6f..297bf53 100644 --- a/functions/src/subscription.js +++ b/functions/src/subscription.js @@ -623,8 +623,12 @@ const handleCustomerSubscriptionEvent = async ( if (!userRef) { console.warn( "[subscription-handleCustomerSubscriptionEvent] User not resolved", - subscription.customer, - event?.type, + { + subscriptionId: subscription?.id || null, + customerId: subscription?.customer || null, + metadataKeys: Object.keys(subscription?.metadata || {}), + eventType: event?.type || null, + }, ); return; } @@ -797,10 +801,46 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { const eventType = event?.type || null; const paymentDocRef = paymentsCollection.doc(invoice.id); + let resolvedSubscriptionId = + typeof invoice.subscription === "string" && invoice.subscription + ? invoice.subscription + : null; + + if (!resolvedSubscriptionId) { + const lineSubscriptionId = Array.isArray(invoice?.lines?.data) + ? invoice.lines.data + .map((line) => + typeof line?.subscription === "string" && line.subscription + ? line.subscription + : null, + ) + .find((value) => value) + : null; + + if (lineSubscriptionId) { + resolvedSubscriptionId = lineSubscriptionId; + console.log("[subscription-handleInvoiceEvent] Subscription resolved from invoice line", { + invoiceId: invoice?.id || null, + subscriptionId: resolvedSubscriptionId, + }); + } + } + + console.log("[subscription-handleInvoiceEvent] Received invoice webhook", { + eventType, + invoiceId: invoice?.id || null, + subscriptionId: invoice?.subscription || null, + resolvedSubscriptionId: resolvedSubscriptionId || null, + customerId: invoice?.customer || null, + status: invoice?.status || null, + billingReason: invoice?.billing_reason || null, + attemptCount: invoice?.attempt_count ?? null, + }); + await upsertPaymentDocument(invoice.id, { userId: firebaseUid || null, customerId: invoice.customer || null, - subscriptionId: invoice.subscription || null, + subscriptionId: resolvedSubscriptionId, status: invoice.status || null, paymentStatus: eventType === "invoice.payment_failed" @@ -834,6 +874,12 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { }); if (!userRef) { + console.warn("[subscription-handleInvoiceEvent] User context not resolved", { + invoiceId: invoice?.id || null, + customerId: invoice?.customer || null, + firebaseUid: firebaseUid || null, + metadataKeys: Object.keys(invoice?.metadata || {}), + }); return; } @@ -862,10 +908,47 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { const billingReason = invoice.billing_reason || null; const isInvoicePaid = invoice.status === "paid"; + const subscriptionInvoiceReasons = ["subscription_cycle", "subscription_create"]; + const isSubscriptionBillingReason = + subscriptionInvoiceReasons.includes(billingReason); + + if ( + isSubscriptionBillingReason && + !resolvedSubscriptionId && + stripe && + typeof invoice.id === "string" + ) { + try { + const fetchedInvoice = await stripe.invoices.retrieve(invoice.id); + const fetchedSubscriptionId = + typeof fetchedInvoice?.subscription === "string" && + fetchedInvoice.subscription + ? fetchedInvoice.subscription + : typeof fetchedInvoice?.subscription?.id === "string" && + fetchedInvoice.subscription.id + ? fetchedInvoice.subscription.id + : null; + if (fetchedSubscriptionId) { + resolvedSubscriptionId = fetchedSubscriptionId; + console.log( + "[subscription-handleInvoiceEvent] Subscription resolved from fetched invoice", + { + invoiceId: invoice.id || null, + subscriptionId: resolvedSubscriptionId, + }, + ); + } + } catch (error) { + console.warn( + "[subscription-handleInvoiceEvent] Unable to fetch invoice to resolve subscription", + invoice.id, + error?.message || error, + ); + } + } + const isSubscriptionInvoice = - ["subscription_cycle", "subscription_create"].includes(billingReason) && - typeof invoice.subscription === "string" && - invoice.subscription; + isSubscriptionBillingReason && Boolean(resolvedSubscriptionId); const shouldProcessAllowance = eventType === "invoice.paid" && isInvoicePaid && isSubscriptionInvoice; @@ -875,7 +958,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { console.log("[subscription-handleInvoiceEvent] Processing subscription allowance", { invoiceId: invoice.id || null, - subscriptionId: invoice.subscription || null, + subscriptionId: resolvedSubscriptionId || null, customer: invoice.customer || null, billingReason, orderTargetUid, @@ -917,6 +1000,21 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { if (expandedPrice && typeof expandedPrice === "object") { stripePrice = expandedPrice; } + + if ( + !resolvedSubscriptionId && + typeof expandedInvoice?.subscription === "string" && + expandedInvoice.subscription + ) { + resolvedSubscriptionId = expandedInvoice.subscription; + console.log( + "[subscription-handleInvoiceEvent] Subscription resolved from expanded invoice", + { + invoiceId: invoice.id || null, + subscriptionId: resolvedSubscriptionId, + }, + ); + } } catch (error) { console.warn( "[subscription-handleInvoiceEvent] Unable to expand invoice price", @@ -932,6 +1030,21 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { }; const stripePrice = await loadInvoicePrice(); + console.log("[subscription-handleInvoiceEvent] Loaded invoice price", { + invoiceId: invoice.id || null, + subscriptionId: resolvedSubscriptionId || null, + priceId: stripePrice?.id || null, + productId: + (typeof stripePrice?.product === "string" + ? stripePrice.product + : stripePrice?.product?.id) || null, + hasProductMetadata: + !!( + stripePrice?.product && + typeof stripePrice.product === "object" && + Object.keys(stripePrice.product.metadata || {}).length > 0 + ), + }); let coinsPerMonth = parseCoinsPerMonth( (stripePrice?.product && typeof stripePrice.product === "object" ? stripePrice.product.metadata @@ -967,7 +1080,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { "[subscription-handleInvoiceEvent] Using static allowance from price metadata", { invoiceId: invoice.id || null, - subscriptionId: invoice.subscription || null, + subscriptionId: resolvedSubscriptionId || null, priceId: invoicePriceId, coinsPerMonth, }, @@ -985,7 +1098,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { "[subscription-handleInvoiceEvent] Using allowance from invoice metadata", { invoiceId: invoice.id || null, - subscriptionId: invoice.subscription || null, + subscriptionId: resolvedSubscriptionId || null, priceId: invoicePriceId, coinsPerMonth, }, @@ -1001,7 +1114,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { "[subscription-handleInvoiceEvent] Using allowance from user profile cache", { invoiceId: invoice.id || null, - subscriptionId: invoice.subscription || null, + subscriptionId: resolvedSubscriptionId || null, priceId: invoicePriceId, coinsPerMonth, }, @@ -1009,6 +1122,17 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { } } + if (coinsPerMonth === null) { + console.warn("[subscription-handleInvoiceEvent] Missing coinsPerMonth allowance", { + invoiceId: invoice.id || null, + subscriptionId: resolvedSubscriptionId || null, + priceId: invoicePriceId || null, + userId: userRef.id, + hasStoredAllowance: Boolean(userData?.subscriptionCoinsPerMonth), + metadataCoins: invoice.metadata?.coinsPerMonth ?? null, + }); + } + const recurringInterval = stripePrice?.recurring?.interval || null; let billingPeriod = userData?.premiumBillingPeriod || null; if (recurringInterval === "month") { @@ -1022,7 +1146,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { console.log("[subscription-handleInvoiceEvent] Allowance context", { invoiceId: invoice.id || null, - subscriptionId: invoice.subscription || null, + subscriptionId: resolvedSubscriptionId || null, coinsPerMonth, billingPeriod, recurringInterval, @@ -1033,7 +1157,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { if (coinsPerMonth && coinsPerMonth > 0 && orderTargetUid) { console.log("[subscription-handleInvoiceEvent] Preparing coin grant", { invoiceId: invoice.id || null, - subscriptionId: invoice.subscription || null, + subscriptionId: resolvedSubscriptionId || null, userId: orderTargetUid, coinsPerMonth, billingPeriod, @@ -1063,7 +1187,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { source: "STRIPE_SUBSCRIPTION", schedule: isAnnual ? "annual_invoice" : "monthly_invoice", invoiceId: invoice.id, - subscriptionId: invoice.subscription || null, + subscriptionId: resolvedSubscriptionId || null, billingPeriod, billingReason, }, @@ -1085,7 +1209,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { console.log("[subscription-handleInvoiceEvent] Subscription coins granted", { orderId: processedOrderId, invoiceId: invoice.id, - subscriptionId: invoice.subscription || null, + subscriptionId: resolvedSubscriptionId || null, userId: orderTargetUid, amount: coinsPerMonth, billingPeriod, @@ -1131,14 +1255,14 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { } else { console.log("[subscription-handleInvoiceEvent] Coins already granted for invoice", { invoiceId: invoice.id, - subscriptionId: invoice.subscription || null, + subscriptionId: resolvedSubscriptionId || null, userId: orderTargetUid, }); } } else { console.log("[subscription-handleInvoiceEvent] Skipping allowance grant", { invoiceId: invoice.id || null, - subscriptionId: invoice.subscription || null, + subscriptionId: resolvedSubscriptionId || null, coinsPerMonth, orderTargetUid, billingPeriod, @@ -1146,6 +1270,17 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { } } + if (!shouldProcessAllowance) { + console.log("[subscription-handleInvoiceEvent] Allowance conditions not met", { + eventType, + invoiceStatus: invoice.status || null, + billingReason, + hasSubscription: Boolean(resolvedSubscriptionId), + isSubscriptionInvoice, + isInvoicePaid, + }); + } + await userRef.set(userUpdate, { merge: true }); }; @@ -1208,6 +1343,13 @@ const handleStripeWebhookEvent = async ({ event, stripe }) => { return; } + console.log("[subscription-handleStripeWebhookEvent] Received event", { + id: event?.id || null, + type: event?.type || null, + apiVersion: event?.api_version || null, + created: event?.created || null, + }); + switch (event.type) { case "checkout.session.completed": await handleCheckoutSessionCompleted(event.data?.object, event, { diff --git a/src/actions/signupActions.js b/src/actions/signupActions.js index 4e6e6a2..9ff849b 100644 --- a/src/actions/signupActions.js +++ b/src/actions/signupActions.js @@ -74,6 +74,12 @@ export function handleFirebaseError(code = "") { return "Ton mot de passe est trop faible."; case "auth/network-request-failed": return "Problème de connexion réseau. Vérifie ta connexion et réessaie."; + case "auth/invalid-action-code": + case "auth/expired-action-code": + case "auth/missing-oob-code": + return "Ce lien de réinitialisation n'est plus valide. Demande un nouveau mot de passe."; + case "auth/missing-email": + return "Renseigne ton adresse e-mail."; default: return "Une erreur est survenue. Réessaie dans quelques instants."; } diff --git a/src/assets/UI/studioBG2.png b/src/assets/UI/studioBG2.png index bb38233..d19d462 100644 Binary files a/src/assets/UI/studioBG2.png and b/src/assets/UI/studioBG2.png differ diff --git a/src/assets/icons/club.png b/src/assets/icons/club.png new file mode 100644 index 0000000..1e8f516 Binary files /dev/null and b/src/assets/icons/club.png differ diff --git a/src/assets/icons/clubIcon.png b/src/assets/icons/clubIcon.png new file mode 100644 index 0000000..4967ac3 Binary files /dev/null and b/src/assets/icons/clubIcon.png differ diff --git a/src/assets/icons/premiumBadge.png b/src/assets/icons/premiumBadge.png new file mode 100644 index 0000000..d5462e8 Binary files /dev/null and b/src/assets/icons/premiumBadge.png differ diff --git a/src/assets/icons/proBadge.png b/src/assets/icons/proBadge.png new file mode 100644 index 0000000..8d681a4 Binary files /dev/null and b/src/assets/icons/proBadge.png differ diff --git a/src/assets/icons/production.png b/src/assets/icons/production.png new file mode 100644 index 0000000..3bf484a Binary files /dev/null and b/src/assets/icons/production.png differ diff --git a/src/assets/icons/starterBadge.png b/src/assets/icons/starterBadge.png new file mode 100644 index 0000000..16ef47e Binary files /dev/null and b/src/assets/icons/starterBadge.png differ diff --git a/src/assets/icons/studio.png b/src/assets/icons/studio.png new file mode 100644 index 0000000..f1a239a Binary files /dev/null and b/src/assets/icons/studio.png differ diff --git a/src/assets/icons/video.png b/src/assets/icons/video.png new file mode 100644 index 0000000..828592b Binary files /dev/null and b/src/assets/icons/video.png differ diff --git a/src/assets/icons/writing.png b/src/assets/icons/writing.png new file mode 100644 index 0000000..3731465 Binary files /dev/null and b/src/assets/icons/writing.png differ diff --git a/src/assets/index.js b/src/assets/index.js index 6c49cfa..cc63c79 100644 --- a/src/assets/index.js +++ b/src/assets/index.js @@ -186,6 +186,8 @@ export const icons = { hitParadeLogo, calendar, coin, + club: require("./icons/club.png"), + clubIcon: require("./icons/clubIcon.png"), }; export const background = { @@ -236,3 +238,16 @@ export const img = { profile, goodVibe, }; + +export const cardsImg = { + writing: require("./icons/writing.png"), + studio: require("./icons/studio.png"), + video: require("./icons/video.png"), + production: require("./icons/production.png"), +}; + +export const subBadges = { + starter: require("./icons/starterBadge.png"), + pro: require("./icons/proBadge.png"), + premium: require("./icons/premiumBadge.png"), +}; diff --git a/src/components/CoinIcon.js b/src/components/CoinIcon.js new file mode 100644 index 0000000..fe76056 --- /dev/null +++ b/src/components/CoinIcon.js @@ -0,0 +1,21 @@ +import React from "react"; +import { Image } from "react-native"; +import { icons } from "../assets"; + +const CoinIcon = ({ size = 22, style }) => { + return ( + + ); +}; + +export default CoinIcon; diff --git a/src/components/ProjectDropDown/ProjectDropDown.js b/src/components/ProjectDropDown/ProjectDropDown.js index 7f6ca4b..286f6c2 100644 --- a/src/components/ProjectDropDown/ProjectDropDown.js +++ b/src/components/ProjectDropDown/ProjectDropDown.js @@ -25,9 +25,10 @@ import { FONT_FAMILY } from "../../styles/Fonts"; import GradientButton from "../GradientButton"; const BUTTON_BLUR_INTENSITY = 20; +const DROPDOWN_BACKGROUND_COLOR = "#252438"; const IS_WEB = Platform.OS === "web"; const WEB_BLUR_FALLBACK_STYLE = { - backgroundColor: Palette.glass, + backgroundColor: "transparent", }; const BLUR_VIEW_PROPS = Platform.OS === "android" @@ -479,6 +480,7 @@ const styles = StyleSheet.create({ padding: 8, position: "relative", overflow: "hidden", + backgroundColor: DROPDOWN_BACKGROUND_COLOR, }, dropdownBlurDisabled: { opacity: 0.6, @@ -507,6 +509,7 @@ const styles = StyleSheet.create({ paddingVertical: 8, gap: 9, maxHeight: 400, + backgroundColor: DROPDOWN_BACKGROUND_COLOR, }, dropdownOverlayContent: { maxHeight: 260, @@ -560,6 +563,7 @@ const styles = StyleSheet.create({ paddingVertical: 12, borderRadius: 10, overflow: "hidden", + backgroundColor: DROPDOWN_BACKGROUND_COLOR, }, projectImage: { width: 60, diff --git a/src/layouts/Page.js b/src/layouts/Page.js index 2ca9416..c4c66aa 100644 --- a/src/layouts/Page.js +++ b/src/layouts/Page.js @@ -4,14 +4,17 @@ import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view import { SafeAreaView } from "react-native-safe-area-context"; import React from "reactn"; import { responsiveHeight } from "../actions/responsiveSizes.js"; -import { icons } from "../assets"; +import { icons, subBadges } from "../assets"; import BaseHeader from "../components/BaseHeader"; +import CoinIcon from "../components/CoinIcon"; import ConnectBtn from "../components/ConnectBtn.js"; import CoinPackModal from "../components/modal/CoinPackModal"; import NavigateHeader from "../components/NavigateHeader"; import ShareBtn from "../components/ShareBtn/ShareBtn"; import { isWeb } from "../hooks/useLayoutType.js"; import { useUserData } from "../providers/UserDataProvider"; +import { Routes } from "../navigation"; +import { navigate } from "../navigation/NavigationService"; import { gutters } from "../styles"; import { FONT_FAMILY } from "../styles/Fonts"; import { subscribeCoinPackModal } from "../utils/coinPackModal"; @@ -75,6 +78,61 @@ export default ({ const showCoinBadge = isWeb && !!currentUID && showCoin; const [isCoinModalVisible, setCoinModalVisible] = React.useState(false); + const subscriptionBadgeSource = React.useMemo(() => { + const allowedLevels = new Set(["starter", "pro", "premium"]); + const pickLevel = (value) => { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + return normalized && allowedLevels.has(normalized) ? normalized : null; + }; + + const nestedSubscription = + currentUserData?.subscription && + typeof currentUserData.subscription === "object" + ? currentUserData.subscription + : null; + + const stripeMetadata = + currentUserData?.stripeSubscription && + typeof currentUserData.stripeSubscription === "object" && + typeof currentUserData.stripeSubscription.metadata === "object" + ? currentUserData.stripeSubscription.metadata + : null; + + const directCandidates = [ + pickLevel(currentUserData?.premiumLevel), + pickLevel(currentUserData?.subscriptionLevel), + pickLevel(currentUserData?.subscriptionPlan), + pickLevel(currentUserData?.subscriptionPack), + ]; + + const nestedCandidates = nestedSubscription + ? ["level", "plan", "pack", "type"].map((key) => + pickLevel(nestedSubscription?.[key]), + ) + : []; + + const metadataCandidates = stripeMetadata + ? [ + "level", + "subscriptionLevel", + "subscription_level", + "pack", + "Pack", + ].map((key) => pickLevel(stripeMetadata?.[key])) + : []; + + const resolvedLevel = [ + ...directCandidates, + ...nestedCandidates, + ...metadataCandidates, + ].find(Boolean); + + return resolvedLevel ? subBadges[resolvedLevel] || null : null; + }, [currentUserData]); + const handleCoinPress = React.useCallback(() => { if (!showCoinBadge) return; setCoinModalVisible(true); @@ -149,39 +207,56 @@ export default ({ /> ) : null} {showCoinBadge ? ( - - - - {formattedCoins} - - + + + {formattedCoins} + + + {subscriptionBadgeSource ? ( + navigate(Routes.ManageSubscription)} + accessibilityRole="button" + style={{ paddingVertical: 4 }} + > + + + ) : null} + ) : null} { useEffect(() => { if (!pendingNavigation || !isFullyLoaded) return; + if (pendingNavigation.type === "resetPassword") { + navigate(Routes.ResetPassword, pendingNavigation.params); + setPendingNavigation(null); + return; + } + if (pendingNavigation.type === "task") { if (!currentUID) return; navigateToTask(pendingNavigation.params); @@ -211,6 +217,36 @@ const UniversalLinkProvider = ({ children }) => { }, [pendingNavigation, currentUID, isFullyLoaded]); const handleParams = (path, queryParams = {}) => { const normalizedPath = normalizePath(path); + + const modeParam = queryParams?.mode; + const resetMode = + typeof modeParam === "string" && modeParam.toLowerCase() === "resetpassword"; + const oobCodeParam = queryParams?.oobCode; + + if (resetMode && typeof oobCodeParam === "string" && oobCodeParam.trim()) { + setPendingNavigation({ + type: "resetPassword", + params: { + oobCode: oobCodeParam.trim(), + continueUrl: + typeof queryParams?.continueUrl === "string" + ? queryParams.continueUrl + : typeof queryParams?.continueURL === "string" + ? queryParams.continueURL + : null, + lang: + typeof queryParams?.lang === "string" + ? queryParams.lang + : typeof queryParams?.language === "string" + ? queryParams.language + : null, + email: + typeof queryParams?.email === "string" ? queryParams.email : null, + }, + }); + return; + } + if (!normalizedPath) return; if (normalizedPath.toLowerCase() === "link") { diff --git a/src/screens/ForgotPassword.js b/src/screens/ForgotPassword.js index 87e69a3..c59a9fc 100644 --- a/src/screens/ForgotPassword.js +++ b/src/screens/ForgotPassword.js @@ -1,4 +1,4 @@ -import React, { useState, useRef, useGlobal } from "reactn"; +import React, { useState, useGlobal } from "reactn"; import { Text, KeyboardAvoidingView } from "react-native"; import { responsiveHeight } from "../actions/responsiveSizes.js"; @@ -11,6 +11,10 @@ import { Fonts, Style } from "../styles"; import Page from "../layouts/Page"; import { background } from "../assets"; import { isWeb } from "../hooks/useLayoutType"; +import { + checkIfEmailIsValid, + handleFirebaseError, +} from "../actions/signupActions.js"; export default ({ navigation }) => { const [, setTooltip] = useGlobal("_tooltip"); @@ -19,10 +23,27 @@ export default ({ navigation }) => { const [email, setEmail] = useState(__DEV__ ? "hello@minuit.agency" : ""); const onResetPassword = async () => { + const trimmedEmail = (email || "").trim(); + if (!trimmedEmail?.length) { + setTooltip({ + text: "Renseigne ton adresse e-mail.", + type: "error", + }); + return; + } + + if (!checkIfEmailIsValid({ email: trimmedEmail })) { + setTooltip({ + text: "Adresse e-mail invalide.", + type: "error", + }); + return; + } + try { setIsLoading(true); - await firebase.auth().sendPasswordResetEmail(email); + await firebase.auth().sendPasswordResetEmail(trimmedEmail); setTooltip({ text: "Email de réinitialisation envoyé!", @@ -31,9 +52,13 @@ export default ({ navigation }) => { navigation.goBack(); } catch (error) { - console.log(error); + console.log("ForgotPassword error", error?.message); + const message = + error?.code && error.code.startsWith("auth/") + ? handleFirebaseError(error.code) + : error?.message || "Une erreur est survenue"; setTooltip({ - text: "Une erreur est survenue", + text: message, type: "error", }); } finally { diff --git a/src/screens/Home/Home.js b/src/screens/Home/Home.js index 1d64815..a319221 100644 --- a/src/screens/Home/Home.js +++ b/src/screens/Home/Home.js @@ -5,13 +5,11 @@ import React, { useRef, useState, } from "react"; -import { Platform, StyleSheet, View } from "react-native"; -import { useIsFocused } from "@react-navigation/native"; +import { StyleSheet, Text, View } from "react-native"; +import { Image as ExpoImage } from "expo-image"; import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; -import { background, videos } from "../../assets"; +import { background, cardsImg, icons, videos } from "../../assets"; import Alert from "../../components/Alert"; -import BorderGradientButton from "../../components/BorderGradientButton"; -import FeatureCarousel from "../../components/FeatureCarousel/FeatureCarousel"; import FullscreenIntroVideo from "../../components/FullscreenIntroVideo"; import GradientButton from "../../components/GradientButton"; import MoreMenu from "../../components/MoreMenu"; @@ -26,11 +24,13 @@ import { navigate } from "../../navigation/NavigationService"; import { Routes } from "../../navigation/Routes"; import { useUser } from "../../providers/UserDataProvider"; import { Palette } from "../../styles"; +import { FONT_FAMILY } from "../../styles/Fonts"; import { - findFirstUnlockedStageIndex, getCreationStageStates, getStageAction, } from "../../utils/projectStages"; +import StageCard from "./components/StageCard"; +import ClubCard from "./components/ClubCard"; const isProjectEmpty = (project) => { if (!project) { @@ -42,8 +42,55 @@ const isProjectEmpty = (project) => { ); }; +const HOME_BACKGROUND_WIDTH = isWeb ? 1280 : 520; +const HOME_BACKGROUND_HEIGHT = isWeb ? 760 : 360; + +const STAGE_CARD_CONTENT = [ + { + key: "songwriter", + step: "ÉTAPE 1", + description: + "Ensemble, nous allons écrire ta chanson.\nMéthodiquement je vais te guider pour construire une oeuvre unique et authentique.", + image: cardsImg.writing, + imagePosition: "left", + textAlign: "left", + lockSide: "right", + }, + { + key: "beatmaker", + step: "ÉTAPE 2", + description: + "Je suis Malik, responsable du studio de musicland, je vais mettre en musique tes paroles en fonction de tes goûts musicaux, ça va être top!", + image: cardsImg.studio, + imagePosition: "right", + textAlign: "right", + lockSide: "left", + }, + { + key: "director", + step: "ÉTAPE 3", + description: + "Tu vas faire une expérience extraordinaire, tu vas te filmer en train d’interpreter ta chanson en Play Back et je vais te guider pour te faciliter la tâche!", + image: cardsImg.video, + imagePosition: "left", + textAlign: "left", + lockSide: "right", + }, + { + key: "publisher", + step: "ÉTAPE 4", + description: + "Je suis Mr Benhaï, Producteur de MusicLand et je vais te faire une proposition qui pourrait t’intéresser, on se retrouve à la sotie du studio!", + image: cardsImg.production, + imagePosition: "right", + textAlign: "right", + lockSide: "left", + }, +]; + +const CLUB_CARD_IMAGE = icons.clubIcon; + const Home = ({ navigation, route }) => { - const isFocused = useIsFocused(); const { userProjects = [], selectProject, @@ -61,7 +108,7 @@ const Home = ({ navigation, route }) => { const projects = useMemo( () => (Array.isArray(userProjects) ? userProjects : []), - [userProjects] + [userProjects], ); const currentProject = useMemo(() => { @@ -94,73 +141,34 @@ const Home = ({ navigation, route }) => { const stageStates = useMemo( () => getCreationStageStates(currentProject), - [currentProject] + [currentProject], ); - const preferredStageIndex = useMemo(() => { - if (!stageStates.length) { - return 0; + const stageStatesByKey = useMemo(() => { + if (!Array.isArray(stageStates)) { + return {}; } - const actionableIndex = stageStates.findIndex( - (stage) => !stage.isCompleted && !stage.isLocked - ); - if (actionableIndex !== -1) { - return actionableIndex; - } - const firstIncomplete = stageStates.findIndex( - (stage) => !stage.isCompleted - ); - if (firstIncomplete !== -1) { - return firstIncomplete; - } - const firstUnlocked = stageStates.findIndex((stage) => !stage.isLocked); - if (firstUnlocked !== -1) { - return firstUnlocked; - } - return stageStates.length - 1; + return stageStates.reduce((acc, stage) => { + if (stage?.key) { + acc[stage.key] = stage; + } + return acc; + }, {}); }, [stageStates]); - const [activeStageIndex, setActiveStageIndex] = useState(preferredStageIndex); const [menuVisible, setMenuVisible] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); const [menuProject, setMenuProject] = useState(null); const menuAnchorRef = useRef(null); - const testLoaderTimeoutRef = useRef(null); const [isIntroVideoVisible, setIsIntroVideoVisible] = useState(false); const [hasLocalAdventureFlag, setHasLocalAdventureFlag] = useState(false); - useEffect(() => { - setActiveStageIndex((prev) => { - const fallbackIndex = - preferredStageIndex >= 0 && preferredStageIndex < stageStates.length - ? preferredStageIndex - : 0; - - if (prev == null || prev >= stageStates.length) { - return fallbackIndex; - } - const current = stageStates[prev]; - if (!current) { - return fallbackIndex; - } - if ( - (current.isLocked || current.isCompleted) && - fallbackIndex !== prev && - stageStates[fallbackIndex] - ) { - return fallbackIndex; - } - return prev; - }); - }, [stageStates, preferredStageIndex]); - const handleSelectProject = useCallback( (project) => { if (!project?.id) return; selectProject(project.id); - setActiveStageIndex(findFirstUnlockedStageIndex(project)); }, - [selectProject] + [selectProject], ); const handleCloseMenu = useCallback(() => { @@ -228,7 +236,7 @@ const Home = ({ navigation, route }) => { }, }, ], - { cancelable: true } + { cancelable: true }, ), }, ]; @@ -262,7 +270,7 @@ const Home = ({ navigation, route }) => { navigate(targetRoute, beatmakerStage?.params); }, }, - ] + ], ); navigation?.setParams?.({ showLyricsCongrats: false }); }, [ @@ -273,19 +281,6 @@ const Home = ({ navigation, route }) => { route?.params?.showLyricsCongrats, ]); - const activeStage = - stageStates[activeStageIndex] || stageStates[preferredStageIndex]; - const stageAction = useMemo(() => { - if (!activeStage) { - return null; - } - return getStageAction(activeStage.key, currentProject); - }, [activeStage, currentProject]); - - const stageRoute = stageAction?.route || null; - const stageParams = stageAction?.params; - const stageLocked = activeStage?.isLocked ?? true; - const hasActiveProject = !!currentProject; const ensureProjectSelected = useCallback(() => { @@ -297,32 +292,9 @@ const Home = ({ navigation, route }) => { } }, [currentProject?.id, selectProject, selectedProject?.id]); - const handleStageAction = useCallback(() => { - if (!hasActiveProject || stageLocked || !stageRoute) { - return; - } - ensureProjectSelected(); - navigate(stageRoute, stageParams); - }, [ - ensureProjectSelected, - hasActiveProject, - stageLocked, - stageParams, - stageRoute, - ]); - - useEffect(() => { - return () => { - if (testLoaderTimeoutRef.current) { - clearTimeout(testLoaderTimeoutRef.current); - testLoaderTimeoutRef.current = null; - } - }; - }, []); - const songwriterAction = useMemo( () => getStageAction("songwriter", null), - [] + [], ); const handleStartNew = useCallback(async () => { @@ -335,7 +307,6 @@ const Home = ({ navigation, route }) => { if (emptyProject?.id) { selectProject(emptyProject.id); - setActiveStageIndex(findFirstUnlockedStageIndex(emptyProject)); navigate(targetRoute, targetParams); return; } @@ -344,7 +315,6 @@ const Home = ({ navigation, route }) => { if (!newProjectId) { return; } - setActiveStageIndex(0); navigate(targetRoute, targetParams); } catch (error) { console.warn("Home: unable to start new project", error); @@ -353,17 +323,7 @@ const Home = ({ navigation, route }) => { text: "Impossible de démarrer un nouveau projet", }); } - }, [ - createNewProject, - projects, - selectProject, - setActiveStageIndex, - setTooltip, - songwriterAction, - ]); - - const continueDisabled = !hasActiveProject || stageLocked || !stageRoute; - const startDisabled = !songwriterAction?.route; + }, [createNewProject, projects, selectProject, setTooltip, songwriterAction]); useEffect(() => { if (currentUserData?.adventureStarted) { @@ -412,104 +372,102 @@ const Home = ({ navigation, route }) => { simpleRef: true, }); const videoUrl = isWeb ? video?.landingWeb : video?.landing; - const homeBackgroundImage = isWeb - ? background.homeBGWeb - : background.homeBG; + const homeBackgroundImage = background.bgTrans; const landingBackgroundImage = homeBackgroundImage; + const stageCards = useMemo( + () => + STAGE_CARD_CONTENT.map((card) => ({ + ...card, + isLocked: stageStatesByKey[card.key]?.isLocked ?? true, + })), + [stageStatesByKey], + ); + + const handleStagePress = useCallback( + (stageKey, isLocked) => { + if (!hasActiveProject || isLocked) { + return; + } + ensureProjectSelected(); + const action = getStageAction(stageKey, currentProject); + if (!action?.route) { + return; + } + navigate(action.route, action.params); + }, + [currentProject, ensureProjectSelected, hasActiveProject], + ); + + const handleClubPress = useCallback(() => { + navigate(Routes.Payments); + }, []); + return adventureStarted ? ( - - - - - {!isWeb && } - - - - - - - - + + + - {/* - Alert("Test", "Ceci est un test d'alert", [ - { text: "Annuler", style: "cancel" }, - { text: "OK", onPress: () => console.log("OK pressé") }, - ]) - } - /> */} - {/* navigate(Routes.SongReady)} - disabled={continueDisabled} - /> */} + + + {!isWeb && } + + + + + 5 espaces à découvrir + + + {stageCards.map((card) => ( + handleStagePress(card.key, card.isLocked)} + /> + ))} + + + - - + + ) : ( <> ( + [styles.card, pressed && styles.cardPressed]} + > + + + + Rejoins le club ! + + +); + +export default memo(ClubCard); + +const styles = StyleSheet.create({ + card: { + marginTop: 28, + borderRadius: 20, + backgroundColor: "#252438", + overflow: "hidden", + width: 200, + alignSelf: "center", + }, + cardPressed: { + opacity: 0.85, + }, + inner: { + paddingVertical: 12, + paddingHorizontal: 14, + alignItems: "center", + gap: 8, + }, + clubLogo: { + width: 160, + height: 36, + }, + image: { + width: 52, + height: 52, + }, + subtitle: { + fontFamily: FONT_FAMILY.InterMedium, + fontSize: 12, + color: Palette.white, + }, +}); diff --git a/src/screens/Home/components/StageCard.js b/src/screens/Home/components/StageCard.js new file mode 100644 index 0000000..cce470c --- /dev/null +++ b/src/screens/Home/components/StageCard.js @@ -0,0 +1,246 @@ +import React, { memo } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { BlurView } from "expo-blur"; +import { Image as ExpoImage } from "expo-image"; +import FontAwesome from "@expo/vector-icons/FontAwesome"; +import { Palette } from "../../../styles"; +import { FONT_FAMILY } from "../../../styles/Fonts"; + +const StageCard = ({ + step, + description, + image, + imagePosition = "left", + textAlign = "left", + isLocked, + onPress, + lockSide = "right", +}) => { + const isImageOnLeft = imagePosition !== "right"; + const isTextRight = textAlign === "right"; + + const imageBlock = ( + + + + ); + + const header = ( + + + {step} + + + ); + + const textBlock = ( + + {isLocked ? ( + + + + ) : null} + {header} + + {description} + + + ); + + const content = isImageOnLeft ? ( + <> + {imageBlock} + {textBlock} + + ) : ( + <> + {textBlock} + {imageBlock} + + ); + + return ( + [ + styles.card, + pressed && !isLocked && styles.cardPressed, + ]} + > + {content} + + ); +}; + +export default memo(StageCard); + +const styles = StyleSheet.create({ + card: { + width: "48%", + minWidth: 260, + flexGrow: 0, + flexShrink: 0, + borderRadius: 20, + }, + cardPressed: { + opacity: 0.85, + }, + inner: { + flexDirection: "row", + alignItems: "stretch", + }, + imageContainer: { + flexGrow: 1, + flexShrink: 1, + minHeight: 200, + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + }, + imageLeft: { + borderTopLeftRadius: 20, + borderBottomLeftRadius: 20, + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + }, + imageRight: { + borderTopRightRadius: 20, + borderBottomRightRadius: 20, + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }, + image: { + width: "85%", + height: "85%", + minHeight: 200, + borderRadius: 0, + }, + imageAlignRight: { + alignSelf: "flex-end", + }, + imageAlignLeft: { + alignSelf: "flex-start", + }, + textContainer: { + flexGrow: 1, + flexShrink: 1, + minWidth: 240, + maxWidth: 320, + minHeight: 120, + maxHeight: 160, + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 18, + justifyContent: "center", + alignSelf: "center", + }, + textContainerRight: { + borderTopRightRadius: 18, + borderBottomRightRadius: 18, + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + marginLeft: -32, + }, + textContainerLeft: { + borderTopLeftRadius: 18, + borderBottomLeftRadius: 18, + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + marginRight: -32, + }, + alignStart: { + alignItems: "flex-start", + }, + alignEnd: { + alignItems: "flex-end", + }, + textHeader: { + width: "100%", + flexDirection: "row", + alignItems: "center", + marginBottom: 8, + }, + textHeaderAlignStart: { + justifyContent: "flex-start", + }, + textHeaderAlignEnd: { + justifyContent: "flex-end", + }, + step: { + fontFamily: FONT_FAMILY.InterSemiBold, + fontSize: 14, + color: Palette.white, + flexShrink: 1, + }, + description: { + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 13, + lineHeight: 20, + color: Palette.white, + }, + textLeft: { + textAlign: "left", + }, + textRight: { + textAlign: "right", + }, + lockBadge: { + position: "absolute", + top: 10, + backgroundColor: "rgba(0, 0, 0, 0.55)", + width: 32, + height: 32, + borderRadius: 16, + alignItems: "center", + justifyContent: "center", + zIndex: 2, + }, + lockBadgeLeft: { + left: 10, + }, + lockBadgeRight: { + right: 10, + }, +}); diff --git a/src/screens/Payments.js b/src/screens/Payments.js index 594d1e1..74599c9 100644 --- a/src/screens/Payments.js +++ b/src/screens/Payments.js @@ -12,7 +12,7 @@ import { BlurView } from "expo-blur"; import { Image as ExpoImage } from "expo-image"; import GradientButton from "../components/GradientButton"; import Page from "../layouts/Page"; -import { background } from "../assets"; +import { background, subBadges } from "../assets"; import { Palette, gutters } from "../styles"; import { FONT_FAMILY } from "../styles/Fonts"; import { isWeb } from "../hooks/useLayoutType"; @@ -82,6 +82,9 @@ function SubscriptionCard({ plan, selected, onSelect }) { typeof plan?.coinsPerMonth === "number" && Number.isFinite(plan.coinsPerMonth) ? Math.round(plan.coinsPerMonth) : null; + const planBadgeKey = getPlanKeyForBadge(plan); + const planBadgeSource = + planBadgeKey && subBadges[planBadgeKey] ? subBadges[planBadgeKey] : null; const handleSelect = React.useCallback(() => { if (typeof onSelect === "function" && plan?.priceId) { onSelect(plan.priceId); @@ -110,7 +113,16 @@ function SubscriptionCard({ plan, selected, onSelect }) { ) : null} - {planName} + + {planName} + {planBadgeSource ? ( + + ) : null} + {plan?.nickname ? ( {plan.nickname} ) : null} @@ -169,6 +181,17 @@ const PACK_PRICE_ID_BY_PERIOD = { }, }; +const PRICE_ID_TO_PLAN_KEY = {}; + +Object.values(PACK_PRICE_ID_BY_PERIOD).forEach((mapping) => { + Object.entries(mapping).forEach(([planKey, priceId]) => { + if (!priceId) { + return; + } + PRICE_ID_TO_PLAN_KEY[priceId] = planKey === "prop" ? "pro" : planKey; + }); +}); + const PLAN_FALLBACK_ORDER = ["starter", "pro", "premium"]; const PLAN_SYNONYMS = { @@ -177,6 +200,33 @@ const PLAN_SYNONYMS = { premium: ["premium"], }; +const getPlanKeyForBadge = (plan) => { + if (!plan || typeof plan !== "object") { + return null; + } + + const priceId = + typeof plan?.priceId === "string" ? plan.priceId : plan?.id || null; + + if (priceId && PRICE_ID_TO_PLAN_KEY[priceId]) { + return PRICE_ID_TO_PLAN_KEY[priceId]; + } + + const label = (plan?.product?.name || plan?.nickname || "") + .toString() + .toLowerCase(); + + if (!label) { + return null; + } + + const match = Object.entries(PLAN_SYNONYMS).find(([, variants]) => + variants.some((variant) => label.includes(variant)), + ); + + return match ? match[0] : null; +}; + const getPlanPriority = (plan, periodKey) => { const priceId = typeof plan?.priceId === "string" ? plan.priceId : plan?.id || null; @@ -685,7 +735,8 @@ const styles = StyleSheet.create({ }, cardBlur: { flex: 1, - padding: gutters * 1.2, + paddingHorizontal: gutters * 1.2, + paddingVertical: gutters, gap: 20, justifyContent: "center", backgroundColor: "rgba(48, 52, 56, 0.55)", @@ -697,10 +748,23 @@ const styles = StyleSheet.create({ cardHeader: { gap: 6, }, + titleRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + width: "100%", + }, planName: { fontFamily: FONT_FAMILY.InterBold, fontSize: 24, color: Palette.white, + flexShrink: 1, + }, + planBadgeImage: { + width: 48, + height: 48, + flexShrink: 0, }, cardSubtitle: { fontFamily: FONT_FAMILY.InterMedium, diff --git a/src/screens/Profile/ManageSubscription.js b/src/screens/Profile/ManageSubscription.js index d122d41..0015a72 100644 --- a/src/screens/Profile/ManageSubscription.js +++ b/src/screens/Profile/ManageSubscription.js @@ -132,6 +132,62 @@ const capitalize = (value) => { return value.charAt(0).toUpperCase() + value.slice(1); }; +const SCHEDULE_COMPARISON_HOUR = 2; +const SCHEDULE_COMPARISON_MINUTE = 30; +const SCHEDULE_DISPLAY_HOUR = 3; +const SCHEDULE_DISPLAY_MINUTE = 30; + +const addMonthsSafe = (date, months = 1) => { + if (!(date instanceof Date) || !Number.isFinite(months)) { + return null; + } + const result = new Date(date.getTime()); + const initialDay = result.getDate(); + result.setMonth(result.getMonth() + months); + if (result.getDate() !== initialDay) { + result.setDate(0); + } + return result; +}; + +const addDaysSafe = (date, days = 1) => { + if (!(date instanceof Date) || !Number.isFinite(days)) { + return null; + } + const result = new Date(date.getTime()); + result.setDate(result.getDate() + days); + return result; +}; + +const alignToScheduleTime = (date) => { + if (!(date instanceof Date)) { + return null; + } + const aligned = new Date(date.getTime()); + aligned.setHours(SCHEDULE_DISPLAY_HOUR, SCHEDULE_DISPLAY_MINUTE, 0, 0); + return aligned; +}; + +const computeFirstAnnualGrantFromCreation = (creationDate) => { + if (!(creationDate instanceof Date)) { + return null; + } + const cutoff = new Date(creationDate.getTime()); + cutoff.setHours(SCHEDULE_COMPARISON_HOUR, SCHEDULE_COMPARISON_MINUTE, 0, 0); + + let base = null; + if (creationDate <= cutoff) { + base = addMonthsSafe(creationDate, 1); + } else { + base = addDaysSafe(creationDate, 1); + } + + if (!base) { + return null; + } + return alignToScheduleTime(base); +}; + const ManageSubscription = ({ navigation }) => { const { currentUserData } = useUserData() || {}; const [isCancelling, setIsCancelling] = useState(false); @@ -334,9 +390,40 @@ const ManageSubscription = ({ navigation }) => { currentUserData?.subscriptionNextGrantAt || currentUserData?.subscriptionGrantNextAt || null; - const nextGrantDate = toDate(nextGrantTimestamp); + const nextGrantRawDate = toDate(nextGrantTimestamp); + const now = new Date(); + + const alignedStoredNextGrant = alignToScheduleTime(nextGrantRawDate); + const futureStoredNextGrant = + alignedStoredNextGrant && + alignedStoredNextGrant.getTime() >= now.getTime() + ? alignedStoredNextGrant + : null; + + const fallbackInitialGrant = + isAnnual && createdAtDate + ? computeFirstAnnualGrantFromCreation(createdAtDate) + : null; + const futureFallbackGrant = + fallbackInitialGrant && + fallbackInitialGrant.getTime() >= now.getTime() + ? fallbackInitialGrant + : null; + + let resolvedNextGrantDate = + futureStoredNextGrant || futureFallbackGrant || null; + + if (futureStoredNextGrant && futureFallbackGrant) { + resolvedNextGrantDate = + futureFallbackGrant.getTime() < futureStoredNextGrant.getTime() + ? futureFallbackGrant + : futureStoredNextGrant; + } + const nextGrantLabel = - nextGrantDate && isAnnual ? formatDate(nextGrantDate) : null; + resolvedNextGrantDate && isAnnual + ? formatDate(resolvedNextGrantDate) + : null; const helperMessages = []; if (!hasActiveSubscription && hasAnySubscription) { @@ -374,7 +461,7 @@ const ManageSubscription = ({ navigation }) => { coinsPerMonth: normalizedCoins, coinsPerMonthLabel, isAnnual, - nextGrantDate, + nextGrantDate: resolvedNextGrantDate, nextGrantLabel, }; }, [currentUserData, remoteSubscription]); diff --git a/src/screens/ResetPassword.js b/src/screens/ResetPassword.js new file mode 100644 index 0000000..5747cd1 --- /dev/null +++ b/src/screens/ResetPassword.js @@ -0,0 +1,298 @@ +import { useRoute } from "@react-navigation/native"; +import React, { useEffect, useMemo, useState, useGlobal } from "reactn"; +import { Text, View } from "react-native"; +import { + checkIfPasswordIsStrongEnough, + handleFirebaseError, +} from "../actions/signupActions"; +import { background } from "../assets"; +import GradientButton from "../components/GradientButton"; +import { Input } from "../components/Input"; +import ItemContainer from "../components/ItemContainer/ItemContainer"; +import firebase from "../config/firebase"; +import Page from "../layouts/Page"; +import { isWeb } from "../hooks/useLayoutType"; +import { Routes } from "../navigation"; +import { navigate } from "../navigation/NavigationService"; +import { Palette } from "../styles"; +import { FONT_FAMILY } from "../styles/Fonts"; + +const PASSWORD_ERROR_MESSAGE = + "Ton mot de passe doit contenir au moins 6 caractères et combiner plusieurs types de caractères."; +const PASSWORD_MISMATCH_MESSAGE = "Les mots de passe ne correspondent pas."; +const INVALID_LINK_MESSAGE = + "Ce lien de réinitialisation n'est plus valide. Demande un nouveau mot de passe."; + +const ResetPassword = () => { + const route = useRoute(); + const oobCode = (route?.params?.oobCode || "").trim(); + + const [, setTooltip] = useGlobal("_tooltip"); + const [, setIsLoading] = useGlobal("_isLoading"); + + const [email, setEmail] = useState(route?.params?.email || ""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordError, setPasswordError] = useState(""); + const [confirmError, setConfirmError] = useState(""); + const [codeError, setCodeError] = useState(""); + const [isVerifyingCode, setIsVerifyingCode] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + + const trimmedPassword = useMemo(() => (password || "").trim(), [password]); + const trimmedConfirmPassword = useMemo( + () => (confirmPassword || "").trim(), + [confirmPassword] + ); + + const isPasswordValid = useMemo(() => { + if (!trimmedPassword.length) return false; + return checkIfPasswordIsStrongEnough({ password: trimmedPassword }); + }, [trimmedPassword]); + + const doPasswordsMatch = useMemo(() => { + if (!trimmedPassword.length || !trimmedConfirmPassword.length) return false; + return trimmedPassword === trimmedConfirmPassword; + }, [trimmedPassword, trimmedConfirmPassword]); + + useEffect(() => { + const verifyCode = async () => { + if (!oobCode) { + setCodeError(INVALID_LINK_MESSAGE); + setTooltip({ text: INVALID_LINK_MESSAGE, type: "error" }); + setIsVerifyingCode(false); + return; + } + + try { + setIsLoading(true); + const resetEmail = await firebase + .auth() + .verifyPasswordResetCode(oobCode); + setEmail(resetEmail); + setCodeError(""); + } catch (error) { + console.log("ResetPassword verify error", error?.message); + const message = + error?.code && error.code.startsWith("auth/") + ? handleFirebaseError(error.code) + : error?.message || INVALID_LINK_MESSAGE; + setCodeError(message || INVALID_LINK_MESSAGE); + setTooltip({ text: message || INVALID_LINK_MESSAGE, type: "error" }); + } finally { + setIsLoading(false); + setIsVerifyingCode(false); + } + }; + + verifyCode(); + }, [oobCode, setIsLoading, setTooltip]); + + useEffect(() => { + if (!password?.length) { + setPasswordError(""); + return; + } + + if (!isPasswordValid) { + setPasswordError(PASSWORD_ERROR_MESSAGE); + } else { + setPasswordError(""); + } + }, [password, isPasswordValid]); + + useEffect(() => { + if (!confirmPassword?.length) { + setConfirmError(""); + return; + } + + if (!doPasswordsMatch) { + setConfirmError(PASSWORD_MISMATCH_MESSAGE); + } else { + setConfirmError(""); + } + }, [confirmPassword, doPasswordsMatch]); + + const handleSubmit = async () => { + if (!isPasswordValid) { + setPasswordError(PASSWORD_ERROR_MESSAGE); + return; + } + if (!doPasswordsMatch) { + setConfirmError(PASSWORD_MISMATCH_MESSAGE); + return; + } + + try { + setIsSubmitting(true); + setIsLoading(true); + await firebase.auth().confirmPasswordReset(oobCode, trimmedPassword); + setTooltip({ + text: "Ton mot de passe a été mis à jour. Tu peux te connecter.", + type: "success", + }); + navigate(Routes.Login); + } catch (error) { + console.log("ResetPassword submit error", error?.message); + const message = + error?.code && error.code.startsWith("auth/") + ? handleFirebaseError(error.code) + : error?.message || "Impossible de mettre à jour le mot de passe."; + setTooltip({ text: message, type: "error" }); + } finally { + setIsLoading(false); + setIsSubmitting(false); + } + }; + + const renderForm = () => ( + <> + + + Définis un nouveau mot de passe + + + Utilise au moins 6 caractères pour sécuriser ton compte. + + {email ? ( + + Compte concerné : {email} + + ) : null} + + + + + {passwordError ? ( + + {passwordError} + + ) : null} + + {confirmError ? ( + + {confirmError} + + ) : null} + + + ); + + const renderCodeError = () => ( + + + Lien invalide ou expiré + + + {codeError || INVALID_LINK_MESSAGE} + + + ); + + const handleFallbackNavigation = () => { + navigate(Routes.ForgotPassword); + }; + + const buttonDisabled = codeError + ? isSubmitting + : isVerifyingCode || + isSubmitting || + !isPasswordValid || + !doPasswordsMatch; + + const buttonLabel = codeError + ? "Demander un nouveau lien" + : isSubmitting + ? "Mise à jour..." + : isVerifyingCode + ? "Vérification..." + : "Mettre à jour le mot de passe"; + + const buttonAction = codeError ? handleFallbackNavigation : handleSubmit; + + return ( + + + + + {codeError ? renderCodeError() : renderForm()} + + + + + + ); +}; + +export default ResetPassword; diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js index e31cd1a..c015a2d 100644 --- a/src/screens/Studio/ComposeSong.js +++ b/src/screens/Studio/ComposeSong.js @@ -6,6 +6,7 @@ import BorderGradientButton from "../../components/BorderGradientButton"; import GradientButton from "../../components/GradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; import AppAlert from "../../components/Alert"; +import CoinIcon from "../../components/CoinIcon"; import firebase, { getFunctionsClient } from "../../config/firebase"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; @@ -338,27 +339,69 @@ const ComposeSong = () => { > Générer la musique ? - + + + Cette action coûte{" "} + + + {MUSIC_GENERATION_COIN_COST} + + + + + Souhaites-tu les utiliser pour lancer la génération ? + + + - Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces. - {"\n"}Souhaites-tu les utiliser pour lancer la génération ? - - - Solde disponible : {formattedCoinBalance} pièces - + + Solde disponible : {formattedCoinBalance} + + + { > Générer la musique ? - + + + Cette action coûte{" "} + + + {MUSIC_GENERATION_COIN_COST} + + + + + Souhaites-tu les utiliser pour lancer la génération ? + + + - Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces. - {"\n"}Souhaites-tu les utiliser pour lancer la génération ? - - - Solde disponible : {formattedCoinBalance} pièces - + + Solde disponible : {formattedCoinBalance} + + + { return FAKE_PROGRESS_MAX; } - const increment = - prevProgress < 60 - ? 1 - : prevProgress < 80 - ? 0.6 - : 0.3; + const increment = prevProgress < 60 ? 1 : prevProgress < 80 ? 0.6 : 0.3; const next = prevProgress + increment; return next >= FAKE_PROGRESS_MAX ? FAKE_PROGRESS_MAX : next; @@ -79,7 +74,7 @@ const CreatingLyrics = ({ active, config, selections }) => { }); const sanitizedStructure = sanitizeStructureList( config?.structure, - CUSTOM_SANITIZE_OPTIONS + CUSTOM_SANITIZE_OPTIONS, ); const callable = firebase .functions() @@ -142,7 +137,7 @@ const CreatingLyrics = ({ active, config, selections }) => { const rawMessage = typeof e?.message === "string" ? e.message : ""; const cleanedMessage = rawMessage.replace( /^functions error: \w+-\w+:\s*/i, - "" + "", ); setError({ message: @@ -172,7 +167,7 @@ const CreatingLyrics = ({ active, config, selections }) => { console.log("💾 [CreatingLyrics] Sauvegarde automatique des paroles"); const sanitizedStructure = sanitizeStructureList( result?.structure || config?.structure, - CUSTOM_SANITIZE_OPTIONS + CUSTOM_SANITIZE_OPTIONS, ); const baseConfig = config && typeof config === "object" && !Array.isArray(config) @@ -180,26 +175,30 @@ const CreatingLyrics = ({ active, config, selections }) => { : {}; if (sanitizedStructure.length > 0) { baseConfig.structure = sanitizedStructure; - } else if (Object.prototype.hasOwnProperty.call(baseConfig, "structure")) { + } else if ( + Object.prototype.hasOwnProperty.call(baseConfig, "structure") + ) { delete baseConfig.structure; } const persistedConfig = Object.keys(baseConfig).length > 0 ? baseConfig : null; let persistedSelections = - selections && typeof selections === "object" && !Array.isArray(selections) + selections && + typeof selections === "object" && + !Array.isArray(selections) ? { ...selections } : null; if (persistedSelections) { if ("customStructure" in persistedSelections) { persistedSelections.customStructure = sanitizeStructureList( persistedSelections.customStructure, - CUSTOM_SANITIZE_OPTIONS + CUSTOM_SANITIZE_OPTIONS, ); } if ("parsedStructure" in persistedSelections) { persistedSelections.parsedStructure = sanitizeStructureList( - persistedSelections.parsedStructure + persistedSelections.parsedStructure, ); } } @@ -252,7 +251,7 @@ const CreatingLyrics = ({ active, config, selections }) => { onPress: redirect, }, ], - { cancelable: false } + { cancelable: false }, ); if (Platform.OS === "web") { @@ -274,22 +273,6 @@ const CreatingLyrics = ({ active, config, selections }) => { height: "50%", }} > - - - { setSaved(true); await setIsLoading(true); console.log( - "📄 [CreatingLyrics] Consultation manuelle du texte" + "📄 [CreatingLyrics] Consultation manuelle du texte", ); await updateProjectData({ title: result?.title || "", @@ -359,7 +342,7 @@ const CreatingLyrics = ({ active, config, selections }) => { } catch (e) { console.error( "⚠️ [CreatingLyrics] Erreur lors de l'ouverture manuelle", - e + e, ); } finally { await setIsLoading(false);