-

+
${greeting}
Bienvenue sur MusicLand
diff --git a/functions/index.js b/functions/index.js
index 1d393d9..88235b1 100644
--- a/functions/index.js
+++ b/functions/index.js
@@ -29,3 +29,4 @@ exports.upload = require("./src/upload");
exports.algolia = require("./src/algolia");
exports.notifications = require("./src/notifications");
exports.rankings = require("./src/rankings");
+exports.payouts = require("./src/payouts");
diff --git a/functions/src/payouts.js b/functions/src/payouts.js
new file mode 100644
index 0000000..489cacd
--- /dev/null
+++ b/functions/src/payouts.js
@@ -0,0 +1,127 @@
+const admin = require("firebase-admin");
+const { onSchedule } = require("firebase-functions/v2/scheduler");
+const { refList } = require("../index");
+
+const firestore = refList.projects.firestore;
+
+const DISTRIBUTION_REVENUE_BASELINE = 1000;
+const DISTRIBUTION_RATIO = 0.3;
+const MAX_RECIPIENTS = 10;
+const WEIGHTS = Array.from({ length: MAX_RECIPIENTS }, (_, idx) => MAX_RECIPIENTS - idx);
+
+const buildPreviousMonthContext = (referenceDate) => {
+ const current = referenceDate ? new Date(referenceDate) : new Date();
+ current.setHours(1, 0, 0, 0);
+ current.setDate(1);
+
+ const target = new Date(current);
+ target.setMonth(target.getMonth() - 1);
+
+ const month = target.getMonth();
+ const year = target.getFullYear();
+ const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`;
+ const rangeStart = new Date(year, month, 1, 0, 0, 0, 0);
+ const rangeEnd = new Date(year, month + 1, 0, 23, 59, 59, 999);
+
+ return {
+ year,
+ month: month + 1,
+ monthKey,
+ rangeStart,
+ rangeEnd,
+ };
+};
+
+exports.distributeMonthlyPayouts = onSchedule(
+ {
+ schedule: "0 1 1 * *",
+ timeZone: "Europe/Paris",
+ },
+ async (event) => {
+ const { scheduleTime } = event;
+ const context = buildPreviousMonthContext(
+ scheduleTime ? new Date(scheduleTime) : new Date()
+ );
+ const monthKey = context.monthKey;
+ const now = admin.firestore.Timestamp.now();
+
+ const statsSnapshot = await firestore
+ .collectionGroup("monthly")
+ .where("monthKey", "==", monthKey)
+ .orderBy("streams", "desc")
+ .limit(MAX_RECIPIENTS)
+ .get();
+
+ const topEntries = statsSnapshot.docs
+ .map((doc, index) => {
+ const data = doc.data() || {};
+ return {
+ rank: index + 1,
+ projectId: data.projectId || doc.ref.parent.parent?.id || null,
+ userId: data.userId || null,
+ streams: typeof data.streams === "number" ? data.streams : 0,
+ statsDocPath: doc.ref.path,
+ };
+ })
+ .filter((entry) => entry.projectId && entry.streams > 0);
+
+ const payoutPool = Number(
+ (DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO).toFixed(2)
+ );
+
+ const weights = WEIGHTS.slice(0, topEntries.length);
+ const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
+
+ let allocations = topEntries.map((entry, idx) => {
+ if (!weightSum) return 0;
+ const rawAmount = (payoutPool * weights[idx]) / weightSum;
+ return Number(rawAmount.toFixed(2));
+ });
+
+ if (allocations.length > 0) {
+ const allocatedTotal = Number(
+ allocations.reduce((sum, amount) => sum + amount, 0).toFixed(2)
+ );
+ const remainder = Number((payoutPool - allocatedTotal).toFixed(2));
+ if (remainder !== 0) {
+ allocations[0] = Number((allocations[0] + remainder).toFixed(2));
+ }
+ }
+
+ const payouts = topEntries.map((entry, idx) => ({
+ rank: entry.rank,
+ projectId: entry.projectId,
+ userId: entry.userId,
+ streams: entry.streams,
+ weight: weights[idx],
+ amount: allocations[idx],
+ statsDocPath: entry.statsDocPath,
+ }));
+
+ const summary = {
+ monthKey,
+ month: context.month,
+ year: context.year,
+ range: {
+ start: admin.firestore.Timestamp.fromDate(context.rangeStart),
+ end: admin.firestore.Timestamp.fromDate(context.rangeEnd),
+ },
+ revenueBaseline: DISTRIBUTION_REVENUE_BASELINE,
+ payoutRatio: DISTRIBUTION_RATIO,
+ payoutPool,
+ totalStreams: topEntries.reduce((sum, entry) => sum + entry.streams, 0),
+ totalRecipients: payouts.length,
+ totalAllocated: payouts.reduce(
+ (sum, payout) => Number((sum + payout.amount).toFixed(2)),
+ 0
+ ),
+ payouts,
+ status: payouts.length ? "computed" : "no-data",
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
+ computedAt: now,
+ };
+
+ const docRef = firestore.collection("monthlyPayouts").doc(monthKey);
+ await docRef.set(summary, { merge: true });
+ }
+);
diff --git a/functions/src/project.js b/functions/src/project.js
index 3f0be78..34562b0 100644
--- a/functions/src/project.js
+++ b/functions/src/project.js
@@ -4,6 +4,15 @@ const { refList } = require("../index");
const { getSunoTimestamps } = require("./lyrics");
const { deleteFolder } = require("../helpers/firebase");
+const firestore = admin.firestore();
+
+const buildMonthKey = (timestamp) => {
+ const date = timestamp.toDate();
+ const year = date.getUTCFullYear();
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
+ return `${year}-${month}`;
+};
+
exports.onProjectUpdate = onDocumentWritten(
"projects/{projectId}",
async (projectSnap) => {
@@ -53,3 +62,69 @@ exports.onProjectUpdate = onDocumentWritten(
}
}
);
+
+exports.onProjectViewsIncrement = onDocumentWritten(
+ "projects/{projectId}",
+ async (projectSnap) => {
+ try {
+ const { projectId } = projectSnap.params;
+ const beforeData = projectSnap?.data?.before?.data() || null;
+ const afterData = projectSnap?.data?.after?.data() || null;
+
+ if (!afterData) return null;
+
+ const beforeViews =
+ typeof beforeData?.views === "number" && Number.isFinite(beforeData.views)
+ ? beforeData.views
+ : 0;
+ const afterViews =
+ typeof afterData?.views === "number" && Number.isFinite(afterData.views)
+ ? afterData.views
+ : 0;
+ const delta = afterViews - beforeViews;
+
+ if (delta <= 0) return null;
+
+ const userId =
+ typeof afterData?.userId === "string" && afterData.userId.trim().length
+ ? afterData.userId.trim()
+ : null;
+ const now = admin.firestore.Timestamp.now();
+ const monthKey = buildMonthKey(now);
+
+ const statsDocRef = firestore
+ .collection("projectStreamStats")
+ .doc(projectId)
+ .collection("monthly")
+ .doc(monthKey);
+
+ await firestore.runTransaction(async (transaction) => {
+ const statsSnapshot = await transaction.get(statsDocRef);
+ const updatePayload = {
+ projectId,
+ userId,
+ monthKey,
+ updatedAt: now,
+ lastStreamAt: now,
+ lastDelta: delta,
+ streams: admin.firestore.FieldValue.increment(delta),
+ };
+
+ const hasFirstStream =
+ statsSnapshot.exists && statsSnapshot.data()?.firstStreamAt;
+ if (!hasFirstStream) {
+ updatePayload.firstStreamAt = now;
+ }
+
+ transaction.set(statsDocRef, updatePayload, { merge: true });
+ });
+
+ return null;
+ } catch (error) {
+ console.log("onProjectViewsIncrement error", {
+ message: error?.message || String(error || ""),
+ });
+ return null;
+ }
+ }
+);
diff --git a/functions/src/users.js b/functions/src/users.js
index e0bba5d..d1c6440 100644
--- a/functions/src/users.js
+++ b/functions/src/users.js
@@ -8,12 +8,44 @@ const { deleteFolder } = require("../helpers/firebase");
const { Resend } = require("resend");
const { welcomeTemplate } = require("../helpers/email");
const { RESEND_API_KEY } = require("../config/keys");
+const { onRequest } = require("firebase-functions/https");
const resendClient = new Resend(RESEND_API_KEY);
const WELCOME_EMAIL_FROM =
process.env.RESEND_FROM_EMAIL || "MusicLand
";
const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
+exports.testWelcomMail = onRequest(async (req, res) => {
+ if (req.method !== "GET") {
+ res.set("Allow", "GET");
+ return res.status(405).json({ success: false, error: "Method not allowed" });
+ }
+ try {
+ const targetEmail = req.query.email || "tdtomthomas@gmail.com";
+ const firstName = req.query.firstName || "Toto";
+ const lastName = req.query.lastName || "Test";
+ const { data, error } = await resendClient.emails.send({
+ from: WELCOME_EMAIL_FROM,
+ to: [targetEmail],
+ subject: WELCOME_EMAIL_SUBJECT,
+ html: welcomeTemplate({ firstName, lastName }),
+ });
+ if (error) {
+ console.log("Failed to send welcome email:", error);
+ return res
+ .status(500)
+ .json({ success: false, error: error.message || error.toString() });
+ }
+ console.log("Welcome email sent:", data);
+ return res.status(200).json({ success: true, data });
+ } catch (e) {
+ console.log(e);
+ return res
+ .status(500)
+ .json({ success: false, error: e.message || e.toString() });
+ }
+});
+
exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
try {
const { email = "", firstName = "", lastName = "" } = event?.data?.data();
diff --git a/index.web.js b/index.web.js
index 4ced4b3..23e775b 100644
--- a/index.web.js
+++ b/index.web.js
@@ -3,6 +3,9 @@ import { registerRootComponent } from 'expo';
// Inject minimal global CSS and a small setNativeProps polyfill for web
if (typeof document !== 'undefined') {
+ document.documentElement?.setAttribute('translate', 'no');
+ document.body?.setAttribute('translate', 'no');
+
const style = document.createElement('style');
style.setAttribute('data-inline-global', 'true');
style.innerHTML = `
diff --git a/src/components/modal/MusicOptionsModal.js b/src/components/modal/MusicOptionsModal.js
index a4aa74c..0025ce2 100644
--- a/src/components/modal/MusicOptionsModal.js
+++ b/src/components/modal/MusicOptionsModal.js
@@ -3,6 +3,7 @@ import { Pressable, Text, View } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
+import { openShareSheet } from "../../utils/shareSheet";
import AppActionSheet from "../AppActionSheet";
const OptionButton = ({ label, onPress, disabled = false }) => (
@@ -40,6 +41,9 @@ const MusicOptionsModal = ({ payload }) => {
projectId = null,
ownerId = null,
title = null,
+ onShare,
+ sharePayload = null,
+ shareDisabled = false,
} = payload || {};
const trigger = useCallback((action) => {
@@ -97,6 +101,23 @@ const MusicOptionsModal = ({ payload }) => {
trigger(run);
}, [downloadDisabled, onDownload, trigger]);
+ const handleShare = useCallback(() => {
+ if (shareDisabled) return;
+ const run = () => {
+ if (typeof onShare === "function") {
+ onShare();
+ return;
+ }
+ if (sharePayload) {
+ openShareSheet(sharePayload);
+ }
+ };
+ trigger(run);
+ }, [onShare, shareDisabled, sharePayload, trigger]);
+
+ const shouldShowShareOption =
+ typeof onShare === "function" || !!sharePayload;
+
return (
@@ -116,6 +137,13 @@ const MusicOptionsModal = ({ payload }) => {
label="Ajouter à une playlist"
onPress={handleAddToPlaylist}
/>
+ {shouldShowShareOption && (
+
+ )}
{
};
}, [trackId, songUrl, title, artist, coverUrl, projectId]);
+ const sharePayload = useMemo(() => {
+ return createMusicSharePayload({
+ projectId,
+ title,
+ artist,
+ });
+ }, [artist, projectId, title]);
+
+ const handleShare = useCallback(() => {
+ if (!sharePayload) return;
+ openShareSheet(sharePayload);
+ }, [sharePayload]);
+
const handleReport = useCallback(() => {
if (!projectId) return;
SheetManager.show("Report", {
@@ -147,15 +164,20 @@ const MusicDetails = ({ route }) => {
onAddToPlaylist: handleAddToPlaylist,
onDownload: handleDownload,
downloadDisabled: !songUrl,
+ onShare: sharePayload ? handleShare : null,
+ sharePayload,
+ shareDisabled: !sharePayload,
},
});
}, [
handleAddToPlaylist,
handleDownload,
handleReport,
+ handleShare,
owner?.id,
project?.userId,
projectId,
+ sharePayload,
songUrl,
title,
]);
diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js
index f43d0dd..4cb8b4a 100644
--- a/src/screens/Library/MusicDetails.web.js
+++ b/src/screens/Library/MusicDetails.web.js
@@ -243,16 +243,21 @@ const MusicDetails = ({ route }) => {
onAddToPlaylist: handleAddToPlaylist,
onDownload: handleDownload,
downloadDisabled: isDownloading || !songUrl,
+ onShare: sharePayload ? handleShare : null,
+ sharePayload,
+ shareDisabled: !sharePayload,
},
});
}, [
handleAddToPlaylist,
handleDownload,
handleReport,
+ handleShare,
isDownloading,
owner?.id,
project?.userId,
projectId,
+ sharePayload,
songUrl,
title,
]);