home, subscription and other fixes

This commit is contained in:
Thomas Demirdjian
2025-11-06 17:33:07 +01:00
parent 5f777f3b6d
commit 4365f1e46d
31 changed files with 1579 additions and 348 deletions
+82 -5
View File
@@ -13,6 +13,33 @@ const { sendNotification } = require("./notifications");
const DISTRIBUTION_REVENUE_BASELINE = 1000; const DISTRIBUTION_REVENUE_BASELINE = 1000;
const DISTRIBUTION_RATIO = 0.3; 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( exports.distributeMonthlyPayouts = onSchedule(
{ {
@@ -51,6 +78,51 @@ exports.distributeMonthlyPayouts = onSchedule(
.orderBy(["streams"], ["desc"]) .orderBy(["streams"], ["desc"])
.value(); .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 payoutsTotalStreamsFromDocs = _.sumBy(entries, "streams");
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null; const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null;
let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0)); let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0));
@@ -63,9 +135,9 @@ exports.distributeMonthlyPayouts = onSchedule(
2, 2,
); );
let allocations = _.map(entries, (entry) => { let allocations = _.map(eligibleEntries, (entry) => {
if (!totalStreams) return 0; if (!eligibleTotalStreams) return 0;
const rawAmount = (payoutPool * entry.streams) / totalStreams; const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams;
return _.round(rawAmount, 2); 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, rank: idx + 1,
projectId: entry.projectId, projectId: entry.projectId,
userId: entry.userId, userId: entry.userId,
streams: entry.streams, streams: entry.streams,
share: totalStreams ? _.round(entry.streams / totalStreams, 6) : 0, share: eligibleTotalStreams
? _.round(entry.streams / eligibleTotalStreams, 6)
: 0,
amount: allocations[idx], amount: allocations[idx],
statsDocPath: entry.statsDocPath, statsDocPath: entry.statsDocPath,
})); }));
@@ -192,7 +266,10 @@ exports.distributeMonthlyPayouts = onSchedule(
payoutRatio: DISTRIBUTION_RATIO, payoutRatio: DISTRIBUTION_RATIO,
payoutPool, payoutPool,
totalStreams, totalStreams,
eligibleTotalStreams,
totalRecipients: payouts.length, totalRecipients: payouts.length,
totalEntries: entries.length,
eligibleEntries: eligibleEntries.length,
totalAllocated: _.round(_.sumBy(payouts, "amount"), 2), totalAllocated: _.round(_.sumBy(payouts, "amount"), 2),
payouts, payouts,
status: payouts.length ? "computed" : "no-data", status: payouts.length ? "computed" : "no-data",
+158 -16
View File
@@ -623,8 +623,12 @@ const handleCustomerSubscriptionEvent = async (
if (!userRef) { if (!userRef) {
console.warn( console.warn(
"[subscription-handleCustomerSubscriptionEvent] User not resolved", "[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; return;
} }
@@ -797,10 +801,46 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
const eventType = event?.type || null; const eventType = event?.type || null;
const paymentDocRef = paymentsCollection.doc(invoice.id); 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, { await upsertPaymentDocument(invoice.id, {
userId: firebaseUid || null, userId: firebaseUid || null,
customerId: invoice.customer || null, customerId: invoice.customer || null,
subscriptionId: invoice.subscription || null, subscriptionId: resolvedSubscriptionId,
status: invoice.status || null, status: invoice.status || null,
paymentStatus: paymentStatus:
eventType === "invoice.payment_failed" eventType === "invoice.payment_failed"
@@ -834,6 +874,12 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
}); });
if (!userRef) { 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; return;
} }
@@ -862,10 +908,47 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
const billingReason = invoice.billing_reason || null; const billingReason = invoice.billing_reason || null;
const isInvoicePaid = invoice.status === "paid"; 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 = const isSubscriptionInvoice =
["subscription_cycle", "subscription_create"].includes(billingReason) && isSubscriptionBillingReason && Boolean(resolvedSubscriptionId);
typeof invoice.subscription === "string" &&
invoice.subscription;
const shouldProcessAllowance = const shouldProcessAllowance =
eventType === "invoice.paid" && isInvoicePaid && isSubscriptionInvoice; eventType === "invoice.paid" && isInvoicePaid && isSubscriptionInvoice;
@@ -875,7 +958,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
console.log("[subscription-handleInvoiceEvent] Processing subscription allowance", { console.log("[subscription-handleInvoiceEvent] Processing subscription allowance", {
invoiceId: invoice.id || null, invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null, subscriptionId: resolvedSubscriptionId || null,
customer: invoice.customer || null, customer: invoice.customer || null,
billingReason, billingReason,
orderTargetUid, orderTargetUid,
@@ -917,6 +1000,21 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
if (expandedPrice && typeof expandedPrice === "object") { if (expandedPrice && typeof expandedPrice === "object") {
stripePrice = expandedPrice; 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) { } catch (error) {
console.warn( console.warn(
"[subscription-handleInvoiceEvent] Unable to expand invoice price", "[subscription-handleInvoiceEvent] Unable to expand invoice price",
@@ -932,6 +1030,21 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
}; };
const stripePrice = await loadInvoicePrice(); 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( let coinsPerMonth = parseCoinsPerMonth(
(stripePrice?.product && typeof stripePrice.product === "object" (stripePrice?.product && typeof stripePrice.product === "object"
? stripePrice.product.metadata ? stripePrice.product.metadata
@@ -967,7 +1080,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
"[subscription-handleInvoiceEvent] Using static allowance from price metadata", "[subscription-handleInvoiceEvent] Using static allowance from price metadata",
{ {
invoiceId: invoice.id || null, invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null, subscriptionId: resolvedSubscriptionId || null,
priceId: invoicePriceId, priceId: invoicePriceId,
coinsPerMonth, coinsPerMonth,
}, },
@@ -985,7 +1098,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
"[subscription-handleInvoiceEvent] Using allowance from invoice metadata", "[subscription-handleInvoiceEvent] Using allowance from invoice metadata",
{ {
invoiceId: invoice.id || null, invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null, subscriptionId: resolvedSubscriptionId || null,
priceId: invoicePriceId, priceId: invoicePriceId,
coinsPerMonth, coinsPerMonth,
}, },
@@ -1001,7 +1114,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
"[subscription-handleInvoiceEvent] Using allowance from user profile cache", "[subscription-handleInvoiceEvent] Using allowance from user profile cache",
{ {
invoiceId: invoice.id || null, invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null, subscriptionId: resolvedSubscriptionId || null,
priceId: invoicePriceId, priceId: invoicePriceId,
coinsPerMonth, 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; const recurringInterval = stripePrice?.recurring?.interval || null;
let billingPeriod = userData?.premiumBillingPeriod || null; let billingPeriod = userData?.premiumBillingPeriod || null;
if (recurringInterval === "month") { if (recurringInterval === "month") {
@@ -1022,7 +1146,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
console.log("[subscription-handleInvoiceEvent] Allowance context", { console.log("[subscription-handleInvoiceEvent] Allowance context", {
invoiceId: invoice.id || null, invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null, subscriptionId: resolvedSubscriptionId || null,
coinsPerMonth, coinsPerMonth,
billingPeriod, billingPeriod,
recurringInterval, recurringInterval,
@@ -1033,7 +1157,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
if (coinsPerMonth && coinsPerMonth > 0 && orderTargetUid) { if (coinsPerMonth && coinsPerMonth > 0 && orderTargetUid) {
console.log("[subscription-handleInvoiceEvent] Preparing coin grant", { console.log("[subscription-handleInvoiceEvent] Preparing coin grant", {
invoiceId: invoice.id || null, invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null, subscriptionId: resolvedSubscriptionId || null,
userId: orderTargetUid, userId: orderTargetUid,
coinsPerMonth, coinsPerMonth,
billingPeriod, billingPeriod,
@@ -1063,7 +1187,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
source: "STRIPE_SUBSCRIPTION", source: "STRIPE_SUBSCRIPTION",
schedule: isAnnual ? "annual_invoice" : "monthly_invoice", schedule: isAnnual ? "annual_invoice" : "monthly_invoice",
invoiceId: invoice.id, invoiceId: invoice.id,
subscriptionId: invoice.subscription || null, subscriptionId: resolvedSubscriptionId || null,
billingPeriod, billingPeriod,
billingReason, billingReason,
}, },
@@ -1085,7 +1209,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
console.log("[subscription-handleInvoiceEvent] Subscription coins granted", { console.log("[subscription-handleInvoiceEvent] Subscription coins granted", {
orderId: processedOrderId, orderId: processedOrderId,
invoiceId: invoice.id, invoiceId: invoice.id,
subscriptionId: invoice.subscription || null, subscriptionId: resolvedSubscriptionId || null,
userId: orderTargetUid, userId: orderTargetUid,
amount: coinsPerMonth, amount: coinsPerMonth,
billingPeriod, billingPeriod,
@@ -1131,14 +1255,14 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
} else { } else {
console.log("[subscription-handleInvoiceEvent] Coins already granted for invoice", { console.log("[subscription-handleInvoiceEvent] Coins already granted for invoice", {
invoiceId: invoice.id, invoiceId: invoice.id,
subscriptionId: invoice.subscription || null, subscriptionId: resolvedSubscriptionId || null,
userId: orderTargetUid, userId: orderTargetUid,
}); });
} }
} else { } else {
console.log("[subscription-handleInvoiceEvent] Skipping allowance grant", { console.log("[subscription-handleInvoiceEvent] Skipping allowance grant", {
invoiceId: invoice.id || null, invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null, subscriptionId: resolvedSubscriptionId || null,
coinsPerMonth, coinsPerMonth,
orderTargetUid, orderTargetUid,
billingPeriod, 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 }); await userRef.set(userUpdate, { merge: true });
}; };
@@ -1208,6 +1343,13 @@ const handleStripeWebhookEvent = async ({ event, stripe }) => {
return; 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) { switch (event.type) {
case "checkout.session.completed": case "checkout.session.completed":
await handleCheckoutSessionCompleted(event.data?.object, event, { await handleCheckoutSessionCompleted(event.data?.object, event, {
+6
View File
@@ -74,6 +74,12 @@ export function handleFirebaseError(code = "") {
return "Ton mot de passe est trop faible."; return "Ton mot de passe est trop faible.";
case "auth/network-request-failed": case "auth/network-request-failed":
return "Problème de connexion réseau. Vérifie ta connexion et réessaie."; 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: default:
return "Une erreur est survenue. Réessaie dans quelques instants."; return "Une erreur est survenue. Réessaie dans quelques instants.";
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 MiB

After

Width:  |  Height:  |  Size: 26 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 KiB

+15
View File
@@ -186,6 +186,8 @@ export const icons = {
hitParadeLogo, hitParadeLogo,
calendar, calendar,
coin, coin,
club: require("./icons/club.png"),
clubIcon: require("./icons/clubIcon.png"),
}; };
export const background = { export const background = {
@@ -236,3 +238,16 @@ export const img = {
profile, profile,
goodVibe, 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"),
};
+21
View File
@@ -0,0 +1,21 @@
import React from "react";
import { Image } from "react-native";
import { icons } from "../assets";
const CoinIcon = ({ size = 22, style }) => {
return (
<Image
source={icons.coin}
style={[
{
width: size,
height: size,
resizeMode: "contain",
},
style,
]}
/>
);
};
export default CoinIcon;
@@ -25,9 +25,10 @@ import { FONT_FAMILY } from "../../styles/Fonts";
import GradientButton from "../GradientButton"; import GradientButton from "../GradientButton";
const BUTTON_BLUR_INTENSITY = 20; const BUTTON_BLUR_INTENSITY = 20;
const DROPDOWN_BACKGROUND_COLOR = "#252438";
const IS_WEB = Platform.OS === "web"; const IS_WEB = Platform.OS === "web";
const WEB_BLUR_FALLBACK_STYLE = { const WEB_BLUR_FALLBACK_STYLE = {
backgroundColor: Palette.glass, backgroundColor: "transparent",
}; };
const BLUR_VIEW_PROPS = const BLUR_VIEW_PROPS =
Platform.OS === "android" Platform.OS === "android"
@@ -479,6 +480,7 @@ const styles = StyleSheet.create({
padding: 8, padding: 8,
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
backgroundColor: DROPDOWN_BACKGROUND_COLOR,
}, },
dropdownBlurDisabled: { dropdownBlurDisabled: {
opacity: 0.6, opacity: 0.6,
@@ -507,6 +509,7 @@ const styles = StyleSheet.create({
paddingVertical: 8, paddingVertical: 8,
gap: 9, gap: 9,
maxHeight: 400, maxHeight: 400,
backgroundColor: DROPDOWN_BACKGROUND_COLOR,
}, },
dropdownOverlayContent: { dropdownOverlayContent: {
maxHeight: 260, maxHeight: 260,
@@ -560,6 +563,7 @@ const styles = StyleSheet.create({
paddingVertical: 12, paddingVertical: 12,
borderRadius: 10, borderRadius: 10,
overflow: "hidden", overflow: "hidden",
backgroundColor: DROPDOWN_BACKGROUND_COLOR,
}, },
projectImage: { projectImage: {
width: 60, width: 60,
+97 -22
View File
@@ -4,14 +4,17 @@ import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import React from "reactn"; import React from "reactn";
import { responsiveHeight } from "../actions/responsiveSizes.js"; import { responsiveHeight } from "../actions/responsiveSizes.js";
import { icons } from "../assets"; import { icons, subBadges } from "../assets";
import BaseHeader from "../components/BaseHeader"; import BaseHeader from "../components/BaseHeader";
import CoinIcon from "../components/CoinIcon";
import ConnectBtn from "../components/ConnectBtn.js"; import ConnectBtn from "../components/ConnectBtn.js";
import CoinPackModal from "../components/modal/CoinPackModal"; import CoinPackModal from "../components/modal/CoinPackModal";
import NavigateHeader from "../components/NavigateHeader"; import NavigateHeader from "../components/NavigateHeader";
import ShareBtn from "../components/ShareBtn/ShareBtn"; import ShareBtn from "../components/ShareBtn/ShareBtn";
import { isWeb } from "../hooks/useLayoutType.js"; import { isWeb } from "../hooks/useLayoutType.js";
import { useUserData } from "../providers/UserDataProvider"; import { useUserData } from "../providers/UserDataProvider";
import { Routes } from "../navigation";
import { navigate } from "../navigation/NavigationService";
import { gutters } from "../styles"; import { gutters } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from "../styles/Fonts";
import { subscribeCoinPackModal } from "../utils/coinPackModal"; import { subscribeCoinPackModal } from "../utils/coinPackModal";
@@ -75,6 +78,61 @@ export default ({
const showCoinBadge = isWeb && !!currentUID && showCoin; const showCoinBadge = isWeb && !!currentUID && showCoin;
const [isCoinModalVisible, setCoinModalVisible] = React.useState(false); 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(() => { const handleCoinPress = React.useCallback(() => {
if (!showCoinBadge) return; if (!showCoinBadge) return;
setCoinModalVisible(true); setCoinModalVisible(true);
@@ -149,39 +207,56 @@ export default ({
/> />
) : null} ) : null}
{showCoinBadge ? ( {showCoinBadge ? (
<Pressable <View
onPress={handleCoinPress}
accessibilityRole="button"
style={{ style={{
position: "absolute", position: "absolute",
top: gutters, top: gutters,
left: gutters, left: gutters,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: "rgba(12, 14, 18, 0.72)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 10, gap: 6,
zIndex: 20, zIndex: 20,
}} }}
> >
<Image <Pressable
source={icons.coin} onPress={handleCoinPress}
style={{ width: 22, height: 22, resizeMode: "contain" }} accessibilityRole="button"
/>
<Text
style={{ style={{
color: "#ffffff", paddingHorizontal: 14,
fontFamily: FONT_FAMILY.InterSemiBold, paddingVertical: 8,
fontSize: 14, borderRadius: 20,
backgroundColor: "rgba(12, 14, 18, 0.72)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
flexDirection: "row",
alignItems: "center",
gap: 8,
}} }}
> >
{formattedCoins} <CoinIcon size={22} />
</Text> <Text
</Pressable> style={{
color: "#ffffff",
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 14,
}}
>
{formattedCoins}
</Text>
</Pressable>
{subscriptionBadgeSource ? (
<Pressable
onPress={() => navigate(Routes.ManageSubscription)}
accessibilityRole="button"
style={{ paddingVertical: 4 }}
>
<Image
source={subscriptionBadgeSource}
style={{ height: 40, width: 40, resizeMode: "contain" }}
/>
</Pressable>
) : null}
</View>
) : null} ) : null}
<PageContainer <PageContainer
{...(containerType === "SAFE_AREA_VIEW" ? { edges: ["top"] } : {})} {...(containerType === "SAFE_AREA_VIEW" ? { edges: ["top"] } : {})}
+6
View File
@@ -13,6 +13,7 @@ import Research from "../screens/Library/Research";
import Login from "../screens/Login"; import Login from "../screens/Login";
import NewMusicOptions from "../screens/NewMusicOptions"; import NewMusicOptions from "../screens/NewMusicOptions";
import Onboarding from "../screens/Onboarding"; import Onboarding from "../screens/Onboarding";
import ResetPassword from "../screens/ResetPassword";
import ChooseDecor from "../screens/Playback/ChooseDecor"; import ChooseDecor from "../screens/Playback/ChooseDecor";
import CreatingDecor from "../screens/Playback/CreatingDecor"; import CreatingDecor from "../screens/Playback/CreatingDecor";
import Playback from "../screens/Playback/Playback"; import Playback from "../screens/Playback/Playback";
@@ -97,6 +98,11 @@ const baseScreens = [
component: ForgotPassword, component: ForgotPassword,
title: "Mot de passe oublié", title: "Mot de passe oublié",
}, },
{
name: Routes.ResetPassword,
component: ResetPassword,
title: "Réinitialiser le mot de passe",
},
{ {
name: Routes.BottomTab, name: Routes.BottomTab,
component: BottomTabScreen, component: BottomTabScreen,
+1
View File
@@ -3,6 +3,7 @@ export const Routes = {
Onboarding: "Onboarding", Onboarding: "Onboarding",
Login: "Login", Login: "Login",
ForgotPassword: "ForgotPassword", ForgotPassword: "ForgotPassword",
ResetPassword: "ResetPassword",
Register: "Register", Register: "Register",
CreatePassword: "CreatePassword", CreatePassword: "CreatePassword",
CreatePseudo: "CreatePseudo", CreatePseudo: "CreatePseudo",
+1
View File
@@ -55,6 +55,7 @@ const HIDDEN_ROUTE_NAMES = new Set([
Routes.Notifications, Routes.Notifications,
Routes.Language, Routes.Language,
Routes.Login, Routes.Login,
Routes.ResetPassword,
Routes.Register, Routes.Register,
]); ]);
+36
View File
@@ -191,6 +191,12 @@ const UniversalLinkProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
if (!pendingNavigation || !isFullyLoaded) return; if (!pendingNavigation || !isFullyLoaded) return;
if (pendingNavigation.type === "resetPassword") {
navigate(Routes.ResetPassword, pendingNavigation.params);
setPendingNavigation(null);
return;
}
if (pendingNavigation.type === "task") { if (pendingNavigation.type === "task") {
if (!currentUID) return; if (!currentUID) return;
navigateToTask(pendingNavigation.params); navigateToTask(pendingNavigation.params);
@@ -211,6 +217,36 @@ const UniversalLinkProvider = ({ children }) => {
}, [pendingNavigation, currentUID, isFullyLoaded]); }, [pendingNavigation, currentUID, isFullyLoaded]);
const handleParams = (path, queryParams = {}) => { const handleParams = (path, queryParams = {}) => {
const normalizedPath = normalizePath(path); 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) return;
if (normalizedPath.toLowerCase() === "link") { if (normalizedPath.toLowerCase() === "link") {
+29 -4
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef, useGlobal } from "reactn"; import React, { useState, useGlobal } from "reactn";
import { Text, KeyboardAvoidingView } from "react-native"; import { Text, KeyboardAvoidingView } from "react-native";
import { responsiveHeight } from "../actions/responsiveSizes.js"; import { responsiveHeight } from "../actions/responsiveSizes.js";
@@ -11,6 +11,10 @@ import { Fonts, Style } from "../styles";
import Page from "../layouts/Page"; import Page from "../layouts/Page";
import { background } from "../assets"; import { background } from "../assets";
import { isWeb } from "../hooks/useLayoutType"; import { isWeb } from "../hooks/useLayoutType";
import {
checkIfEmailIsValid,
handleFirebaseError,
} from "../actions/signupActions.js";
export default ({ navigation }) => { export default ({ navigation }) => {
const [, setTooltip] = useGlobal("_tooltip"); const [, setTooltip] = useGlobal("_tooltip");
@@ -19,10 +23,27 @@ export default ({ navigation }) => {
const [email, setEmail] = useState(__DEV__ ? "hello@minuit.agency" : ""); const [email, setEmail] = useState(__DEV__ ? "hello@minuit.agency" : "");
const onResetPassword = async () => { 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 { try {
setIsLoading(true); setIsLoading(true);
await firebase.auth().sendPasswordResetEmail(email); await firebase.auth().sendPasswordResetEmail(trimmedEmail);
setTooltip({ setTooltip({
text: "Email de réinitialisation envoyé!", text: "Email de réinitialisation envoyé!",
@@ -31,9 +52,13 @@ export default ({ navigation }) => {
navigation.goBack(); navigation.goBack();
} catch (error) { } 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({ setTooltip({
text: "Une erreur est survenue", text: message,
type: "error", type: "error",
}); });
} finally { } finally {
+226 -226
View File
@@ -5,13 +5,11 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { Platform, StyleSheet, View } from "react-native"; import { StyleSheet, Text, View } from "react-native";
import { useIsFocused } from "@react-navigation/native"; import { Image as ExpoImage } from "expo-image";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; 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 Alert from "../../components/Alert";
import BorderGradientButton from "../../components/BorderGradientButton";
import FeatureCarousel from "../../components/FeatureCarousel/FeatureCarousel";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo"; import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
import GradientButton from "../../components/GradientButton"; import GradientButton from "../../components/GradientButton";
import MoreMenu from "../../components/MoreMenu"; import MoreMenu from "../../components/MoreMenu";
@@ -26,11 +24,13 @@ import { navigate } from "../../navigation/NavigationService";
import { Routes } from "../../navigation/Routes"; import { Routes } from "../../navigation/Routes";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { Palette } from "../../styles"; import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { import {
findFirstUnlockedStageIndex,
getCreationStageStates, getCreationStageStates,
getStageAction, getStageAction,
} from "../../utils/projectStages"; } from "../../utils/projectStages";
import StageCard from "./components/StageCard";
import ClubCard from "./components/ClubCard";
const isProjectEmpty = (project) => { const isProjectEmpty = (project) => {
if (!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 dinterpreter 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 tinté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 Home = ({ navigation, route }) => {
const isFocused = useIsFocused();
const { const {
userProjects = [], userProjects = [],
selectProject, selectProject,
@@ -61,7 +108,7 @@ const Home = ({ navigation, route }) => {
const projects = useMemo( const projects = useMemo(
() => (Array.isArray(userProjects) ? userProjects : []), () => (Array.isArray(userProjects) ? userProjects : []),
[userProjects] [userProjects],
); );
const currentProject = useMemo(() => { const currentProject = useMemo(() => {
@@ -94,73 +141,34 @@ const Home = ({ navigation, route }) => {
const stageStates = useMemo( const stageStates = useMemo(
() => getCreationStageStates(currentProject), () => getCreationStageStates(currentProject),
[currentProject] [currentProject],
); );
const preferredStageIndex = useMemo(() => { const stageStatesByKey = useMemo(() => {
if (!stageStates.length) { if (!Array.isArray(stageStates)) {
return 0; return {};
} }
const actionableIndex = stageStates.findIndex( return stageStates.reduce((acc, stage) => {
(stage) => !stage.isCompleted && !stage.isLocked if (stage?.key) {
); acc[stage.key] = stage;
if (actionableIndex !== -1) { }
return actionableIndex; return acc;
} }, {});
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;
}, [stageStates]); }, [stageStates]);
const [activeStageIndex, setActiveStageIndex] = useState(preferredStageIndex);
const [menuVisible, setMenuVisible] = useState(false); const [menuVisible, setMenuVisible] = useState(false);
const [menuAnchor, setMenuAnchor] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null);
const [menuProject, setMenuProject] = useState(null); const [menuProject, setMenuProject] = useState(null);
const menuAnchorRef = useRef(null); const menuAnchorRef = useRef(null);
const testLoaderTimeoutRef = useRef(null);
const [isIntroVideoVisible, setIsIntroVideoVisible] = useState(false); const [isIntroVideoVisible, setIsIntroVideoVisible] = useState(false);
const [hasLocalAdventureFlag, setHasLocalAdventureFlag] = 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( const handleSelectProject = useCallback(
(project) => { (project) => {
if (!project?.id) return; if (!project?.id) return;
selectProject(project.id); selectProject(project.id);
setActiveStageIndex(findFirstUnlockedStageIndex(project));
}, },
[selectProject] [selectProject],
); );
const handleCloseMenu = useCallback(() => { 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); navigate(targetRoute, beatmakerStage?.params);
}, },
}, },
] ],
); );
navigation?.setParams?.({ showLyricsCongrats: false }); navigation?.setParams?.({ showLyricsCongrats: false });
}, [ }, [
@@ -273,19 +281,6 @@ const Home = ({ navigation, route }) => {
route?.params?.showLyricsCongrats, 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 hasActiveProject = !!currentProject;
const ensureProjectSelected = useCallback(() => { const ensureProjectSelected = useCallback(() => {
@@ -297,32 +292,9 @@ const Home = ({ navigation, route }) => {
} }
}, [currentProject?.id, selectProject, selectedProject?.id]); }, [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( const songwriterAction = useMemo(
() => getStageAction("songwriter", null), () => getStageAction("songwriter", null),
[] [],
); );
const handleStartNew = useCallback(async () => { const handleStartNew = useCallback(async () => {
@@ -335,7 +307,6 @@ const Home = ({ navigation, route }) => {
if (emptyProject?.id) { if (emptyProject?.id) {
selectProject(emptyProject.id); selectProject(emptyProject.id);
setActiveStageIndex(findFirstUnlockedStageIndex(emptyProject));
navigate(targetRoute, targetParams); navigate(targetRoute, targetParams);
return; return;
} }
@@ -344,7 +315,6 @@ const Home = ({ navigation, route }) => {
if (!newProjectId) { if (!newProjectId) {
return; return;
} }
setActiveStageIndex(0);
navigate(targetRoute, targetParams); navigate(targetRoute, targetParams);
} catch (error) { } catch (error) {
console.warn("Home: unable to start new project", 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", text: "Impossible de démarrer un nouveau projet",
}); });
} }
}, [ }, [createNewProject, projects, selectProject, setTooltip, songwriterAction]);
createNewProject,
projects,
selectProject,
setActiveStageIndex,
setTooltip,
songwriterAction,
]);
const continueDisabled = !hasActiveProject || stageLocked || !stageRoute;
const startDisabled = !songwriterAction?.route;
useEffect(() => { useEffect(() => {
if (currentUserData?.adventureStarted) { if (currentUserData?.adventureStarted) {
@@ -412,104 +372,102 @@ const Home = ({ navigation, route }) => {
simpleRef: true, simpleRef: true,
}); });
const videoUrl = isWeb ? video?.landingWeb : video?.landing; const videoUrl = isWeb ? video?.landingWeb : video?.landing;
const homeBackgroundImage = isWeb const homeBackgroundImage = background.bgTrans;
? background.homeBGWeb
: background.homeBG;
const landingBackgroundImage = homeBackgroundImage; 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 ? ( return adventureStarted ? (
<Page <View style={styles.root}>
shareBtn <Page
headerType="NONE" shareBtn
containerStyle={{ width: "100%", maxWidth: "100%", paddingHorizontal: 0 }} headerType="NONE"
backgroundImg={homeBackgroundImage} scrollEnabled={false}
> containerStyle={styles.page}
<View style={[styles.root, isWeb ? styles.rootWeb : styles.rootNative]}> contentContainerStyle={styles.pageContent}
<View width="100%"
style={{ maxWidth={null}
zIndex: 10, backgroundColor="#303438"
position: "absolute", >
top: 10, <View style={styles.inner}>
width: 400, <ExpoImage
alignSelf: "center", source={homeBackgroundImage}
flexDirection: isWeb ? "column" : "row", contentFit="cover"
alignItems: "center", style={styles.centerImage}
paddingHorizontal: isWeb ? 0 : 30,
gap: 10,
}}
>
<ProjectDropDown
style={[isWeb ? { width: "100%" } : { flex: 1 }]}
projects={projects}
selectedProject={currentProject}
allowEmptySelection
onSelectProject={handleSelectProject}
onModifyProject={handleModifyProject}
onCreateProject={handleStartNew}
formatDate={formatDate}
/>
{!isWeb && <ShareBtn />}
</View>
<MoreMenu
visible={menuVisible}
top={menuAnchor?.top ?? 0}
position={menuAnchor}
onClose={handleCloseMenu}
inPlaylist={false}
projectId={menuProject?.id || null}
extraItems={moreMenuItems}
/>
<View style={[styles.carouselSection, styles.carouselSectionElevated]}>
<FeatureCarousel
selectedProject={currentProject}
stageStates={stageStates}
activeIndex={activeStageIndex}
onActiveIndexChange={setActiveStageIndex}
backgroundImage={homeBackgroundImage}
isFocused={isFocused}
/>
</View>
<View
style={{
position: "absolute",
bottom: isWeb ? 180 : 140,
flexDirection: isWeb ? "row" : "column",
gap: 5,
alignSelf: "center",
}}
>
<GradientButton
containerStyle={{ width: Platform.OS === "web" ? 250 : "100%" }}
title={"Commencer à créer"}
onPress={handleStartNew}
disabled={startDisabled}
/>
<BorderGradientButton
containerStyle={{ width: Platform.OS === "web" ? 250 : "100%" }}
title={"Continuer la création"}
onPress={handleStageAction}
disabled={continueDisabled}
/> />
{/* <BorderGradientButton <View style={styles.topBar}>
containerStyle={{ width: Platform.OS === "web" ? 250 : "100%" }} <ProjectDropDown
title={"Test Alert"} style={styles.projectDropDown}
onPress={() => projects={projects}
Alert("Test", "Ceci est un test d'alert", [ selectedProject={currentProject}
{ text: "Annuler", style: "cancel" }, allowEmptySelection
{ text: "OK", onPress: () => console.log("OK pressé") }, onSelectProject={handleSelectProject}
]) onModifyProject={handleModifyProject}
} onCreateProject={handleStartNew}
/> */} formatDate={formatDate}
{/* <BorderGradientButton />
containerStyle={{ width: Platform.OS === "web" ? 250 : "100%" }} {!isWeb && <ShareBtn />}
title={"Continuer la création"} </View>
onPress={() => navigate(Routes.SongReady)}
disabled={continueDisabled} <MoreMenu
/> */} visible={menuVisible}
top={menuAnchor?.top ?? 0}
position={menuAnchor}
onClose={handleCloseMenu}
inPlaylist={false}
projectId={menuProject?.id || null}
extraItems={moreMenuItems}
/>
<Text style={styles.subtitle}>5 espaces à découvrir</Text>
<View style={styles.cardsGrid}>
{stageCards.map((card) => (
<StageCard
key={card.key}
step={card.step}
description={card.description}
image={card.image}
imagePosition={card.imagePosition}
textAlign={card.textAlign}
isLocked={card.isLocked}
lockSide={card.lockSide}
onPress={() => handleStagePress(card.key, card.isLocked)}
/>
))}
</View>
<ClubCard image={CLUB_CARD_IMAGE} onPress={handleClubPress} />
</View> </View>
</View> </Page>
</Page> </View>
) : ( ) : (
<> <>
<Page <Page
@@ -545,33 +503,75 @@ export default Home;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
root: { root: {
flex: 1, flex: 1,
backgroundColor: "#303438",
}, },
rootWeb: { page: {
backgroundColor: "transparent", backgroundColor: "transparent",
}, padding: 0,
rootNative: { paddingLeft: isWeb ? 32 : 0,
backgroundColor: "rgba(66, 91, 135, 0.3)", paddingRight: isWeb ? 32 : 0,
}, paddingTop: 0,
heroImage: { paddingBottom: 0,
position: "absolute",
top: -60,
alignSelf: "center",
width: "80%",
maxWidth: 920,
height: 420,
opacity: 0.9,
},
shareIcon: {
width: 18,
height: 18,
tintColor: Palette.white,
},
carouselSection: {
width: "100%", width: "100%",
flex: 1, alignSelf: "stretch",
}, },
carouselSectionElevated: { pageContent: {
marginTop: -100, flexGrow: 1,
paddingTop: 80, },
inner: {
flex: 1,
width: "100%",
alignSelf: "stretch",
alignItems: "stretch",
justifyContent: "flex-start",
position: "relative",
},
centerImage: {
width: HOME_BACKGROUND_WIDTH + 120,
height: HOME_BACKGROUND_HEIGHT + 80,
borderRadius: 22,
overflow: "hidden",
position: "absolute",
top: "45%",
left: "50%",
transform: [
{ translateX: -HOME_BACKGROUND_WIDTH / 2 },
{ translateY: -HOME_BACKGROUND_HEIGHT / 2 },
],
pointerEvents: "none",
},
topBar: {
width: "100%",
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
gap: 12,
marginTop: 24,
marginBottom: 8,
zIndex: 10,
},
projectDropDown: {
width: isWeb ? 420 : "100%",
maxWidth: 420,
flexGrow: 1,
},
subtitle: {
width: "100%",
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
color: Palette.white,
textAlign: "center",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 16,
},
cardsGrid: {
width: "100%",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "space-between",
rowGap: 20,
columnGap: 20,
}, },
}); });
+58
View File
@@ -0,0 +1,58 @@
import React, { memo } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Image as ExpoImage } from "expo-image";
import { FONT_FAMILY } from "../../../styles/Fonts";
import { Palette } from "../../../styles";
import { icons } from "../../../assets";
const ClubCard = ({ image, onPress }) => (
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
>
<View style={styles.inner}>
<ExpoImage
source={icons.club}
contentFit="contain"
style={styles.clubLogo}
/>
<ExpoImage source={image} contentFit="contain" style={styles.image} />
<Text style={styles.subtitle}>Rejoins le club !</Text>
</View>
</Pressable>
);
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,
},
});
+246
View File
@@ -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 = (
<View
style={[
styles.imageContainer,
isImageOnLeft ? styles.imageLeft : styles.imageRight,
]}
>
<ExpoImage
source={image}
contentFit="contain"
style={[
styles.image,
isImageOnLeft ? styles.imageAlignRight : styles.imageAlignLeft,
]}
/>
</View>
);
const header = (
<View
style={[
styles.textHeader,
isTextRight ? styles.textHeaderAlignEnd : styles.textHeaderAlignStart,
]}
>
<Text
style={[
styles.step,
isTextRight ? styles.textRight : styles.textLeft,
]}
numberOfLines={1}
>
{step}
</Text>
</View>
);
const textBlock = (
<BlurView
tint="dark"
intensity={30}
style={[
styles.textContainer,
isImageOnLeft ? styles.textContainerRight : styles.textContainerLeft,
isTextRight ? styles.alignEnd : styles.alignStart,
]}
>
{isLocked ? (
<View
style={[
styles.lockBadge,
isTextRight ? styles.lockBadgeLeft : styles.lockBadgeRight,
]}
>
<FontAwesome name="lock" size={18} color={Palette.primary} />
</View>
) : null}
{header}
<Text
style={[
styles.description,
isTextRight ? styles.textRight : styles.textLeft,
]}
>
{description}
</Text>
</BlurView>
);
const content = isImageOnLeft ? (
<>
{imageBlock}
{textBlock}
</>
) : (
<>
{textBlock}
{imageBlock}
</>
);
return (
<Pressable
onPress={onPress}
disabled={isLocked}
style={({ pressed }) => [
styles.card,
pressed && !isLocked && styles.cardPressed,
]}
>
<View style={styles.inner}>{content}</View>
</Pressable>
);
};
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,
},
});
+67 -3
View File
@@ -12,7 +12,7 @@ import { BlurView } from "expo-blur";
import { Image as ExpoImage } from "expo-image"; import { Image as ExpoImage } from "expo-image";
import GradientButton from "../components/GradientButton"; import GradientButton from "../components/GradientButton";
import Page from "../layouts/Page"; import Page from "../layouts/Page";
import { background } from "../assets"; import { background, subBadges } from "../assets";
import { Palette, gutters } from "../styles"; import { Palette, gutters } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from "../styles/Fonts";
import { isWeb } from "../hooks/useLayoutType"; import { isWeb } from "../hooks/useLayoutType";
@@ -82,6 +82,9 @@ function SubscriptionCard({ plan, selected, onSelect }) {
typeof plan?.coinsPerMonth === "number" && Number.isFinite(plan.coinsPerMonth) typeof plan?.coinsPerMonth === "number" && Number.isFinite(plan.coinsPerMonth)
? Math.round(plan.coinsPerMonth) ? Math.round(plan.coinsPerMonth)
: null; : null;
const planBadgeKey = getPlanKeyForBadge(plan);
const planBadgeSource =
planBadgeKey && subBadges[planBadgeKey] ? subBadges[planBadgeKey] : null;
const handleSelect = React.useCallback(() => { const handleSelect = React.useCallback(() => {
if (typeof onSelect === "function" && plan?.priceId) { if (typeof onSelect === "function" && plan?.priceId) {
onSelect(plan.priceId); onSelect(plan.priceId);
@@ -110,7 +113,16 @@ function SubscriptionCard({ plan, selected, onSelect }) {
) : null} ) : null}
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<Text style={styles.planName}>{planName}</Text> <View style={styles.titleRow}>
<Text style={styles.planName}>{planName}</Text>
{planBadgeSource ? (
<ExpoImage
source={planBadgeSource}
style={styles.planBadgeImage}
contentFit="contain"
/>
) : null}
</View>
{plan?.nickname ? ( {plan?.nickname ? (
<Text style={styles.cardSubtitle}>{plan.nickname}</Text> <Text style={styles.cardSubtitle}>{plan.nickname}</Text>
) : null} ) : 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_FALLBACK_ORDER = ["starter", "pro", "premium"];
const PLAN_SYNONYMS = { const PLAN_SYNONYMS = {
@@ -177,6 +200,33 @@ const PLAN_SYNONYMS = {
premium: ["premium"], 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 getPlanPriority = (plan, periodKey) => {
const priceId = const priceId =
typeof plan?.priceId === "string" ? plan.priceId : plan?.id || null; typeof plan?.priceId === "string" ? plan.priceId : plan?.id || null;
@@ -685,7 +735,8 @@ const styles = StyleSheet.create({
}, },
cardBlur: { cardBlur: {
flex: 1, flex: 1,
padding: gutters * 1.2, paddingHorizontal: gutters * 1.2,
paddingVertical: gutters,
gap: 20, gap: 20,
justifyContent: "center", justifyContent: "center",
backgroundColor: "rgba(48, 52, 56, 0.55)", backgroundColor: "rgba(48, 52, 56, 0.55)",
@@ -697,10 +748,23 @@ const styles = StyleSheet.create({
cardHeader: { cardHeader: {
gap: 6, gap: 6,
}, },
titleRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
width: "100%",
},
planName: { planName: {
fontFamily: FONT_FAMILY.InterBold, fontFamily: FONT_FAMILY.InterBold,
fontSize: 24, fontSize: 24,
color: Palette.white, color: Palette.white,
flexShrink: 1,
},
planBadgeImage: {
width: 48,
height: 48,
flexShrink: 0,
}, },
cardSubtitle: { cardSubtitle: {
fontFamily: FONT_FAMILY.InterMedium, fontFamily: FONT_FAMILY.InterMedium,
+90 -3
View File
@@ -132,6 +132,62 @@ const capitalize = (value) => {
return value.charAt(0).toUpperCase() + value.slice(1); 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 ManageSubscription = ({ navigation }) => {
const { currentUserData } = useUserData() || {}; const { currentUserData } = useUserData() || {};
const [isCancelling, setIsCancelling] = useState(false); const [isCancelling, setIsCancelling] = useState(false);
@@ -334,9 +390,40 @@ const ManageSubscription = ({ navigation }) => {
currentUserData?.subscriptionNextGrantAt || currentUserData?.subscriptionNextGrantAt ||
currentUserData?.subscriptionGrantNextAt || currentUserData?.subscriptionGrantNextAt ||
null; 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 = const nextGrantLabel =
nextGrantDate && isAnnual ? formatDate(nextGrantDate) : null; resolvedNextGrantDate && isAnnual
? formatDate(resolvedNextGrantDate)
: null;
const helperMessages = []; const helperMessages = [];
if (!hasActiveSubscription && hasAnySubscription) { if (!hasActiveSubscription && hasAnySubscription) {
@@ -374,7 +461,7 @@ const ManageSubscription = ({ navigation }) => {
coinsPerMonth: normalizedCoins, coinsPerMonth: normalizedCoins,
coinsPerMonthLabel, coinsPerMonthLabel,
isAnnual, isAnnual,
nextGrantDate, nextGrantDate: resolvedNextGrantDate,
nextGrantLabel, nextGrantLabel,
}; };
}, [currentUserData, remoteSubscription]); }, [currentUserData, remoteSubscription]);
+298
View File
@@ -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 = () => (
<>
<View style={{ gap: 2 }}>
<Text
style={{
fontSize: 22,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
}}
>
Définis un nouveau mot de passe
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
Utilise au moins 6 caractères pour sécuriser ton compte.
</Text>
{email ? (
<Text
style={{
marginTop: 8,
fontSize: 12,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
Compte concerné : {email}
</Text>
) : null}
</View>
<View style={{ gap: 16 }}>
<Input
placeholder="Nouveau mot de passe"
label="Nouveau mot de passe"
type="password"
value={password}
setValue={setPassword}
isBlur
/>
{passwordError ? (
<Text
style={{
fontSize: 12,
color: Palette.red,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
{passwordError}
</Text>
) : null}
<Input
placeholder="Confirmer le mot de passe"
label="Confirmer le mot de passe"
type="password"
value={confirmPassword}
setValue={setConfirmPassword}
isBlur
/>
{confirmError ? (
<Text
style={{
fontSize: 12,
color: Palette.red,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
{confirmError}
</Text>
) : null}
</View>
</>
);
const renderCodeError = () => (
<View style={{ gap: 12 }}>
<Text
style={{
fontSize: 18,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
}}
>
Lien invalide ou expiré
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
{codeError || INVALID_LINK_MESSAGE}
</Text>
</View>
);
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 (
<Page
width={isWeb ? 600 : null}
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
headerType="NAVIGATION"
title="Réinitialisation"
>
<View style={{ flex: 1, paddingTop: 20 }}>
<ItemContainer height={360} disableKeyboardHeight>
<View style={{ gap: 32, paddingTop: 5, paddingHorizontal: 5 }}>
{codeError ? renderCodeError() : renderForm()}
<GradientButton
title={buttonLabel}
containerStyle={{
width: "80%",
alignSelf: "center",
}}
onPress={buttonAction}
disabled={buttonDisabled}
/>
</View>
</ItemContainer>
</View>
</Page>
);
};
export default ResetPassword;
+61 -18
View File
@@ -6,6 +6,7 @@ import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton"; import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader"; import MusicLandHeader from "../../components/MusicLandHeader";
import AppAlert from "../../components/Alert"; import AppAlert from "../../components/Alert";
import CoinIcon from "../../components/CoinIcon";
import firebase, { getFunctionsClient } from "../../config/firebase"; import firebase, { getFunctionsClient } from "../../config/firebase";
import Page from "../../layouts/Page"; import Page from "../../layouts/Page";
import { Routes } from "../../navigation"; import { Routes } from "../../navigation";
@@ -338,27 +339,69 @@ const ComposeSong = () => {
> >
Générer la musique ? Générer la musique ?
</Text> </Text>
<Text <View style={{ alignItems: "center", gap: 8 }}>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexWrap: "wrap",
gap: 6,
}}
>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
}}
>
Cette action coûte{" "}
</Text>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
textAlign: "center",
}}
>
{MUSIC_GENERATION_COIN_COST}
</Text>
<CoinIcon size={18} />
</View>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
}}
>
Souhaites-tu les utiliser pour lancer la génération ?
</Text>
</View>
<View
style={{ style={{
fontSize: 16, flexDirection: "row",
color: Palette.white, alignItems: "center",
fontFamily: FONT_FAMILY.InterRegular, justifyContent: "center",
textAlign: "center", gap: 6,
}} }}
> >
Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces. <Text
{"\n"}Souhaites-tu les utiliser pour lancer la génération ? style={{
</Text> fontSize: 14,
<Text color: Palette.white,
style={{ fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14, textAlign: "center",
color: Palette.white, }}
fontFamily: FONT_FAMILY.InterMedium, >
textAlign: "center", Solde disponible : {formattedCoinBalance}
}} </Text>
> <CoinIcon size={16} />
Solde disponible : {formattedCoinBalance} pièces </View>
</Text>
</View> </View>
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}> <View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
<GradientButton <GradientButton
+61 -18
View File
@@ -11,6 +11,7 @@ import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton"; import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader"; import MusicLandHeader from "../../components/MusicLandHeader";
import AppAlert from "../../components/Alert"; import AppAlert from "../../components/Alert";
import CoinIcon from "../../components/CoinIcon";
import firebase, { getFunctionsClient } from "../../config/firebase"; import firebase, { getFunctionsClient } from "../../config/firebase";
import Page from "../../layouts/Page"; import Page from "../../layouts/Page";
import { Routes } from "../../navigation"; import { Routes } from "../../navigation";
@@ -367,27 +368,69 @@ const ComposeSong = () => {
> >
Générer la musique ? Générer la musique ?
</Text> </Text>
<Text <View style={{ alignItems: "center", gap: 8 }}>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexWrap: "wrap",
gap: 6,
}}
>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
}}
>
Cette action coûte{" "}
</Text>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
textAlign: "center",
}}
>
{MUSIC_GENERATION_COIN_COST}
</Text>
<CoinIcon size={18} />
</View>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
}}
>
Souhaites-tu les utiliser pour lancer la génération ?
</Text>
</View>
<View
style={{ style={{
fontSize: 16, flexDirection: "row",
color: Palette.white, alignItems: "center",
fontFamily: FONT_FAMILY.InterRegular, justifyContent: "center",
textAlign: "center", gap: 6,
}} }}
> >
Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces. <Text
{"\n"}Souhaites-tu les utiliser pour lancer la génération ? style={{
</Text> fontSize: 14,
<Text color: Palette.white,
style={{ fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14, textAlign: "center",
color: Palette.white, }}
fontFamily: FONT_FAMILY.InterMedium, >
textAlign: "center", Solde disponible : {formattedCoinBalance}
}} </Text>
> <CoinIcon size={16} />
Solde disponible : {formattedCoinBalance} pièces </View>
</Text>
</View> </View>
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}> <View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
<GradientButton <GradientButton
+15 -32
View File
@@ -55,12 +55,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
return FAKE_PROGRESS_MAX; return FAKE_PROGRESS_MAX;
} }
const increment = const increment = prevProgress < 60 ? 1 : prevProgress < 80 ? 0.6 : 0.3;
prevProgress < 60
? 1
: prevProgress < 80
? 0.6
: 0.3;
const next = prevProgress + increment; const next = prevProgress + increment;
return next >= FAKE_PROGRESS_MAX ? FAKE_PROGRESS_MAX : next; return next >= FAKE_PROGRESS_MAX ? FAKE_PROGRESS_MAX : next;
@@ -79,7 +74,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
}); });
const sanitizedStructure = sanitizeStructureList( const sanitizedStructure = sanitizeStructureList(
config?.structure, config?.structure,
CUSTOM_SANITIZE_OPTIONS CUSTOM_SANITIZE_OPTIONS,
); );
const callable = firebase const callable = firebase
.functions() .functions()
@@ -142,7 +137,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
const rawMessage = typeof e?.message === "string" ? e.message : ""; const rawMessage = typeof e?.message === "string" ? e.message : "";
const cleanedMessage = rawMessage.replace( const cleanedMessage = rawMessage.replace(
/^functions error: \w+-\w+:\s*/i, /^functions error: \w+-\w+:\s*/i,
"" "",
); );
setError({ setError({
message: message:
@@ -172,7 +167,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
console.log("💾 [CreatingLyrics] Sauvegarde automatique des paroles"); console.log("💾 [CreatingLyrics] Sauvegarde automatique des paroles");
const sanitizedStructure = sanitizeStructureList( const sanitizedStructure = sanitizeStructureList(
result?.structure || config?.structure, result?.structure || config?.structure,
CUSTOM_SANITIZE_OPTIONS CUSTOM_SANITIZE_OPTIONS,
); );
const baseConfig = const baseConfig =
config && typeof config === "object" && !Array.isArray(config) config && typeof config === "object" && !Array.isArray(config)
@@ -180,26 +175,30 @@ const CreatingLyrics = ({ active, config, selections }) => {
: {}; : {};
if (sanitizedStructure.length > 0) { if (sanitizedStructure.length > 0) {
baseConfig.structure = sanitizedStructure; baseConfig.structure = sanitizedStructure;
} else if (Object.prototype.hasOwnProperty.call(baseConfig, "structure")) { } else if (
Object.prototype.hasOwnProperty.call(baseConfig, "structure")
) {
delete baseConfig.structure; delete baseConfig.structure;
} }
const persistedConfig = const persistedConfig =
Object.keys(baseConfig).length > 0 ? baseConfig : null; Object.keys(baseConfig).length > 0 ? baseConfig : null;
let persistedSelections = let persistedSelections =
selections && typeof selections === "object" && !Array.isArray(selections) selections &&
typeof selections === "object" &&
!Array.isArray(selections)
? { ...selections } ? { ...selections }
: null; : null;
if (persistedSelections) { if (persistedSelections) {
if ("customStructure" in persistedSelections) { if ("customStructure" in persistedSelections) {
persistedSelections.customStructure = sanitizeStructureList( persistedSelections.customStructure = sanitizeStructureList(
persistedSelections.customStructure, persistedSelections.customStructure,
CUSTOM_SANITIZE_OPTIONS CUSTOM_SANITIZE_OPTIONS,
); );
} }
if ("parsedStructure" in persistedSelections) { if ("parsedStructure" in persistedSelections) {
persistedSelections.parsedStructure = sanitizeStructureList( persistedSelections.parsedStructure = sanitizeStructureList(
persistedSelections.parsedStructure persistedSelections.parsedStructure,
); );
} }
} }
@@ -252,7 +251,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
onPress: redirect, onPress: redirect,
}, },
], ],
{ cancelable: false } { cancelable: false },
); );
if (Platform.OS === "web") { if (Platform.OS === "web") {
@@ -274,22 +273,6 @@ const CreatingLyrics = ({ active, config, selections }) => {
height: "50%", height: "50%",
}} }}
> >
<View
style={{
zIndex: 1,
position: "absolute",
top: -150,
width: "50%",
height: "80%",
alignSelf: "center",
}}
>
<Image
source={ai.nathalie}
style={{ width: "100%", height: "100%", right: -10 }}
resizeMode="contain"
/>
</View>
<View <View
style={{ style={{
flex: 1, flex: 1,
@@ -344,7 +327,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
setSaved(true); setSaved(true);
await setIsLoading(true); await setIsLoading(true);
console.log( console.log(
"📄 [CreatingLyrics] Consultation manuelle du texte" "📄 [CreatingLyrics] Consultation manuelle du texte",
); );
await updateProjectData({ await updateProjectData({
title: result?.title || "", title: result?.title || "",
@@ -359,7 +342,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
} catch (e) { } catch (e) {
console.error( console.error(
"⚠️ [CreatingLyrics] Erreur lors de l'ouverture manuelle", "⚠️ [CreatingLyrics] Erreur lors de l'ouverture manuelle",
e e,
); );
} finally { } finally {
await setIsLoading(false); await setIsLoading(false);