home, subscription and other fixes
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 35 MiB After Width: | Height: | Size: 26 MiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 712 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 702 KiB |
|
After Width: | Height: | Size: 776 KiB |
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
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,
|
||||
|
||||
@@ -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 ? (
|
||||
<Pressable
|
||||
onPress={handleCoinPress}
|
||||
accessibilityRole="button"
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 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",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
gap: 6,
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={icons.coin}
|
||||
style={{ width: 22, height: 22, resizeMode: "contain" }}
|
||||
/>
|
||||
<Text
|
||||
<Pressable
|
||||
onPress={handleCoinPress}
|
||||
accessibilityRole="button"
|
||||
style={{
|
||||
color: "#ffffff",
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 14,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
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}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<CoinIcon size={22} />
|
||||
<Text
|
||||
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}
|
||||
<PageContainer
|
||||
{...(containerType === "SAFE_AREA_VIEW" ? { edges: ["top"] } : {})}
|
||||
|
||||
@@ -13,6 +13,7 @@ import Research from "../screens/Library/Research";
|
||||
import Login from "../screens/Login";
|
||||
import NewMusicOptions from "../screens/NewMusicOptions";
|
||||
import Onboarding from "../screens/Onboarding";
|
||||
import ResetPassword from "../screens/ResetPassword";
|
||||
import ChooseDecor from "../screens/Playback/ChooseDecor";
|
||||
import CreatingDecor from "../screens/Playback/CreatingDecor";
|
||||
import Playback from "../screens/Playback/Playback";
|
||||
@@ -97,6 +98,11 @@ const baseScreens = [
|
||||
component: ForgotPassword,
|
||||
title: "Mot de passe oublié",
|
||||
},
|
||||
{
|
||||
name: Routes.ResetPassword,
|
||||
component: ResetPassword,
|
||||
title: "Réinitialiser le mot de passe",
|
||||
},
|
||||
{
|
||||
name: Routes.BottomTab,
|
||||
component: BottomTabScreen,
|
||||
|
||||
@@ -3,6 +3,7 @@ export const Routes = {
|
||||
Onboarding: "Onboarding",
|
||||
Login: "Login",
|
||||
ForgotPassword: "ForgotPassword",
|
||||
ResetPassword: "ResetPassword",
|
||||
Register: "Register",
|
||||
CreatePassword: "CreatePassword",
|
||||
CreatePseudo: "CreatePseudo",
|
||||
|
||||
@@ -55,6 +55,7 @@ const HIDDEN_ROUTE_NAMES = new Set([
|
||||
Routes.Notifications,
|
||||
Routes.Language,
|
||||
Routes.Login,
|
||||
Routes.ResetPassword,
|
||||
Routes.Register,
|
||||
]);
|
||||
|
||||
|
||||
@@ -191,6 +191,12 @@ const UniversalLinkProvider = ({ children }) => {
|
||||
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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? (
|
||||
<Page
|
||||
shareBtn
|
||||
headerType="NONE"
|
||||
containerStyle={{ width: "100%", maxWidth: "100%", paddingHorizontal: 0 }}
|
||||
backgroundImg={homeBackgroundImage}
|
||||
>
|
||||
<View style={[styles.root, isWeb ? styles.rootWeb : styles.rootNative]}>
|
||||
<View
|
||||
style={{
|
||||
zIndex: 10,
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
width: 400,
|
||||
alignSelf: "center",
|
||||
flexDirection: isWeb ? "column" : "row",
|
||||
alignItems: "center",
|
||||
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}
|
||||
<View style={styles.root}>
|
||||
<Page
|
||||
shareBtn
|
||||
headerType="NONE"
|
||||
scrollEnabled={false}
|
||||
containerStyle={styles.page}
|
||||
contentContainerStyle={styles.pageContent}
|
||||
width="100%"
|
||||
maxWidth={null}
|
||||
backgroundColor="#303438"
|
||||
>
|
||||
<View style={styles.inner}>
|
||||
<ExpoImage
|
||||
source={homeBackgroundImage}
|
||||
contentFit="cover"
|
||||
style={styles.centerImage}
|
||||
/>
|
||||
|
||||
{/* <BorderGradientButton
|
||||
containerStyle={{ width: Platform.OS === "web" ? 250 : "100%" }}
|
||||
title={"Test Alert"}
|
||||
onPress={() =>
|
||||
Alert("Test", "Ceci est un test d'alert", [
|
||||
{ text: "Annuler", style: "cancel" },
|
||||
{ text: "OK", onPress: () => console.log("OK pressé") },
|
||||
])
|
||||
}
|
||||
/> */}
|
||||
{/* <BorderGradientButton
|
||||
containerStyle={{ width: Platform.OS === "web" ? 250 : "100%" }}
|
||||
title={"Continuer la création"}
|
||||
onPress={() => navigate(Routes.SongReady)}
|
||||
disabled={continueDisabled}
|
||||
/> */}
|
||||
<View style={styles.topBar}>
|
||||
<ProjectDropDown
|
||||
style={styles.projectDropDown}
|
||||
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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</Page>
|
||||
</Page>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Page
|
||||
@@ -545,33 +503,75 @@ export default Home;
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "#303438",
|
||||
},
|
||||
rootWeb: {
|
||||
page: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
rootNative: {
|
||||
backgroundColor: "rgba(66, 91, 135, 0.3)",
|
||||
},
|
||||
heroImage: {
|
||||
position: "absolute",
|
||||
top: -60,
|
||||
alignSelf: "center",
|
||||
width: "80%",
|
||||
maxWidth: 920,
|
||||
height: 420,
|
||||
opacity: 0.9,
|
||||
},
|
||||
shareIcon: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
tintColor: Palette.white,
|
||||
},
|
||||
carouselSection: {
|
||||
padding: 0,
|
||||
paddingLeft: isWeb ? 32 : 0,
|
||||
paddingRight: isWeb ? 32 : 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
width: "100%",
|
||||
flex: 1,
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
carouselSectionElevated: {
|
||||
marginTop: -100,
|
||||
paddingTop: 80,
|
||||
pageContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
<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 ? (
|
||||
<Text style={styles.cardSubtitle}>{plan.nickname}</Text>
|
||||
) : 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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ?
|
||||
</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={{
|
||||
fontSize: 16,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
textAlign: "center",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces.
|
||||
{"\n"}Souhaites-tu les utiliser pour lancer la génération ?
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Solde disponible : {formattedCoinBalance} pièces
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Solde disponible : {formattedCoinBalance}
|
||||
</Text>
|
||||
<CoinIcon size={16} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
|
||||
<GradientButton
|
||||
|
||||
@@ -11,6 +11,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";
|
||||
@@ -367,27 +368,69 @@ const ComposeSong = () => {
|
||||
>
|
||||
Générer la musique ?
|
||||
</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={{
|
||||
fontSize: 16,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
textAlign: "center",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces.
|
||||
{"\n"}Souhaites-tu les utiliser pour lancer la génération ?
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Solde disponible : {formattedCoinBalance} pièces
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Solde disponible : {formattedCoinBalance}
|
||||
</Text>
|
||||
<CoinIcon size={16} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
|
||||
<GradientButton
|
||||
|
||||
@@ -55,12 +55,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
|
||||
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%",
|
||||
}}
|
||||
>
|
||||
<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
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -344,7 +327,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
|
||||
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);
|
||||
|
||||