home, subscription and other fixes
@@ -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",
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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.";
|
||||||
}
|
}
|
||||||
|
|||||||
|
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,
|
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"),
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,13 +207,21 @@ 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,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
zIndex: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCoinPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={{
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
@@ -164,14 +230,10 @@ export default ({
|
|||||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 10,
|
gap: 8,
|
||||||
zIndex: 20,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<CoinIcon size={22} />
|
||||||
source={icons.coin}
|
|
||||||
style={{ width: 22, height: 22, resizeMode: "contain" }}
|
|
||||||
/>
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
@@ -182,6 +244,19 @@ export default ({
|
|||||||
{formattedCoins}
|
{formattedCoins}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</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"] } : {})}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 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 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;
|
|
||||||
}
|
}
|
||||||
const firstIncomplete = stageStates.findIndex(
|
return acc;
|
||||||
(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,34 +372,59 @@ 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 ? (
|
||||||
|
<View style={styles.root}>
|
||||||
<Page
|
<Page
|
||||||
shareBtn
|
shareBtn
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
containerStyle={{ width: "100%", maxWidth: "100%", paddingHorizontal: 0 }}
|
scrollEnabled={false}
|
||||||
backgroundImg={homeBackgroundImage}
|
containerStyle={styles.page}
|
||||||
>
|
contentContainerStyle={styles.pageContent}
|
||||||
<View style={[styles.root, isWeb ? styles.rootWeb : styles.rootNative]}>
|
width="100%"
|
||||||
<View
|
maxWidth={null}
|
||||||
style={{
|
backgroundColor="#303438"
|
||||||
zIndex: 10,
|
|
||||||
position: "absolute",
|
|
||||||
top: 10,
|
|
||||||
width: 400,
|
|
||||||
alignSelf: "center",
|
|
||||||
flexDirection: isWeb ? "column" : "row",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: isWeb ? 0 : 30,
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<View style={styles.inner}>
|
||||||
|
<ExpoImage
|
||||||
|
source={homeBackgroundImage}
|
||||||
|
contentFit="cover"
|
||||||
|
style={styles.centerImage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.topBar}>
|
||||||
<ProjectDropDown
|
<ProjectDropDown
|
||||||
style={[isWeb ? { width: "100%" } : { flex: 1 }]}
|
style={styles.projectDropDown}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
selectedProject={currentProject}
|
selectedProject={currentProject}
|
||||||
allowEmptySelection
|
allowEmptySelection
|
||||||
@@ -450,6 +435,7 @@ const Home = ({ navigation, route }) => {
|
|||||||
/>
|
/>
|
||||||
{!isWeb && <ShareBtn />}
|
{!isWeb && <ShareBtn />}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<MoreMenu
|
<MoreMenu
|
||||||
visible={menuVisible}
|
visible={menuVisible}
|
||||||
top={menuAnchor?.top ?? 0}
|
top={menuAnchor?.top ?? 0}
|
||||||
@@ -459,57 +445,29 @@ const Home = ({ navigation, route }) => {
|
|||||||
projectId={menuProject?.id || null}
|
projectId={menuProject?.id || null}
|
||||||
extraItems={moreMenuItems}
|
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
|
<Text style={styles.subtitle}>5 espaces à découvrir</Text>
|
||||||
containerStyle={{ width: Platform.OS === "web" ? 250 : "100%" }}
|
|
||||||
title={"Test Alert"}
|
<View style={styles.cardsGrid}>
|
||||||
onPress={() =>
|
{stageCards.map((card) => (
|
||||||
Alert("Test", "Ceci est un test d'alert", [
|
<StageCard
|
||||||
{ text: "Annuler", style: "cancel" },
|
key={card.key}
|
||||||
{ text: "OK", onPress: () => console.log("OK pressé") },
|
step={card.step}
|
||||||
])
|
description={card.description}
|
||||||
}
|
image={card.image}
|
||||||
/> */}
|
imagePosition={card.imagePosition}
|
||||||
{/* <BorderGradientButton
|
textAlign={card.textAlign}
|
||||||
containerStyle={{ width: Platform.OS === "web" ? 250 : "100%" }}
|
isLocked={card.isLocked}
|
||||||
title={"Continuer la création"}
|
lockSide={card.lockSide}
|
||||||
onPress={() => navigate(Routes.SongReady)}
|
onPress={() => handleStagePress(card.key, card.isLocked)}
|
||||||
disabled={continueDisabled}
|
/>
|
||||||
/> */}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<ClubCard image={CLUB_CARD_IMAGE} onPress={handleClubPress} />
|
||||||
</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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { 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}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
<Text style={styles.planName}>{planName}</Text>
|
<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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 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,6 +339,16 @@ const ComposeSong = () => {
|
|||||||
>
|
>
|
||||||
Générer la musique ?
|
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
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -346,9 +357,39 @@ const ComposeSong = () => {
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces.
|
Cette action coûte{" "}
|
||||||
{"\n"}Souhaites-tu les utiliser pour lancer la génération ?
|
|
||||||
</Text>
|
</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={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -357,8 +398,10 @@ const ComposeSong = () => {
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Solde disponible : {formattedCoinBalance} pièces
|
Solde disponible : {formattedCoinBalance}
|
||||||
</Text>
|
</Text>
|
||||||
|
<CoinIcon size={16} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
|
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
|
||||||
<GradientButton
|
<GradientButton
|
||||||
|
|||||||
@@ -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,6 +368,16 @@ const ComposeSong = () => {
|
|||||||
>
|
>
|
||||||
Générer la musique ?
|
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
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -375,9 +386,39 @@ const ComposeSong = () => {
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces.
|
Cette action coûte{" "}
|
||||||
{"\n"}Souhaites-tu les utiliser pour lancer la génération ?
|
|
||||||
</Text>
|
</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={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -386,8 +427,10 @@ const ComposeSong = () => {
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Solde disponible : {formattedCoinBalance} pièces
|
Solde disponible : {formattedCoinBalance}
|
||||||
</Text>
|
</Text>
|
||||||
|
<CoinIcon size={16} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
|
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
|
||||||
<GradientButton
|
<GradientButton
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||