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);