feat: fixes and formatter

This commit is contained in:
2026-01-12 16:01:32 +01:00
parent 85c6084351
commit 11e632acff
353 changed files with 23315 additions and 27361 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
module.exports = {
extends: ["expo", "prettier"],
plugins: ["prettier"],
extends: ['expo', 'prettier'],
plugins: ['prettier'],
rules: {
"prettier/prettier": [
"error",
'prettier/prettier': [
'error',
{
singleQuote: false,
},
],
},
};
}
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
build
vendor
coverage
.next
.out
public/build
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "es5"
}
+98 -110
View File
@@ -1,28 +1,22 @@
import "@expo/metro-runtime";
import { PortalProvider } from "@gorhom/portal";
import { DefaultTheme, NavigationContainer } from "@react-navigation/native";
import { useFonts } from "expo-font";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import moment from "moment";
import "moment/locale/fr";
import { Platform, Text, TextInput } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import React, {
setGlobal,
useCallback,
useEffect,
useGlobal,
useState,
} from "reactn";
import '@expo/metro-runtime'
import { PortalProvider } from '@gorhom/portal'
import { DefaultTheme, NavigationContainer } from '@react-navigation/native'
import { useFonts } from 'expo-font'
import * as SplashScreen from 'expo-splash-screen'
import { StatusBar } from 'expo-status-bar'
import moment from 'moment'
import 'moment/locale/fr'
import { Platform, Text, TextInput } from 'react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import React, { setGlobal, useCallback, useEffect, useGlobal, useState } from 'reactn'
import { MainStack, Routes } from "./src/navigation";
import { Palette } from "./src/styles";
import { MainStack, Routes } from './src/navigation'
import { Palette } from './src/styles'
import { navigationRef, reset } from "./src/navigation/NavigationService";
import { navigationRef, reset } from './src/navigation/NavigationService'
import { MenuProvider } from "react-native-popup-menu";
import initialGlobalState from "./src/config/initialGlobalState";
import { MenuProvider } from 'react-native-popup-menu'
import initialGlobalState from './src/config/initialGlobalState'
import {
Inter_400Regular,
@@ -30,143 +24,143 @@ import {
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
} from "@expo-google-fonts/inter";
import firebase from "./src/config/firebase";
import Providers from "./src/providers";
} from '@expo-google-fonts/inter'
import firebase from './src/config/firebase'
import Providers from './src/providers'
import { LogBox } from "react-native";
import CommentsBottomSheet from "./src/components/bottomsheets/CommentsBottomSheet";
import ShareQrModalContainer from "./src/components/modal/ShareQrModalContainer";
import AppDownloadBanner from "./src/components/AppDownloadBanner";
import MobileSplashVideo from "./src/components/MobileSplashVideo";
import "./src/utils/Sheet";
import { LogBox } from 'react-native'
import CommentsBottomSheet from './src/components/bottomsheets/CommentsBottomSheet'
import ShareQrModalContainer from './src/components/modal/ShareQrModalContainer'
import AppDownloadBanner from './src/components/AppDownloadBanner'
import MobileSplashVideo from './src/components/MobileSplashVideo'
import './src/utils/Sheet'
console.disableYellowBox = true;
console.reportErrorsAsExceptions = false;
console.disableYellowBox = true
console.reportErrorsAsExceptions = false
moment.locale("fr");
moment.locale('fr')
// Keep typography stable regardless of the user's system font scaling on mobile
if (Platform.OS !== "web") {
if (Platform.OS !== 'web') {
if (Text.defaultProps == null) {
Text.defaultProps = {};
Text.defaultProps = {}
}
if (TextInput.defaultProps == null) {
TextInput.defaultProps = {};
TextInput.defaultProps = {}
}
Text.defaultProps.allowFontScaling = false;
TextInput.defaultProps.allowFontScaling = false;
Text.defaultProps.allowFontScaling = false
TextInput.defaultProps.allowFontScaling = false
}
setGlobal(initialGlobalState);
setGlobal(initialGlobalState)
SplashScreen.preventAutoHideAsync();
SplashScreen.preventAutoHideAsync()
const App = () => {
const [, setCurrentUID] = useGlobal("currentUID");
const [, setCurrentUserRoles] = useGlobal("currentUserRoles");
const [, setCurrentUID] = useGlobal('currentUID')
const [, setCurrentUserRoles] = useGlobal('currentUserRoles')
const [appIsReady, setAppIsReady] = useState(false);
const [appIsReady, setAppIsReady] = useState(false)
const [isInitializing, setInitializing] = useState(true);
const [isInitializing, setInitializing] = useState(true)
const routeNameRef = React.useRef(null);
const routeNameRef = React.useRef(null)
const syncActiveRoute = useCallback(() => {
const currentRoute = navigationRef.current?.getCurrentRoute();
const currentName = currentRoute?.name ?? null;
const currentRoute = navigationRef.current?.getCurrentRoute()
const currentName = currentRoute?.name ?? null
if (routeNameRef.current !== currentName) {
routeNameRef.current = currentName;
setGlobal({ activeRouteName: currentName });
routeNameRef.current = currentName
setGlobal({ activeRouteName: currentName })
}
}, [setGlobal]);
}, [setGlobal])
const handleNavigationReady = useCallback(() => {
syncActiveRoute();
}, [syncActiveRoute]);
syncActiveRoute()
}, [syncActiveRoute])
const handleNavigationStateChange = useCallback(() => {
syncActiveRoute();
}, [syncActiveRoute]);
syncActiveRoute()
}, [syncActiveRoute])
const [loaded] = useFonts({
NewYorkSemibold: require("./src/assets/fonts/NewYork-Semibold.ttf"),
OpenSansRegular: require("./src/assets/fonts/OpenSans-Regular.ttf"),
NewYorkSemibold: require('./src/assets/fonts/NewYork-Semibold.ttf'),
OpenSansRegular: require('./src/assets/fonts/OpenSans-Regular.ttf'),
InterRegular: Inter_400Regular,
InterMedium: Inter_500Medium,
InterSemiBold: Inter_600SemiBold,
InterBold: Inter_700Bold,
InterRegularItalic: Inter_400Regular_Italic,
HelveticaNeueRegular: require("./src/assets/fonts/HelveticaNeueRegular.ttf"),
HelveticaNeueMedium: require("./src/assets/fonts/HelveticaNeueMedium.ttf"),
HelveticaNeueBold: require("./src/assets/fonts/HelveticaNeueBold.ttf"),
OwnersRegular: require("./src/assets/fonts/OwnersRegular.ttf"),
OwnersMedium: require("./src/assets/fonts/OwnersMedium.ttf"),
OwnersBold: require("./src/assets/fonts/OwnersBold.ttf"),
});
HelveticaNeueRegular: require('./src/assets/fonts/HelveticaNeueRegular.ttf'),
HelveticaNeueMedium: require('./src/assets/fonts/HelveticaNeueMedium.ttf'),
HelveticaNeueBold: require('./src/assets/fonts/HelveticaNeueBold.ttf'),
OwnersRegular: require('./src/assets/fonts/OwnersRegular.ttf'),
OwnersMedium: require('./src/assets/fonts/OwnersMedium.ttf'),
OwnersBold: require('./src/assets/fonts/OwnersBold.ttf'),
})
useEffect(() => {
if (loaded) {
setAppIsReady(true);
LogBox.ignoreAllLogs();
setAppIsReady(true)
LogBox.ignoreAllLogs()
}
}, [loaded]);
}, [loaded])
const onLayoutRootView = useCallback(async () => {
if (appIsReady) {
await SplashScreen.hideAsync();
await SplashScreen.hideAsync()
}
}, [appIsReady, loaded]);
}, [appIsReady, loaded])
useEffect(() => {
const subscriber = firebase.auth().onAuthStateChanged(onAuthStateChanged);
return subscriber;
}, []);
const subscriber = firebase.auth().onAuthStateChanged(onAuthStateChanged)
return subscriber
}, [])
useEffect(() => {
if (Platform.OS === "web") {
const s = document.createElement("script");
s.src = "https://minuit.app/embed/report-a-problem.js";
s.async = true;
s.setAttribute("data-project-id", "playbackproduction");
s.setAttribute("data-position", "right");
s.setAttribute("data-offset", "16");
s.setAttribute("data-primary-color", "#FB68A8");
s.setAttribute("data-bg-primary-color", "#0E0E12");
s.setAttribute("data-bg-secondary-color", "#1D1825");
s.setAttribute("data-text-color", "#F3F0F5");
if (Platform.OS === 'web') {
const s = document.createElement('script')
s.src = 'https://minuit.app/embed/report-a-problem.js'
s.async = true
s.setAttribute('data-project-id', 'playbackproduction')
s.setAttribute('data-position', 'right')
s.setAttribute('data-offset', '16')
s.setAttribute('data-primary-color', '#FB68A8')
s.setAttribute('data-bg-primary-color', '#0E0E12')
s.setAttribute('data-bg-secondary-color', '#1D1825')
s.setAttribute('data-text-color', '#F3F0F5')
document.body.appendChild(s);
document.body.appendChild(s)
return () => {
try {
s.remove();
} catch (_) { }
};
s.remove()
} catch (_) {}
}
}
}, []);
}, [])
const onAuthStateChanged = async (user) => {
if (isInitializing) {
setInitializing(false);
setInitializing(false)
}
if (user?.uid) {
setCurrentUID(user.uid);
setCurrentUID(user.uid)
const idTokenResult = await user.getIdTokenResult();
setCurrentUserRoles(idTokenResult?.claims?.roles || []);
const idTokenResult = await user.getIdTokenResult()
setCurrentUserRoles(idTokenResult?.claims?.roles || [])
} else {
setCurrentUID(null);
setGlobal(initialGlobalState);
setCurrentUID(null)
setGlobal(initialGlobalState)
reset({
index: 0,
routes: [{ name: Routes.Splash }],
});
})
}
};
}
if (!loaded || isInitializing) {
return null;
return null
}
return (
<>
@@ -177,8 +171,7 @@ const App = () => {
style={[
{
flex: 1,
backgroundColor:
Platform.OS === "web" ? "transparent" : Palette.darkPurple,
backgroundColor: Platform.OS === 'web' ? 'transparent' : Palette.darkPurple,
},
]}
>
@@ -191,10 +184,7 @@ const App = () => {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
background:
Platform.OS === "web"
? "transparent"
: Palette.darkPurple,
background: Platform.OS === 'web' ? 'transparent' : Palette.darkPurple,
},
}}
ref={navigationRef}
@@ -202,12 +192,10 @@ const App = () => {
onStateChange={handleNavigationStateChange}
documentTitle={{
formatter: (options) =>
options?.title
? `${options?.title} - MusicLand`
: "MusicLand",
options?.title ? `${options?.title} - MusicLand` : 'MusicLand',
}}
linking={{
prefixes: ["musicland://", "https://musicland-one.vercel.app"],
prefixes: ['musicland://', 'https://musicland-one.vercel.app'],
config: {
screens: {
// Add any deep link configurations here if needed
@@ -227,7 +215,7 @@ const App = () => {
</PortalProvider>
</GestureHandlerRootView>
</>
);
};
)
}
export default App;
export default App
+5 -8
View File
@@ -1,10 +1,7 @@
module.exports = function (api) {
api.cache(true);
api.cache(true)
return {
presets: ["babel-preset-expo"],
plugins: [
"@babel/plugin-proposal-export-namespace-from",
"react-native-reanimated/plugin",
],
};
};
presets: ['babel-preset-expo'],
plugins: ['@babel/plugin-proposal-export-namespace-from', 'react-native-reanimated/plugin'],
}
}
+89 -131
View File
@@ -1,69 +1,69 @@
const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const entryFile = path.resolve(__dirname, "index.js");
const srcDir = path.resolve(__dirname, "src");
const entryFile = path.resolve(__dirname, 'index.js')
const srcDir = path.resolve(__dirname, 'src')
// Extensions de fichiers à analyser
const scriptExtensions = [".js", ".jsx", ".ts", ".tsx", ".web.js"];
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".svg"];
const scriptExtensions = ['.js', '.jsx', '.ts', '.tsx', '.web.js']
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg']
// Fonction pour obtenir tous les fichiers dans un dossier avec des extensions spécifiques
function getAllFiles(dir, extensions, fileList = []) {
const files = fs.readdirSync(dir);
const files = fs.readdirSync(dir)
files.forEach(function (file) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
const filePath = path.join(dir, file)
const stat = fs.statSync(filePath)
if (stat.isDirectory()) {
getAllFiles(filePath, extensions, fileList);
getAllFiles(filePath, extensions, fileList)
} else if (extensions.includes(path.extname(file))) {
fileList.push(filePath);
fileList.push(filePath)
}
});
return fileList;
})
return fileList
}
// Vérifie si un chemin est un fichier valide
function isValidFile(filePath) {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
return fs.existsSync(filePath) && fs.statSync(filePath).isFile()
}
// Résout le chemin de l'import en un fichier valide
function resolveImport(filePath, importPath) {
let importedFile = path.resolve(path.dirname(filePath), importPath);
let importedFile = path.resolve(path.dirname(filePath), importPath)
// Liste des extensions à tester, y compris .web.js
const allExtensions = scriptExtensions;
const allExtensions = scriptExtensions
// Si le chemin n'a pas d'extension, essayer avec différentes extensions
if (!path.extname(importedFile)) {
for (const ext of allExtensions) {
if (isValidFile(importedFile + ext)) {
return importedFile + ext;
return importedFile + ext
}
}
}
// Si un fichier avec l'extension actuelle existe, le retourner
if (isValidFile(importedFile)) {
return importedFile;
return importedFile
}
// Si le chemin est un répertoire, chercher un index avec les extensions
if (fs.existsSync(importedFile) && fs.statSync(importedFile).isDirectory()) {
const indexFiles = allExtensions.map((ext) => "index" + ext);
const indexFiles = allExtensions.map((ext) => 'index' + ext)
for (const indexFile of indexFiles) {
const indexFilePath = path.join(importedFile, indexFile);
const indexFilePath = path.join(importedFile, indexFile)
if (isValidFile(indexFilePath)) {
return indexFilePath;
return indexFilePath
}
}
}
// Aucun fichier valide trouvé
return null;
return null
}
// Analyse des fichiers pour trouver les imports
@@ -74,178 +74,136 @@ function getDependencies(
usedFiles = new Set(),
usedAssets = new Set()
) {
if (visitedFiles.has(filePath))
return { usedDependencies, usedFiles, usedAssets };
visitedFiles.add(filePath);
usedFiles.add(filePath);
if (visitedFiles.has(filePath)) return { usedDependencies, usedFiles, usedAssets }
visitedFiles.add(filePath)
usedFiles.add(filePath)
const content = fs.readFileSync(filePath, "utf-8");
let ast;
const content = fs.readFileSync(filePath, 'utf-8')
let ast
try {
ast = parser.parse(content, {
sourceType: "module",
plugins: ["jsx", "typescript", "classProperties", "dynamicImport"],
});
sourceType: 'module',
plugins: ['jsx', 'typescript', 'classProperties', 'dynamicImport'],
})
} catch (error) {
console.error(`Erreur lors de l'analyse du fichier ${filePath}:`, error);
return { usedDependencies, usedFiles, usedAssets };
console.error(`Erreur lors de l'analyse du fichier ${filePath}:`, error)
return { usedDependencies, usedFiles, usedAssets }
}
traverse(ast, {
ImportDeclaration({ node }) {
const importPath = node.source.value;
handleImport(
filePath,
importPath,
visitedFiles,
usedDependencies,
usedFiles,
usedAssets
);
const importPath = node.source.value
handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets)
},
CallExpression({ node }) {
if (
node.callee.type === "Import" ||
(node.callee.name === "require" && node.arguments.length)
node.callee.type === 'Import' ||
(node.callee.name === 'require' && node.arguments.length)
) {
const importArg = node.arguments[0];
if (importArg && importArg.type === "StringLiteral") {
const importPath = importArg.value;
handleImport(
filePath,
importPath,
visitedFiles,
usedDependencies,
usedFiles,
usedAssets
);
const importArg = node.arguments[0]
if (importArg && importArg.type === 'StringLiteral') {
const importPath = importArg.value
handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets)
}
}
},
});
})
// Vérifier si un fichier .web.js correspondant existe
if (filePath.endsWith(".js")) {
const webFilePath = filePath.replace(/\.js$/, ".web.js");
if (filePath.endsWith('.js')) {
const webFilePath = filePath.replace(/\.js$/, '.web.js')
if (isValidFile(webFilePath)) {
getDependencies(
webFilePath,
visitedFiles,
usedDependencies,
usedFiles,
usedAssets
);
getDependencies(webFilePath, visitedFiles, usedDependencies, usedFiles, usedAssets)
}
}
return { usedDependencies, usedFiles, usedAssets };
return { usedDependencies, usedFiles, usedAssets }
}
// Fonction pour gérer les imports
function handleImport(
filePath,
importPath,
visitedFiles,
usedDependencies,
usedFiles,
usedAssets
) {
if (importPath.startsWith(".")) {
function handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets) {
if (importPath.startsWith('.')) {
// Chemin relatif
const resolvedPath = resolveImport(filePath, importPath);
const resolvedPath = resolveImport(filePath, importPath)
if (resolvedPath) {
const ext = path.extname(resolvedPath);
const ext = path.extname(resolvedPath)
if (scriptExtensions.includes(ext)) {
getDependencies(
resolvedPath,
visitedFiles,
usedDependencies,
usedFiles,
usedAssets
);
getDependencies(resolvedPath, visitedFiles, usedDependencies, usedFiles, usedAssets)
} else if (imageExtensions.includes(ext)) {
usedAssets.add(resolvedPath);
usedAssets.add(resolvedPath)
}
}
} else {
// Dépendance de node_modules
const dep = importPath.split("/")[0];
usedDependencies.add(dep);
const dep = importPath.split('/')[0]
usedDependencies.add(dep)
}
}
(async () => {
;(async () => {
// Étape 1: Obtenir tous les fichiers utilisés
const allScriptFiles = getAllFiles(srcDir, scriptExtensions);
const allAssetFiles = getAllFiles(srcDir, imageExtensions);
const allScriptFiles = getAllFiles(srcDir, scriptExtensions)
const allAssetFiles = getAllFiles(srcDir, imageExtensions)
const { usedDependencies, usedFiles, usedAssets } =
getDependencies(entryFile);
const { usedDependencies, usedFiles, usedAssets } = getDependencies(entryFile)
// Étape 2: Supprimer les fichiers scripts inutilisés
const unusedScriptFiles = allScriptFiles.filter(
(file) => !usedFiles.has(file)
);
const unusedScriptFiles = allScriptFiles.filter((file) => !usedFiles.has(file))
unusedScriptFiles.forEach((file) => {
fs.unlinkSync(file);
console.log(`Fichier script supprimé: ${file}`);
});
fs.unlinkSync(file)
console.log(`Fichier script supprimé: ${file}`)
})
// Étape 3: Supprimer les images non utilisées
const unusedAssetFiles = allAssetFiles.filter(
(file) => !usedAssets.has(file)
);
const unusedAssetFiles = allAssetFiles.filter((file) => !usedAssets.has(file))
unusedAssetFiles.forEach((file) => {
fs.unlinkSync(file);
console.log(`Fichier image supprimé: ${file}`);
});
fs.unlinkSync(file)
console.log(`Fichier image supprimé: ${file}`)
})
// Étape 4: Optimiser les images utilisées sans perte de qualité
async function optimizeImages(usedAssets) {
// Import dynamique des modules ES
const imagemin = (await import("imagemin")).default;
const imageminOptipng = (await import("imagemin-optipng")).default;
const imageminJpegtran = (await import("imagemin-jpegtran")).default;
const imageminGifsicle = (await import("imagemin-gifsicle")).default;
const imageminSvgo = (await import("imagemin-svgo")).default;
const imagemin = (await import('imagemin')).default
const imageminOptipng = (await import('imagemin-optipng')).default
const imageminJpegtran = (await import('imagemin-jpegtran')).default
const imageminGifsicle = (await import('imagemin-gifsicle')).default
const imageminSvgo = (await import('imagemin-svgo')).default
for (const file of usedAssets) {
const ext = path.extname(file).toLowerCase();
const plugins = [];
const ext = path.extname(file).toLowerCase()
const plugins = []
if (ext === ".png") {
plugins.push(imageminOptipng({ optimizationLevel: 3 }));
} else if (ext === ".jpg" || ext === ".jpeg") {
plugins.push(imageminJpegtran({ progressive: true }));
} else if (ext === ".gif") {
plugins.push(imageminGifsicle({ optimizationLevel: 3 }));
} else if (ext === ".svg") {
plugins.push(imageminSvgo());
if (ext === '.png') {
plugins.push(imageminOptipng({ optimizationLevel: 3 }))
} else if (ext === '.jpg' || ext === '.jpeg') {
plugins.push(imageminJpegtran({ progressive: true }))
} else if (ext === '.gif') {
plugins.push(imageminGifsicle({ optimizationLevel: 3 }))
} else if (ext === '.svg') {
plugins.push(imageminSvgo())
} else {
continue;
continue
}
try {
const optimized = await imagemin([file], {
destination: path.dirname(file),
plugins: plugins,
});
})
if (optimized && optimized.length > 0) {
console.log(`Image optimisée: ${file}`);
console.log(`Image optimisée: ${file}`)
} else {
console.log(`Image déjà optimisée ou non optimisable: ${file}`);
console.log(`Image déjà optimisée ou non optimisable: ${file}`)
}
} catch (error) {
console.error(
`Erreur lors de l'optimisation de l'image ${file}:`,
error
);
console.error(`Erreur lors de l'optimisation de l'image ${file}:`, error)
}
}
}
await optimizeImages(usedAssets);
})();
await optimizeImages(usedAssets)
})()
+6 -6
View File
@@ -1,10 +1,10 @@
exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y";
exports.GEMINI_API_KEY = 'AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y'
exports.SUNO_API_KEY = "c1636e04f606811511e19ec6e1545aa6"; // api key
exports.SUNO_API_KEY = 'c1636e04f606811511e19ec6e1545aa6' // api key
exports.RESEND_API_KEY = "re_NLcHmYiz_4LoFrzvPBbShNQBgNGTQmBbu";
exports.RESEND_API_KEY = 're_NLcHmYiz_4LoFrzvPBbShNQBgNGTQmBbu'
exports.STRIPE_SECRET_KEY =
"sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu";
exports.STRIPE_WEBHOOK_SECRET = "whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW";
exports.STRIPE_RETURN_URL = "";
'sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu'
exports.STRIPE_WEBHOOK_SECRET = 'whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW'
exports.STRIPE_RETURN_URL = ''
+6 -7
View File
@@ -1,8 +1,7 @@
exports.SUNO_API_BASE = "https://api.sunoapi.org";
exports.SUNO_API_PATH = "/api/v1/generate";
exports.SUNO_STATUS_PATH = "/api/v1/generate/record-info";
exports.SUNO_TIMESTAMPED_LYRICS_PATH =
"/api/v1/generate/get-timestamped-lyrics";
exports.SUNO_MODEL = "V5";
exports.SUNO_API_BASE = 'https://api.sunoapi.org'
exports.SUNO_API_PATH = '/api/v1/generate'
exports.SUNO_STATUS_PATH = '/api/v1/generate/record-info'
exports.SUNO_TIMESTAMPED_LYRICS_PATH = '/api/v1/generate/get-timestamped-lyrics'
exports.SUNO_MODEL = 'V5'
exports.SUNO_CALLBACK_URL =
"https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback";
'https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback'
+4 -4
View File
@@ -1,5 +1,5 @@
exports.BATCH_TYPE = {
ADD: "ADD",
UPDATE: "UPDATE",
DELETE: "DELETE",
};
ADD: 'ADD',
UPDATE: 'UPDATE',
DELETE: 'DELETE',
}
+16 -17
View File
@@ -1,18 +1,17 @@
const fs = require("fs");
const path = require("path");
const fs = require('fs')
const path = require('path')
const musicLandLogoBase64 = fs.readFileSync(
path.join(__dirname, "../assets/musicLandLogo.png"),
{ encoding: "base64" },
);
const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`;
const musicLandLogoBase64 = fs.readFileSync(path.join(__dirname, '../assets/musicLandLogo.png'), {
encoding: 'base64',
})
const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`
function basicTemplate({ title = "", content = "", button = null }) {
function basicTemplate({ title = '', content = '', button = null }) {
const btn = button?.url
? `<p><a href="${button.url}" style="display:inline-block;padding:10px 16px;background:#6C5CE7;color:#fff;border-radius:8px;text-decoration:none">${
button?.label || "Ouvrir"
button?.label || 'Ouvrir'
}</a></p>`
: "";
: ''
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
<meta charset="utf-8" />
<meta name="color-scheme" content="dark" />
@@ -50,12 +49,12 @@ function basicTemplate({ title = "", content = "", button = null }) {
</td>
</tr>
</table>
</body></html>`;
</body></html>`
}
function welcomeTemplate({ firstName = "", lastName = "" }) {
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
const greeting = fullName ? `Salut ${fullName},` : "Salut,";
function welcomeTemplate({ firstName = '', lastName = '' }) {
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim()
const greeting = fullName ? `Salut ${fullName},` : 'Salut,'
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
<meta charset="utf-8" />
<meta name="color-scheme" content="dark" />
@@ -104,8 +103,8 @@ function welcomeTemplate({ firstName = "", lastName = "" }) {
</td>
</tr>
</table>
</body></html>`;
</body></html>`
}
exports.basicTemplate = basicTemplate;
exports.welcomeTemplate = welcomeTemplate;
exports.basicTemplate = basicTemplate
exports.welcomeTemplate = welcomeTemplate
+34 -40
View File
@@ -1,88 +1,82 @@
const admin = require("firebase-admin");
const { BATCH_TYPE } = require("../config/types");
const admin = require('firebase-admin')
const { BATCH_TYPE } = require('../config/types')
async function deleteFolder(path) {
try {
console.log(`Deleting folder: ${path}`);
const bucket = admin.storage().bucket();
await bucket.deleteFiles({ prefix: path, force: true });
console.log(`Folder ${path} deleted successfully`);
console.log(`Deleting folder: ${path}`)
const bucket = admin.storage().bucket()
await bucket.deleteFiles({ prefix: path, force: true })
console.log(`Folder ${path} deleted successfully`)
} catch (e) {
console.log(e);
console.log(e)
}
}
async function batchFirestore({
path = "", // ADD only
path = '', // ADD only
docs = [], // not for ADD
data = {}, // not for DELETE
type = BATCH_TYPE.UPDATE,
}) {
try {
if (docs?.length === 0 && type !== BATCH_TYPE.ADD) {
console.log(`Aucun document trouvé dans la collection ${path}.`);
return;
console.log(`Aucun document trouvé dans la collection ${path}.`)
return
}
// Préparer des lots pour les opérations
const batches = [];
let currentBatch = admin.firestore().batch();
let operationCounter = 0;
const batches = []
let currentBatch = admin.firestore().batch()
let operationCounter = 0
docs.forEach((doc) => {
const ref =
doc?.ref ||
(typeof doc?.path === "string"
? admin.firestore().doc(doc.path)
: null);
doc?.ref || (typeof doc?.path === 'string' ? admin.firestore().doc(doc.path) : null)
const payload =
doc && typeof doc.data === "object" && doc.data !== null && !Array.isArray(doc.data)
doc && typeof doc.data === 'object' && doc.data !== null && !Array.isArray(doc.data)
? doc.data
: data;
: data
if (type === BATCH_TYPE.ADD) {
// Pour ADD, créer un nouveau document avec ID automatique
const newDocRef = admin.firestore().collection(path).doc();
currentBatch.set(newDocRef, data);
const newDocRef = admin.firestore().collection(path).doc()
currentBatch.set(newDocRef, data)
} else if (type === BATCH_TYPE.UPDATE) {
if (!ref) {
throw new Error("batchFirestore UPDATE nécessite une référence de document.");
throw new Error('batchFirestore UPDATE nécessite une référence de document.')
}
if (!payload || Object.keys(payload).length === 0) {
throw new Error("batchFirestore UPDATE nécessite des données à écrire.");
throw new Error('batchFirestore UPDATE nécessite des données à écrire.')
}
currentBatch.set(ref, payload, { merge: true });
currentBatch.set(ref, payload, { merge: true })
} else if (type === BATCH_TYPE.DELETE) {
if (!ref) {
throw new Error("batchFirestore DELETE nécessite une référence de document.");
throw new Error('batchFirestore DELETE nécessite une référence de document.')
}
currentBatch.delete(ref);
currentBatch.delete(ref)
} else {
throw new Error(`Opération non supportée : ${type}`);
throw new Error(`Opération non supportée : ${type}`)
}
operationCounter++;
operationCounter++
// Si le lot atteint la limite de 500 opérations, le sauvegarder et en créer un nouveau
if (operationCounter === 500) {
batches.push(currentBatch);
currentBatch = admin.firestore().batch();
operationCounter = 0;
batches.push(currentBatch)
currentBatch = admin.firestore().batch()
operationCounter = 0
}
});
})
// Ajouter le dernier lot si des opérations y sont présentes
if (operationCounter > 0) {
batches.push(currentBatch);
batches.push(currentBatch)
}
// Exécuter tous les lots en parallèle
await Promise.all(batches.map((batch) => batch.commit()));
await Promise.all(batches.map((batch) => batch.commit()))
} catch (error) {
console.error(
`Erreur lors des opérations ${type} pour la collection ${path} :`,
error,
);
console.error(`Erreur lors des opérations ${type} pour la collection ${path} :`, error)
}
}
exports.batchFirestore = batchFirestore;
exports.deleteFolder = deleteFolder;
exports.batchFirestore = batchFirestore
exports.deleteFolder = deleteFolder
+99 -114
View File
@@ -1,44 +1,42 @@
const { googleAI } = require("@genkit-ai/googleai");
const { genkit, z } = require("genkit");
const { GEMINI_API_KEY } = require("../config/keys");
const admin = require("firebase-admin");
const { Buffer } = require("buffer");
const { setTimeout } = require("timers/promises");
const { googleAI } = require('@genkit-ai/googleai')
const { genkit, z } = require('genkit')
const { GEMINI_API_KEY } = require('../config/keys')
const admin = require('firebase-admin')
const { Buffer } = require('buffer')
const { setTimeout } = require('timers/promises')
// --- CONFIGURATION ---
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API.
const TEXT_MODEL_NAME = "gemini-3-pro-preview";
const IMAGE_MODEL_NAME = "gemini-3-pro-image-preview";
const TEXT_MODEL_NAME = 'gemini-3-pro-preview'
const IMAGE_MODEL_NAME = 'gemini-3-pro-image-preview'
// --- SINGLETON PATTERN (WARM START) ---
// On stocke l'instance en dehors de la fonction pour la réutiliser
// entre les invocations si le conteneur est "chaud".
let aiInstance = null;
let aiInstance = null
const getAiInstance = () => {
if (!aiInstance) {
console.log("⚡ [Gemini] Initialisation froide (Cold Start)");
console.log('⚡ [Gemini] Initialisation froide (Cold Start)')
aiInstance = genkit({
plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
});
})
}
return aiInstance;
};
return aiInstance
}
/**
* Génération de texte générique
*/
exports.generateAI = async ({ system = "", prompt = "", schema }) => {
const ai = getAiInstance(); // Récupère l'instance singleton
exports.generateAI = async ({ system = '', prompt = '', schema }) => {
const ai = getAiInstance() // Récupère l'instance singleton
if (prompt?.length < 1) {
throw new Error(
"Vous devez spécifier un prompt pour effectuer cette action.",
);
throw new Error('Vous devez spécifier un prompt pour effectuer cette action.')
}
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`);
const startedAt = Date.now();
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`)
const startedAt = Date.now()
try {
const { output } = await ai.generate({
@@ -49,76 +47,68 @@ exports.generateAI = async ({ system = "", prompt = "", schema }) => {
config: {
temperature: 0.7, // Créativité équilibrée
},
});
})
return output;
return output
} catch (error) {
console.error("❌ [generateAI] Error:", error.message);
throw error;
console.error('❌ [generateAI] Error:', error.message)
throw error
} finally {
console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`);
console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`)
}
};
}
/**
* Analyse la toxicité des paroles.
* Utilise gemini-1.5-flash-002 avec des réglages permissifs pour l'analyse.
*/
exports.analyseLyrics = async ({ title = "", lyrics }) => {
const ai = getAiInstance();
exports.analyseLyrics = async ({ title = '', lyrics }) => {
const ai = getAiInstance()
// --- Normalisation ---
const normalizeLyrics = (raw) => {
if (!raw) return "";
if (typeof raw === "string") return raw;
if (!raw) return ''
if (typeof raw === 'string') return raw
if (Array.isArray(raw)) {
return raw
.map((s) => {
if (!s) return "";
const label = s.type ? String(s.type).toUpperCase() : "SECTION";
return `[${label}]\n${s.lyrics || ""}`;
if (!s) return ''
const label = s.type ? String(s.type).toUpperCase() : 'SECTION'
return `[${label}]\n${s.lyrics || ''}`
})
.filter(Boolean)
.join("\n\n");
.join('\n\n')
}
if (typeof raw === "object") {
const parts = [];
if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`);
if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`);
return parts.join("\n\n");
if (typeof raw === 'object') {
const parts = []
if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`)
if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`)
return parts.join('\n\n')
}
return String(raw || "");
};
return String(raw || '')
}
const lyricsText = normalizeLyrics(lyrics).trim();
if (!lyricsText) throw new Error("analyseLyrics: paroles requises.");
const lyricsText = normalizeLyrics(lyrics).trim()
if (!lyricsText) throw new Error('analyseLyrics: paroles requises.')
// --- Schéma ---
const moderationSchema = z.object({
title: z.string().describe("Titre analysé"),
flagged: z
.boolean()
.describe("Vrai si le contenu nécessite un avertissement."),
blocked: z
.boolean()
.describe("Vrai UNIQUEMENT si violation grave (Haine, Violence réelle)."),
score: z
.number()
.min(0)
.max(1)
.describe("Score de risque (0=Sûr, 1=Dangereux)."),
reasons: z.array(z.string()).describe("Liste concise des raisons."),
title: z.string().describe('Titre analysé'),
flagged: z.boolean().describe('Vrai si le contenu nécessite un avertissement.'),
blocked: z.boolean().describe('Vrai UNIQUEMENT si violation grave (Haine, Violence réelle).'),
score: z.number().min(0).max(1).describe('Score de risque (0=Sûr, 1=Dangereux).'),
reasons: z.array(z.string()).describe('Liste concise des raisons.'),
excerpts: z
.array(
z.object({
quote: z.string(),
category: z.string(),
severity: z.string(),
}),
})
)
.max(10),
success: z.boolean(),
});
})
// --- Prompt ---
const system = `Tu es un Expert en Modération de Contenu Musical (Trust & Safety).
@@ -127,20 +117,20 @@ TA MISSION : Distinguer l'expression artistique (même crue/vulgaire) du contenu
1. "FLAGGED" (Avertissement) : Vulgarités, thèmes matures, drogue, sexe consensuel.
2. "BLOCKED" (Interdit) : Discours de haine, harcèlement ciblé, pédopornographie, incitation explicite violence/suicide.
Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.`;
Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.`
const userPrompt = `
ANALYSE CETTE CHANSON :
Titre : ${title || "Inconnu"}
Titre : ${title || 'Inconnu'}
PAROLES :
"""
${lyricsText}
"""
`;
`
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`);
const startedAt = Date.now();
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`)
const startedAt = Date.now()
try {
const { output } = await ai.generate({
@@ -151,108 +141,103 @@ ${lyricsText}
config: {
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
safetySettings: [
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" },
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
{
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
threshold: "BLOCK_NONE",
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
threshold: 'BLOCK_NONE',
},
{
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
threshold: "BLOCK_NONE",
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
threshold: 'BLOCK_NONE',
},
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" },
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
],
},
});
})
if (!output) throw new Error("Échec de l'analyse de modération.");
if (!output) throw new Error("Échec de l'analyse de modération.")
// Correction de cohérence
if (output.blocked) {
output.flagged = true;
if (output.score < 0.7) output.score = 0.85;
output.flagged = true
if (output.score < 0.7) output.score = 0.85
}
return output;
return output
} finally {
console.log(`⏱️ [analyseLyrics] Durée: ${Date.now() - startedAt}ms`);
console.log(`⏱️ [analyseLyrics] Durée: ${Date.now() - startedAt}ms`)
}
};
}
/**
* Génération d'image via Imagen 3
*/
exports.generateImageV2 = async (prompt, size = 1024, path = "") => {
const ai = getAiInstance();
exports.generateImageV2 = async (prompt, size = 1024, path = '') => {
const ai = getAiInstance()
if (typeof prompt !== "string" || prompt.trim().length < 1) {
throw new Error("Prompt requis.");
if (typeof prompt !== 'string' || prompt.trim().length < 1) {
throw new Error('Prompt requis.')
}
console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`);
const startedAt = Date.now();
console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`)
const startedAt = Date.now()
// Optimisation du prompt pour Imagen
let enhancedPrompt = prompt.trim();
if (!enhancedPrompt.toLowerCase().includes("high quality")) {
enhancedPrompt += ", high quality, detailed, 4k";
let enhancedPrompt = prompt.trim()
if (!enhancedPrompt.toLowerCase().includes('high quality')) {
enhancedPrompt += ', high quality, detailed, 4k'
}
// Aspect ratio 1:1 pour les pochettes
enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`;
enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`
const maxAttempts = 3;
let lastError = null;
const maxAttempts = 3
let lastError = null
try {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
console.log(`🔄 Tentative ${attempt}/${maxAttempts}`);
console.log(`🔄 Tentative ${attempt}/${maxAttempts}`)
const response = await ai.generate({
model: googleAI.model(IMAGE_MODEL_NAME),
prompt: enhancedPrompt,
});
})
const media = response.media;
const media = response.media
if (media && media.url) {
console.log("✅ Image générée.");
console.log('✅ Image générée.')
// --- Sauvegarde dans Firebase Storage ---
const dataUrl = String(media.url);
const commaIdx = dataUrl.indexOf(",");
const b64 =
commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl;
const buffer = Buffer.from(b64, "base64");
const dataUrl = String(media.url)
const commaIdx = dataUrl.indexOf(',')
const b64 = commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl
const buffer = Buffer.from(b64, 'base64')
const bucket = admin.storage().bucket();
const token = require("crypto").randomUUID();
const file = bucket.file(path);
const bucket = admin.storage().bucket()
const token = require('crypto').randomUUID()
const file = bucket.file(path)
await file.save(buffer, {
resumable: false,
metadata: {
contentType: media.contentType || "image/png",
contentType: media.contentType || 'image/png',
metadata: { firebaseStorageDownloadTokens: token },
},
});
})
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`;
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`
}
throw new Error("Pas de média dans la réponse IA.");
throw new Error('Pas de média dans la réponse IA.')
} catch (err) {
console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message);
lastError = err;
if (attempt < maxAttempts) await setTimeout(2000 * attempt);
console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message)
lastError = err
if (attempt < maxAttempts) await setTimeout(2000 * attempt)
}
}
throw new Error(
`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`,
);
throw new Error(`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`)
} finally {
console.log(
`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`,
);
console.log(`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`)
}
};
}
+47 -69
View File
@@ -1,137 +1,115 @@
exports.generatePicturePrompt = (project = {}) => {
const {
title = "",
title = '',
lyrics: lyricsRaw,
musicConfig = {},
coverStyle: coverStyleRaw = "",
artistName: artistNameRaw = "",
} = project || {};
coverStyle: coverStyleRaw = '',
artistName: artistNameRaw = '',
} = project || {}
// --- 1. Nettoyage et Normalisation ---
const sanitizeInline = (value = "") => {
if (typeof value !== "string") return "";
const sanitizeInline = (value = '') => {
if (typeof value !== 'string') return ''
return value
.replace(/[\r\n]+/g, " ")
.replace(/[<>]/g, "")
.trim();
};
.replace(/[\r\n]+/g, ' ')
.replace(/[<>]/g, '')
.trim()
}
const titleForPrompt = sanitizeInline(title) || "Sans titre";
const artistName =
sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || "");
const hasArtistName = artistName.length > 0;
const titleForPrompt = sanitizeInline(title) || 'Sans titre'
const artistName = sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || '')
const hasArtistName = artistName.length > 0
const {
genres = [],
tempo = "",
mood = "",
instruments = [],
} = musicConfig || {};
const userStyle = sanitizeInline(coverStyleRaw);
const { genres = [], tempo = '', mood = '', instruments = [] } = musicConfig || {}
const userStyle = sanitizeInline(coverStyleRaw)
// --- 2. Intelligence Visuelle (Mapping) ---
// Détermination de l'énergie visuelle
const isEnergetic =
tempo &&
/\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(
String(tempo),
);
tempo && /\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(String(tempo))
const isDark =
mood &&
/\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(
String(mood),
);
mood && /\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(String(mood))
// Construction de la Palette & Lumière
let visualAtmosphere = "";
let visualAtmosphere = ''
if (isDark) {
visualAtmosphere =
"Atmosphere: Cinematic atmosphere, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents.";
'Atmosphere: Cinematic atmosphere, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents.'
} else if (isEnergetic) {
visualAtmosphere =
"Atmosphere: Dynamic atmosphere, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast.";
'Atmosphere: Dynamic atmosphere, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast.'
} else {
visualAtmosphere =
"Atmosphere: Soft atmosphere, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors.";
'Atmosphere: Soft atmosphere, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors.'
}
// Définition du Style de Rendu (Si l'utilisateur est vague, on renforce)
let renderingStyle = userStyle
? `Art Style: ${userStyle}`
: "Art Style: Digital Art, Mixed Media";
let renderingStyle = userStyle ? `Art Style: ${userStyle}` : 'Art Style: Digital Art, Mixed Media'
if (
userStyle.toLowerCase().includes("realist") ||
userStyle.toLowerCase().includes("photo")
) {
if (userStyle.toLowerCase().includes('realist') || userStyle.toLowerCase().includes('photo')) {
renderingStyle +=
", 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing.";
', 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing.'
} else if (
userStyle.toLowerCase().includes("illu") ||
userStyle.toLowerCase().includes("dessin")
userStyle.toLowerCase().includes('illu') ||
userStyle.toLowerCase().includes('dessin')
) {
renderingStyle +=
", vector art, clean lines, professional illustration, flat design or detailed painting.";
', vector art, clean lines, professional illustration, flat design or detailed painting.'
} else {
// Style par défaut "Album Cover" qui marche bien
renderingStyle +=
", abstract surrealism, conceptual album art, high fidelity, masterpiece.";
renderingStyle += ', abstract surrealism, conceptual album art, high fidelity, masterpiece.'
}
// --- 3. Extraction de l'Inspiration (Lyrics) ---
// On cherche le REFRAIN en priorité pour l'image, car c'est le cœur visuel
const sections = Array.isArray(lyricsRaw) ? lyricsRaw : [];
const chorus = sections.find(
(s) => s.type === "refrain" || s.type === "chorus",
);
const verse = sections.find(
(s) => s.type === "couplet" || s.type === "verse",
);
const sections = Array.isArray(lyricsRaw) ? lyricsRaw : []
const chorus = sections.find((s) => s.type === 'refrain' || s.type === 'chorus')
const verse = sections.find((s) => s.type === 'couplet' || s.type === 'verse')
// On prend 2 lignes max du refrain, ou du premier couplet
const visualHook = (chorus?.lyrics || verse?.lyrics || "")
.split("\n")
const visualHook = (chorus?.lyrics || verse?.lyrics || '')
.split('\n')
.filter((l) => l.length > 10) // On évite les lignes trop courtes
.slice(0, 2)
.join(". ");
.join('. ')
const imageryPrompt = visualHook
? `Visual Inspiration: An interpretation of these lyrics: "${visualHook}".`
: "Visual Inspiration: Abstract visual representation of the song's mood.";
: "Visual Inspiration: Abstract visual representation of the song's mood."
// --- 4. Construction du Prompt Final (Structure Optimisée Imagen 3) ---
const promptParts = [
// Rôle
"Design a professional, high-quality music album cover.",
'Design a professional, high-quality music album cover.',
// 1. Le Texte (Crucial pour Imagen 3 - Doit être au début ou très clair)
`**Typography & Text:**`,
`The song title "${titleForPrompt}" must be the CENTERPIECE. Write it in a distinct font that matches the mood.`,
hasArtistName
? `The artist name "${artistName}" must appear smaller, elegant, and legible near the bottom or top.`
: "",
"Ensure perfect spelling. The text should be integrated into the artwork (e.g., metallic texture, neon glow, or bold cut-out), not just pasted on top.",
: '',
'Ensure perfect spelling. The text should be integrated into the artwork (e.g., metallic texture, neon glow, or bold cut-out), not just pasted on top.',
// 2. Le Visuel
`**Visuals:**`,
renderingStyle,
imageryPrompt,
`Subject: A central visual element that represents the song. ${isEnergetic ? "Dynamic composition." : "Balanced, centered composition."}`,
`Subject: A central visual element that represents the song. ${isEnergetic ? 'Dynamic composition.' : 'Balanced, centered composition.'}`,
// 3. L'Atmosphère (Context from music config)
`**Mood & Color:**`,
visualAtmosphere,
genres.length > 0 ? `Musical Vibe Reference: ${genres.join(", ")}.` : "",
genres.length > 0 ? `Musical Vibe Reference: ${genres.join(', ')}.` : '',
// 4. Contraintes Négatives (Phrasées positivement pour l'IA)
"**Constraints:**",
"Use a square 1:1 aspect ratio.",
"Do NOT depict literal musical instruments (like guitars or microphones) unless they are part of a surreal abstract composition.",
"No blurry text. No messy borders. No watermarks.",
];
'**Constraints:**',
'Use a square 1:1 aspect ratio.',
'Do NOT depict literal musical instruments (like guitars or microphones) unless they are part of a surreal abstract composition.',
'No blurry text. No messy borders. No watermarks.',
]
return promptParts.filter(Boolean).join("\n\n");
};
return promptParts.filter(Boolean).join('\n\n')
}
+19 -19
View File
@@ -1,26 +1,26 @@
const normalizeDate = (input) =>
typeof input?.toDate === "function" ? input.toDate() : new Date(input);
typeof input?.toDate === 'function' ? input.toDate() : new Date(input)
const buildMonthKey = (timestamp) => {
const date = normalizeDate(timestamp);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
};
const date = normalizeDate(timestamp)
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${year}-${month}`
}
const buildPreviousMonthContext = (referenceDate) => {
const current = referenceDate ? new Date(referenceDate) : new Date();
current.setUTCHours(0, 0, 0, 0);
current.setUTCDate(1);
const current = referenceDate ? new Date(referenceDate) : new Date()
current.setUTCHours(0, 0, 0, 0)
current.setUTCDate(1)
const target = new Date(current);
target.setUTCMonth(target.getUTCMonth() - 1);
const target = new Date(current)
target.setUTCMonth(target.getUTCMonth() - 1)
const year = target.getUTCFullYear();
const monthIndex = target.getUTCMonth();
const rangeStart = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0));
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999));
const monthKey = buildMonthKey(rangeStart);
const year = target.getUTCFullYear()
const monthIndex = target.getUTCMonth()
const rangeStart = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0))
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999))
const monthKey = buildMonthKey(rangeStart)
return {
year,
@@ -28,10 +28,10 @@ const buildPreviousMonthContext = (referenceDate) => {
monthKey,
rangeStart,
rangeEnd,
};
};
}
}
module.exports = {
buildMonthKey,
buildPreviousMonthContext,
};
}
+195 -236
View File
@@ -1,281 +1,263 @@
const admin = require("firebase-admin");
const { HttpsError } = require("firebase-functions/https");
const Stripe = require("stripe");
const { URL } = require("url");
const admin = require('firebase-admin')
const { HttpsError } = require('firebase-functions/https')
const Stripe = require('stripe')
const { URL } = require('url')
const {
STRIPE_SECRET_KEY = "",
STRIPE_RETURN_URL = "",
STRIPE_PORTAL_CONFIGURATION = "",
STRIPE_SECRET_KEY = '',
STRIPE_RETURN_URL = '',
STRIPE_PORTAL_CONFIGURATION = '',
STRIPE_MODE: CONFIG_STRIPE_MODE,
} = require("../config/keys");
} = require('../config/keys')
const STRIPE_MODE =
typeof CONFIG_STRIPE_MODE === "string" && CONFIG_STRIPE_MODE.trim()
typeof CONFIG_STRIPE_MODE === 'string' && CONFIG_STRIPE_MODE.trim()
? CONFIG_STRIPE_MODE.trim()
: "test";
: 'test'
const requireEnv = (key) => {
const value = process.env?.[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
const value = process.env?.[key]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
throw new Error(`${key} not configured`);
};
throw new Error(`${key} not configured`)
}
const STRIPE_API_VERSION = "2023-10-16";
const DEFAULT_TEST_RETURN_URL = "http://localhost:8081";
const ALLOWED_RETURN_SCHEMES = ["http", "https", "minuit"];
const STRIPE_API_VERSION = '2023-10-16'
const DEFAULT_TEST_RETURN_URL = 'http://localhost:8081'
const ALLOWED_RETURN_SCHEMES = ['http', 'https', 'minuit']
let cachedStripeClient = null;
let cachedPortalConfigurationId = null;
let cachedStripeClient = null
let cachedPortalConfigurationId = null
const resolveStripeSecretKey = () => {
const inlineKey =
typeof STRIPE_SECRET_KEY === "string" ? STRIPE_SECRET_KEY.trim() : "";
const inlineKey = typeof STRIPE_SECRET_KEY === 'string' ? STRIPE_SECRET_KEY.trim() : ''
if (inlineKey) {
return inlineKey;
return inlineKey
}
const required = requireEnv("STRIPE_SECRET_KEY");
if (typeof required === "string" && required.trim()) {
return required.trim();
const required = requireEnv('STRIPE_SECRET_KEY')
if (typeof required === 'string' && required.trim()) {
return required.trim()
}
throw new Error("STRIPE_SECRET_KEY not configured");
};
throw new Error('STRIPE_SECRET_KEY not configured')
}
const getStripeClient = () => {
if (cachedStripeClient) {
return cachedStripeClient;
return cachedStripeClient
}
let secretKey;
let secretKey
try {
secretKey = resolveStripeSecretKey();
secretKey = resolveStripeSecretKey()
} catch (error) {
console.error("[getStripeClient] Missing STRIPE_SECRET_KEY", error);
console.error('[getStripeClient] Missing STRIPE_SECRET_KEY', error)
throw new HttpsError(
"failed-precondition",
"Stripe nest pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.",
);
'failed-precondition',
'Stripe nest pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.'
)
}
cachedStripeClient = new Stripe(secretKey, {
apiVersion: STRIPE_API_VERSION,
});
})
return cachedStripeClient;
};
return cachedStripeClient
}
const getReturnBaseUrl = () => {
const resolveBase = () => {
if (STRIPE_RETURN_URL) {
return STRIPE_RETURN_URL;
return STRIPE_RETURN_URL
}
if (STRIPE_MODE !== "prod") {
return DEFAULT_TEST_RETURN_URL;
if (STRIPE_MODE !== 'prod') {
return DEFAULT_TEST_RETURN_URL
}
return requireEnv("STRIPE_RETURN_URL");
};
const rawBase = resolveBase();
const sanitizedBase = typeof rawBase === "string" ? rawBase.trim() : "";
if (!sanitizedBase) {
if (STRIPE_MODE !== "prod") {
return DEFAULT_TEST_RETURN_URL;
}
throw new Error("STRIPE_RETURN_URL not configured");
return requireEnv('STRIPE_RETURN_URL')
}
return sanitizedBase.endsWith("/")
? sanitizedBase.slice(0, -1)
: sanitizedBase;
};
const rawBase = resolveBase()
const sanitizedBase = typeof rawBase === 'string' ? rawBase.trim() : ''
if (!sanitizedBase) {
if (STRIPE_MODE !== 'prod') {
return DEFAULT_TEST_RETURN_URL
}
throw new Error('STRIPE_RETURN_URL not configured')
}
return sanitizedBase.endsWith('/') ? sanitizedBase.slice(0, -1) : sanitizedBase
}
const sanitizeReturnUrl = (value) => {
if (typeof value !== "string") {
return null;
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim();
const trimmed = value.trim()
if (!trimmed) {
return null;
return null
}
const validateScheme = (scheme) => {
if (!ALLOWED_RETURN_SCHEMES.includes(scheme)) {
throw new HttpsError(
"invalid-argument",
`Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`,
);
'invalid-argument',
`Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`
)
}
};
}
try {
const parsedUrl = new URL(trimmed);
const scheme = parsedUrl.protocol.replace(":", "").toLowerCase();
validateScheme(scheme);
return trimmed;
const parsedUrl = new URL(trimmed)
const scheme = parsedUrl.protocol.replace(':', '').toLowerCase()
validateScheme(scheme)
return trimmed
} catch (_error) {
const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i);
const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i)
if (schemeMatch && schemeMatch[1]) {
const scheme = schemeMatch[1].toLowerCase();
validateScheme(scheme);
return trimmed;
const scheme = schemeMatch[1].toLowerCase()
validateScheme(scheme)
return trimmed
}
throw new HttpsError(
"invalid-argument",
`URL de retour Stripe invalide: ${trimmed}`,
);
throw new HttpsError('invalid-argument', `URL de retour Stripe invalide: ${trimmed}`)
}
};
}
const getReturnUrls = (overrides) => {
if (overrides && typeof overrides === "object") {
const successOverride = sanitizeReturnUrl(overrides.successUrl);
const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl);
if (overrides && typeof overrides === 'object') {
const successOverride = sanitizeReturnUrl(overrides.successUrl)
const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl)
if (!successOverride) {
throw new HttpsError(
"invalid-argument",
"successUrl est requis pour configurer les retours Stripe.",
);
'invalid-argument',
'successUrl est requis pour configurer les retours Stripe.'
)
}
return {
successUrl: successOverride,
cancelUrl: cancelOverride || successOverride,
};
}
}
const baseUrl = getReturnBaseUrl();
const baseUrl = getReturnBaseUrl()
const joinPath = (url, path) => {
const trimmedUrl = url.endsWith("/") ? url.slice(0, -1) : url;
const trimmedPath = path.startsWith("/") ? path.slice(1) : path;
return `${trimmedUrl}/${trimmedPath}`;
};
const trimmedUrl = url.endsWith('/') ? url.slice(0, -1) : url
const trimmedPath = path.startsWith('/') ? path.slice(1) : path
return `${trimmedUrl}/${trimmedPath}`
}
const appendQuery = (url, query) =>
url.includes("?") ? `${url}&${query}` : `${url}?${query}`;
const appendQuery = (url, query) => (url.includes('?') ? `${url}&${query}` : `${url}?${query}`)
const successBase = joinPath(baseUrl, "payment-success");
const cancelBase = joinPath(baseUrl, "payment-error");
const successBase = joinPath(baseUrl, 'payment-success')
const cancelBase = joinPath(baseUrl, 'payment-error')
return {
successUrl: appendQuery(successBase, "session_id={CHECKOUT_SESSION_ID}"),
cancelUrl: appendQuery(cancelBase, "session_id={CHECKOUT_SESSION_ID}"),
};
};
successUrl: appendQuery(successBase, 'session_id={CHECKOUT_SESSION_ID}'),
cancelUrl: appendQuery(cancelBase, 'session_id={CHECKOUT_SESSION_ID}'),
}
}
const normalizeBoolean = (value) => value === true;
const normalizeBoolean = (value) => value === true
const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
if (!Array.isArray(productList) || productList.length === 0) {
throw new HttpsError(
"invalid-argument",
"Au moins un produit est requis pour créer une session de paiement.",
);
'invalid-argument',
'Au moins un produit est requis pour créer une session de paiement.'
)
}
const lineItems = [];
const summary = [];
let hasSubscription = false;
const lineItems = []
const summary = []
let hasSubscription = false
for (let index = 0; index < productList.length; index += 1) {
const rawItem = productList[index];
const item = rawItem && typeof rawItem === "object" ? rawItem : {};
const rawItem = productList[index]
const item = rawItem && typeof rawItem === 'object' ? rawItem : {}
const isRenewable = normalizeBoolean(item.isRenewable);
const isRenewable = normalizeBoolean(item.isRenewable)
const rawQuantity =
typeof item.quantity === "number" && Number.isFinite(item.quantity)
typeof item.quantity === 'number' && Number.isFinite(item.quantity)
? item.quantity
: parseInt(item.quantity, 10);
const quantity =
Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1;
: parseInt(item.quantity, 10)
const quantity = Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1
const priceId = typeof item.priceID === "string" ? item.priceID.trim() : "";
const priceId = typeof item.priceID === 'string' ? item.priceID.trim() : ''
if (isRenewable && !priceId) {
throw new HttpsError(
"invalid-argument",
`Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`,
);
'invalid-argument',
`Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`
)
}
if (priceId) {
let stripePrice = null;
let stripePrice = null
if (stripe) {
try {
stripePrice = await stripe.prices.retrieve(priceId);
stripePrice = await stripe.prices.retrieve(priceId)
} catch (error) {
console.error(
"[buildCheckoutLineItems] Unable to retrieve price",
priceId,
error,
);
console.error('[buildCheckoutLineItems] Unable to retrieve price', priceId, error)
throw new HttpsError(
"invalid-argument",
`Le price ID "${priceId}" est introuvable (élément ${index + 1}).`,
);
'invalid-argument',
`Le price ID "${priceId}" est introuvable (élément ${index + 1}).`
)
}
}
const priceIsRecurring =
stripePrice?.type === "recurring" || !!stripePrice?.recurring;
const priceIsRecurring = stripePrice?.type === 'recurring' || !!stripePrice?.recurring
if (isRenewable && !priceIsRecurring) {
throw new HttpsError(
"invalid-argument",
`Le price ID "${priceId}" n'est pas compatible avec un abonnement.`,
);
'invalid-argument',
`Le price ID "${priceId}" n'est pas compatible avec un abonnement.`
)
}
const resolvedIsRenewable = priceIsRecurring ? true : isRenewable;
const resolvedIsRenewable = priceIsRecurring ? true : isRenewable
if (resolvedIsRenewable) {
hasSubscription = true;
hasSubscription = true
}
lineItems.push({
price: priceId,
quantity,
});
})
summary.push({
type: "price",
type: 'price',
priceID: priceId,
quantity,
isRenewable: resolvedIsRenewable,
});
continue;
})
continue
}
const rawUnitAmount = Number(item.unitAmount);
const unitAmount = Math.round(rawUnitAmount);
const rawUnitAmount = Number(item.unitAmount)
const unitAmount = Math.round(rawUnitAmount)
if (!Number.isFinite(unitAmount) || unitAmount <= 0) {
throw new HttpsError(
"invalid-argument",
`Le montant indiqué pour l'élément ${index + 1} est invalide.`,
);
'invalid-argument',
`Le montant indiqué pour l'élément ${index + 1} est invalide.`
)
}
const currency =
typeof item.currency === "string"
? item.currency.trim().toLowerCase()
: "eur";
const currency = typeof item.currency === 'string' ? item.currency.trim().toLowerCase() : 'eur'
if (!/^[a-z]{3}$/.test(currency)) {
throw new HttpsError(
"invalid-argument",
`La devise indiquée pour l'élément ${index + 1} est invalide.`,
);
'invalid-argument',
`La devise indiquée pour l'élément ${index + 1} est invalide.`
)
}
const label =
typeof item.label === "string" && item.label.trim()
? item.label.trim()
: "Paiement ponctuel";
typeof item.label === 'string' && item.label.trim() ? item.label.trim() : 'Paiement ponctuel'
lineItems.push({
price_data: {
@@ -286,95 +268,84 @@ const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
unit_amount: unitAmount,
},
quantity,
});
})
summary.push({
type: "custom",
type: 'custom',
currency,
unitAmount,
quantity,
isRenewable: false,
});
})
}
if (hasSubscription) {
const hasNonSubscription = summary.some((item) => !item.isRenewable);
const hasNonSubscription = summary.some((item) => !item.isRenewable)
if (hasNonSubscription) {
throw new HttpsError(
"invalid-argument",
"Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.",
);
'invalid-argument',
'Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.'
)
}
}
return { lineItems, summary, hasSubscription };
};
return { lineItems, summary, hasSubscription }
}
const ensureStripeCustomer = async ({
uid,
stripe,
refsList,
createIfMissing = true,
}) => {
const ensureStripeCustomer = async ({ uid, stripe, refsList, createIfMissing = true }) => {
if (!uid) {
return { customerId: null, userData: null };
return { customerId: null, userData: null }
}
const userRef = refsList?.users?.doc(uid);
const snapshot = userRef ? await userRef.get() : null;
const userData = snapshot?.exists ? snapshot.data() : null;
const userRef = refsList?.users?.doc(uid)
const snapshot = userRef ? await userRef.get() : null
const userData = snapshot?.exists ? snapshot.data() : null
let customerId = userData?.stripeCustomerId;
let customerId = userData?.stripeCustomerId
if (customerId) {
return { customerId, userData };
return { customerId, userData }
}
if (!createIfMissing) {
return { customerId: null, userData };
return { customerId: null, userData }
}
let authRecord = null;
let authRecord = null
try {
authRecord = await admin.auth().getUser(uid);
authRecord = await admin.auth().getUser(uid)
} catch (error) {
console.warn(
"[ensureStripeCustomer] Impossible de récupérer auth user",
error,
);
console.warn('[ensureStripeCustomer] Impossible de récupérer auth user', error)
}
const email = userData?.email || authRecord?.email || undefined;
const nameFromProfile = [userData?.firstName, userData?.lastName]
.filter(Boolean)
.join(" ")
.trim();
const name = nameFromProfile || authRecord?.displayName || undefined;
const email = userData?.email || authRecord?.email || undefined
const nameFromProfile = [userData?.firstName, userData?.lastName].filter(Boolean).join(' ').trim()
const name = nameFromProfile || authRecord?.displayName || undefined
const customer = await stripe.customers.create({
email,
name,
metadata: {
firebaseUID: uid,
appMode: STRIPE_MODE || "test",
appMode: STRIPE_MODE || 'test',
},
});
})
customerId = customer.id;
customerId = customer.id
if (userRef) {
await userRef.set(
{
stripeCustomerId: customerId,
},
{ merge: true },
);
{ merge: true }
)
}
return {
customerId,
userData: { ...userData, stripeCustomerId: customerId },
};
};
}
}
const formatCheckoutSessionResponse = (session) => ({
id: session.id,
@@ -391,84 +362,72 @@ const formatCheckoutSessionResponse = (session) => ({
created: session.created,
expires_at: session.expires_at,
client_secret: session.client_secret || null,
});
})
const getPortalConfigurationId = async (stripe) => {
if (STRIPE_PORTAL_CONFIGURATION) {
return STRIPE_PORTAL_CONFIGURATION;
return STRIPE_PORTAL_CONFIGURATION
}
if (cachedPortalConfigurationId) {
return cachedPortalConfigurationId;
return cachedPortalConfigurationId
}
if (
!stripe ||
typeof stripe.billingPortal?.configurations?.list !== "function"
) {
throw new HttpsError(
"internal",
"Client Stripe indisponible pour la configuration du portail.",
);
if (!stripe || typeof stripe.billingPortal?.configurations?.list !== 'function') {
throw new HttpsError('internal', 'Client Stripe indisponible pour la configuration du portail.')
}
try {
const configurations = await stripe.billingPortal.configurations.list({
limit: 100,
});
})
const defaultConfiguration =
configurations.data.find((config) => config.is_default) ||
configurations.data.find((config) => config.active);
configurations.data.find((config) => config.active)
if (defaultConfiguration?.id) {
cachedPortalConfigurationId = defaultConfiguration.id;
return cachedPortalConfigurationId;
cachedPortalConfigurationId = defaultConfiguration.id
return cachedPortalConfigurationId
}
} catch (error) {
console.warn(
"[getPortalConfigurationId] Impossible de lister les configurations de portail",
error?.message || error,
);
'[getPortalConfigurationId] Impossible de lister les configurations de portail',
error?.message || error
)
}
try {
const defaultReturnUrl = getReturnBaseUrl();
const createdConfiguration =
await stripe.billingPortal.configurations.create({
default_return_url: defaultReturnUrl,
business_profile: {
headline: "Minuit Starter",
},
});
const defaultReturnUrl = getReturnBaseUrl()
const createdConfiguration = await stripe.billingPortal.configurations.create({
default_return_url: defaultReturnUrl,
business_profile: {
headline: 'Minuit Starter',
},
})
if (createdConfiguration?.id) {
cachedPortalConfigurationId = createdConfiguration.id;
return cachedPortalConfigurationId;
cachedPortalConfigurationId = createdConfiguration.id
return cachedPortalConfigurationId
}
} catch (error) {
console.warn(
"[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut",
error?.message || error,
);
'[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut',
error?.message || error
)
}
return null;
};
return null
}
const mapStripeErrorToHttps = (error, fallbackMessage) => {
const message =
error?.raw?.message ||
error?.message ||
fallbackMessage ||
"Erreur Stripe.";
const statusCode = error?.statusCode || error?.raw?.statusCode;
const isClientError =
typeof statusCode === "number" && statusCode >= 400 && statusCode < 500;
const message = error?.raw?.message || error?.message || fallbackMessage || 'Erreur Stripe.'
const statusCode = error?.statusCode || error?.raw?.statusCode
const isClientError = typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500
const code = isClientError ? "failed-precondition" : "internal";
return new HttpsError(code, message);
};
const code = isClientError ? 'failed-precondition' : 'internal'
return new HttpsError(code, message)
}
module.exports = {
getStripeClient,
@@ -479,4 +438,4 @@ module.exports = {
formatCheckoutSessionResponse,
getPortalConfigurationId,
mapStripeErrorToHttps,
};
}
+41 -43
View File
@@ -1,54 +1,52 @@
const admin = require("firebase-admin");
const admin = require('firebase-admin')
// Use default credentials/environment provided by Cloud Functions.
// Avoid bundling a service account key and hardcoding project/bucket.
admin.initializeApp();
admin.initializeApp()
const db = admin.firestore();
const REGION = process.env.FIREBASE_REGION || "europe-west1";
const db = admin.firestore()
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
exports.db = db;
exports.REGION = REGION;
exports.db = db
exports.REGION = REGION
exports.refList = {
projects: db.collection("projects"),
playlists: db.collection("playlists"),
tasks: db.collection("tasks"),
notifications: db.collection("notifications"),
users: db.collection("users"),
projectStreamStats: db.collection("projectStreamStats"),
projectStreamStatsMonthlyTotals: db.collection(
"projectStreamStatsMonthlyTotals",
),
monthlyPayoutEntries: db.collection("monthlyPayoutEntries"),
monthlyPayouts: db.collection("monthlyPayouts"),
};
exports.refsList = exports.refList;
projects: db.collection('projects'),
playlists: db.collection('playlists'),
tasks: db.collection('tasks'),
notifications: db.collection('notifications'),
users: db.collection('users'),
projectStreamStats: db.collection('projectStreamStats'),
projectStreamStatsMonthlyTotals: db.collection('projectStreamStatsMonthlyTotals'),
monthlyPayoutEntries: db.collection('monthlyPayoutEntries'),
monthlyPayouts: db.collection('monthlyPayouts'),
}
exports.refsList = exports.refList
exports.ALERT_TYPE = {
NEW_LIKE: "NEW_LIKE",
NEW_COMMENT: "NEW_COMMENT",
NEW_FOLLOWER: "NEW_FOLLOWER",
MUSIC_GENERATION_SUCCESS: "MUSIC_GENERATION_SUCCESS",
MUSIC_GENERATION_FAILED: "MUSIC_GENERATION_FAILED",
COVER_GENERATION_SUCCESS: "COVER_GENERATION_SUCCESS",
COVER_GENERATION_FAILED: "COVER_GENERATION_FAILED",
CREDITS_UPDATED: "CREDITS_UPDATED",
PAYOUT_AVAILABLE: "PAYOUT_AVAILABLE",
};
NEW_LIKE: 'NEW_LIKE',
NEW_COMMENT: 'NEW_COMMENT',
NEW_FOLLOWER: 'NEW_FOLLOWER',
MUSIC_GENERATION_SUCCESS: 'MUSIC_GENERATION_SUCCESS',
MUSIC_GENERATION_FAILED: 'MUSIC_GENERATION_FAILED',
COVER_GENERATION_SUCCESS: 'COVER_GENERATION_SUCCESS',
COVER_GENERATION_FAILED: 'COVER_GENERATION_FAILED',
CREDITS_UPDATED: 'CREDITS_UPDATED',
PAYOUT_AVAILABLE: 'PAYOUT_AVAILABLE',
}
// Exporter toutes les fonctions
exports.users = require("./src/users");
exports.music = require("./src/music");
exports.lyrics = require("./src/lyrics");
exports.cover = require("./src/cover");
exports.projects = require("./src/project");
exports.thumbnail = require("./src/thumbnail");
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");
exports.subscription = require("./src/subscription");
exports.youtube = require("./src/youtube");
exports.orders = require("./src/orders");
exports.users = require('./src/users')
exports.music = require('./src/music')
exports.lyrics = require('./src/lyrics')
exports.cover = require('./src/cover')
exports.projects = require('./src/project')
exports.thumbnail = require('./src/thumbnail')
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')
exports.subscription = require('./src/subscription')
exports.youtube = require('./src/youtube')
exports.orders = require('./src/orders')
+20 -23
View File
@@ -1,26 +1,23 @@
const { onRequest } = require("firebase-functions/v2/https");
const { onRequest } = require('firebase-functions/v2/https')
exports.algoliaTransformProjectData = onRequest(
{ region: "europe-west1" },
(req, res) => {
const payload = req.body.data;
const objectID = payload.objectID;
try {
const flat = { ...payload };
delete flat["musicTimestamps"];
exports.algoliaTransformProjectData = onRequest({ region: 'europe-west1' }, (req, res) => {
const payload = req.body.data
const objectID = payload.objectID
try {
const flat = { ...payload }
delete flat['musicTimestamps']
console.log(`Change in ${payload.objectID}`);
console.log(flat);
// Ton object final doit contenir "objectID"
const result = {
objectID,
...flat,
};
res.send({ result });
} catch (e) {
console.log(`Error ${objectID}:`, e.message);
res.status(500).end();
console.log(`Change in ${payload.objectID}`)
console.log(flat)
// Ton object final doit contenir "objectID"
const result = {
objectID,
...flat,
}
},
);
res.send({ result })
} catch (e) {
console.log(`Error ${objectID}:`, e.message)
res.status(500).end()
}
})
+154 -171
View File
@@ -1,98 +1,97 @@
const { onDocumentCreated } = require("firebase-functions/v2/firestore");
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const logger = require("firebase-functions/logger");
const path = require("path");
const fs = require("fs");
const axios = require("axios");
const sharp = require("sharp");
const crypto = require("crypto");
const { onDocumentCreated } = require('firebase-functions/v2/firestore')
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const logger = require('firebase-functions/logger')
const path = require('path')
const fs = require('fs')
const axios = require('axios')
const sharp = require('sharp')
const crypto = require('crypto')
// Imports internes
const { generateImageV2 } = require("../helpers/gemini");
const { generatePicturePrompt } = require("../helpers/prompts");
const { ALERT_TYPE, refList } = require("../index");
const { sendNotification } = require("./notifications");
const { generateImageV2 } = require('../helpers/gemini')
const { generatePicturePrompt } = require('../helpers/prompts')
const { ALERT_TYPE, refList } = require('../index')
const { sendNotification } = require('./notifications')
// Configuration
const bucket = admin.storage().bucket();
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png");
const bucket = admin.storage().bucket()
const LOGO_PATH = path.resolve(__dirname, '../assets/musicLandLogo.png')
// Cache mémoire pour le buffer du logo (évite les I/O disque à chaque appel sur instance chaude)
let _cachedLogoBuffer = null;
let _cachedLogoBuffer = null
/**
* Récupère le buffer du logo depuis le cache ou le disque
*/
const getLogoBuffer = async () => {
if (_cachedLogoBuffer) return _cachedLogoBuffer;
if (_cachedLogoBuffer) return _cachedLogoBuffer
try {
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH);
return _cachedLogoBuffer;
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH)
return _cachedLogoBuffer
} catch (error) {
logger.error("❌ [Cover] Impossible de lire le fichier logo", error);
throw new Error("Asset Logo manquant sur le serveur");
logger.error('❌ [Cover] Impossible de lire le fichier logo', error)
throw new Error('Asset Logo manquant sur le serveur')
}
};
}
/**
* Utilitaires Strings
*/
const pickFirstNonEmpty = (...values) => {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim()
}
}
return "";
};
return ''
}
const combineNames = (...parts) =>
parts
.map((part) => (typeof part === "string" ? part.trim() : ""))
.map((part) => (typeof part === 'string' ? part.trim() : ''))
.filter(Boolean)
.join(" ")
.trim();
.join(' ')
.trim()
/**
* Résolution intelligente du nom d'artiste
*/
async function resolveArtistName(project = {}) {
// 1. Vérification directe sur le projet ou le snapshot "owner"
const owner = project?.owner || {};
const owner = project?.owner || {}
const direct = pickFirstNonEmpty(
project?.artistName,
project?.userName,
owner?.artistName,
owner?.userName,
owner?.displayName,
);
if (direct) return direct;
owner?.displayName
)
if (direct) return direct
// 2. Fallback : Récupération depuis la collection Users
const userId =
typeof project?.userId === "string" ? project.userId.trim() : "";
if (!userId) return "";
const userId = typeof project?.userId === 'string' ? project.userId.trim() : ''
if (!userId) return ''
try {
const userSnapshot = await refList.users.doc(userId).get();
if (!userSnapshot?.exists) return "";
const userSnapshot = await refList.users.doc(userId).get()
if (!userSnapshot?.exists) return ''
const userData = userSnapshot.data() || {};
const userData = userSnapshot.data() || {}
return (
pickFirstNonEmpty(
userData.artistName,
userData.userName,
userData.displayName,
combineNames(userData.firstName, userData.lastName),
) || ""
);
combineNames(userData.firstName, userData.lastName)
) || ''
)
} catch (error) {
logger.warn("⚠️ [Cover] Artist name resolution failed", {
logger.warn('⚠️ [Cover] Artist name resolution failed', {
projectId: project?.id,
error: error.message,
});
return "";
})
return ''
}
}
@@ -100,60 +99,57 @@ async function resolveArtistName(project = {}) {
* Ajoute le logo en filigrane sur l'image générée
*/
async function buildCoverWithLogo(backgroundUrl, targetPath) {
logger.info("🖼️ [Cover] Compositing logo...");
logger.info('🖼️ [Cover] Compositing logo...')
try {
// Téléchargement background + Lecture Logo (parallèle)
const [bgResponse, logoBuffer] = await Promise.all([
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
axios.get(backgroundUrl, { responseType: 'arraybuffer' }),
getLogoBuffer(),
]);
])
const baseImage = sharp(bgResponse.data);
const metadata = await baseImage.metadata();
const width = metadata.width || 1024;
const height = metadata.height || 1024;
const baseImage = sharp(bgResponse.data)
const metadata = await baseImage.metadata()
const width = metadata.width || 1024
const height = metadata.height || 1024
// Calcul dynamique de la taille du logo (32% de la largeur)
const desiredWidth = Math.round(width * 0.32);
const margin = Math.round(width * 0.04);
const desiredWidth = Math.round(width * 0.32)
const margin = Math.round(width * 0.04)
// Redimensionnement du logo
const resizedLogo = await sharp(logoBuffer)
.resize({ width: desiredWidth })
.png()
.toBuffer();
const resizedLogo = await sharp(logoBuffer).resize({ width: desiredWidth }).png().toBuffer()
// Positionnement (Bas Droite)
const logoMetadata = await sharp(resizedLogo).metadata();
const left = Math.max(width - logoMetadata.width - margin, 0);
const top = Math.max(height - logoMetadata.height - margin, 0);
const logoMetadata = await sharp(resizedLogo).metadata()
const left = Math.max(width - logoMetadata.width - margin, 0)
const top = Math.max(height - logoMetadata.height - margin, 0)
// Composition
const stampedBuffer = await baseImage
.ensureAlpha()
.composite([{ input: resizedLogo, left, top, blend: "over" }])
.composite([{ input: resizedLogo, left, top, blend: 'over' }])
.png()
.toBuffer();
.toBuffer()
// Upload vers Storage
const token = crypto.randomUUID();
const file = bucket.file(targetPath);
const token = crypto.randomUUID()
const file = bucket.file(targetPath)
await file.save(stampedBuffer, {
resumable: false,
metadata: {
contentType: "image/png",
cacheControl: "public, max-age=31536000",
contentType: 'image/png',
cacheControl: 'public, max-age=31536000',
metadata: { firebaseStorageDownloadTokens: token },
},
});
})
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`;
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
} catch (error) {
logger.error("❌ [Cover] buildCoverWithLogo failed", error);
logger.error('❌ [Cover] buildCoverWithLogo failed', error)
// En cas d'échec du logo, on renvoie l'URL originale pour ne pas tout perdre
return backgroundUrl;
return backgroundUrl
}
}
@@ -161,74 +157,69 @@ async function buildCoverWithLogo(backgroundUrl, targetPath) {
* Cœur de la logique de génération
*/
async function performCoverGeneration(project) {
const t0 = Date.now();
const artistName = await resolveArtistName(project);
const t0 = Date.now()
const artistName = await resolveArtistName(project)
// Génération du Prompt optimisé
const prompt = generatePicturePrompt({
...project,
artistName,
});
})
logger.info("🎨 [Cover] Prompt generated", {
logger.info('🎨 [Cover] Prompt generated', {
projectId: project.id,
artistName,
promptPreview: prompt.slice(0, 100) + "...",
});
promptPreview: prompt.slice(0, 100) + '...',
})
const baseTimestamp = Date.now();
const GENERATION_COUNT = 2; // Nombre de variantes simultanées
const baseTimestamp = Date.now()
const GENERATION_COUNT = 2 // Nombre de variantes simultanées
// Création d'un tableau de promesses pour exécuter les tâches en parallèle
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(
async (_, index) => {
const uniqueSuffix = `${baseTimestamp}-${index}`;
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`;
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(async (_, index) => {
const uniqueSuffix = `${baseTimestamp}-${index}`
const storageBasePath = `users/${project.userId}/projects/${project.id}`
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`
try {
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
try {
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath)
if (!generatedUrl) throw new Error("URL vide retournée par l'IA");
if (!generatedUrl) throw new Error("URL vide retournée par l'IA")
// 2. Ajout du Logo
const finalCoverUrl = await buildCoverWithLogo(
generatedUrl,
stampedPath,
);
// 2. Ajout du Logo
const finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath)
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`);
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`)
return {
id: uniqueSuffix,
generatedUrl,
finalUrl: finalCoverUrl,
promptUsed: prompt,
};
} catch (e) {
// On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all
logger.error(`❌ [Cover] Option ${index + 1} failed`, {
error: e.message,
});
return null; // On retourne null pour filtrer plus tard
return {
id: uniqueSuffix,
generatedUrl,
finalUrl: finalCoverUrl,
promptUsed: prompt,
}
},
);
} catch (e) {
// On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all
logger.error(`❌ [Cover] Option ${index + 1} failed`, {
error: e.message,
})
return null // On retourne null pour filtrer plus tard
}
})
// Attente de la résolution de toutes les générations
const results = await Promise.all(generationPromises);
const results = await Promise.all(generationPromises)
// On garde uniquement les tentatives réussies (non null)
const options = results.filter(Boolean);
const options = results.filter(Boolean)
if (options.length === 0) {
throw new Error("Toutes les tentatives de génération ont échoué.");
throw new Error('Toutes les tentatives de génération ont échoué.')
}
// Sauvegarde dans Firestore
const [firstOption] = options;
const [firstOption] = options
await refList.projects.doc(project.id).set(
{
@@ -238,19 +229,19 @@ async function performCoverGeneration(project) {
// selectedOptionId: firstOption.id, // disable default selection
options, // Sauvegarde de toutes les variantes réussies
},
coverStatus: "GENERATED",
coverStatus: 'GENERATED',
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
{ merge: true }
)
logger.info("🏁 [Cover] Process complete", {
logger.info('🏁 [Cover] Process complete', {
projectId: project.id,
successCount: options.length,
duration: Date.now() - t0,
});
})
return firstOption.finalUrl;
return firstOption.finalUrl
}
/**
@@ -260,77 +251,72 @@ async function performCoverGeneration(project) {
exports.onTaskCreateGenerateCover = onDocumentCreated(
{
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
memory: "1GiB",
document: "tasks/{taskId}",
memory: '1GiB',
document: 'tasks/{taskId}',
},
async (event) => {
const data = event.data?.data() || {};
const { type, projectId } = data;
const taskId = event.params.taskId;
const data = event.data?.data() || {}
const { type, projectId } = data
const taskId = event.params.taskId
if (!projectId) return; // Ignorer les tâches mal formées
if (!["cover", "combine"].includes(type)) return; // Ignorer les autres types de tâches
if (!projectId) return // Ignorer les tâches mal formées
if (!['cover', 'combine'].includes(type)) return // Ignorer les autres types de tâches
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId });
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId })
try {
// 1. Validation & Setup
if (type === "combine") {
if (type === 'combine') {
// Feature désactivée pour le moment
await event.data.ref.update({
status: "CANCELLED",
status: 'CANCELLED',
error: "La personnalisation photo n'est plus disponible.",
updatedAt: FieldValue.serverTimestamp(),
});
return;
})
return
}
// Mise à jour statut projet
await refList.projects.doc(projectId).update({
coverStatus: "GENERATING",
coverStatus: 'GENERATING',
updatedAt: FieldValue.serverTimestamp(),
});
})
// 2. Chargement Projet
const projectSnap = await refList.projects.doc(projectId).get();
if (!projectSnap.exists) throw new Error("Projet introuvable");
const projectSnap = await refList.projects.doc(projectId).get()
if (!projectSnap.exists) throw new Error('Projet introuvable')
const project = { id: projectId, ...projectSnap.data() };
const project = { id: projectId, ...projectSnap.data() }
// Idempotency check (si déjà généré, on ne refait pas)
if (
Array.isArray(project?.cover?.options) &&
project.cover.options.length > 0
) {
logger.warn("⚠️ [Task] Cover already exists. Skipping.");
await refList.projects
.doc(projectId)
.update({ coverStatus: "GENERATED" });
if (Array.isArray(project?.cover?.options) && project.cover.options.length > 0) {
logger.warn('⚠️ [Task] Cover already exists. Skipping.')
await refList.projects.doc(projectId).update({ coverStatus: 'GENERATED' })
await event.data.ref.update({
status: "DONE",
info: "Already generated",
});
return;
status: 'DONE',
info: 'Already generated',
})
return
}
// 3. Exécution Génération
const coverUrl = await performCoverGeneration(project);
const coverUrl = await performCoverGeneration(project)
// 4. Finalisation Tâche
await event.data.ref.update({
status: "DONE",
status: 'DONE',
coverUrl,
updatedAt: FieldValue.serverTimestamp(),
});
})
// 5. Notification
if (project.userId) {
const projectTitle = project.title || "ton projet";
const projectTitle = project.title || 'ton projet'
await sendNotification({
sender: "SYSTEM",
sender: 'SYSTEM',
receiver: project.userId,
receiverCollection: "users",
title: "Pochette prête !",
receiverCollection: 'users',
title: 'Pochette prête !',
message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
data: {
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
@@ -338,39 +324,36 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
projectTitle,
coverUrl,
},
}).catch((err) => logger.warn("Notification failed", err));
}).catch((err) => logger.warn('Notification failed', err))
}
} catch (error) {
logger.error(`🔥 [Task ${taskId}] Failed`, error);
logger.error(`🔥 [Task ${taskId}] Failed`, error)
// Mise à jour erreur Tâche
await event.data.ref.set(
{ status: "ERROR", error: error.message },
{ merge: true },
);
await event.data.ref.set({ status: 'ERROR', error: error.message }, { merge: true })
// Mise à jour erreur Projet
await refList.projects.doc(projectId).update({
coverStatus: "ERROR",
coverStatus: 'ERROR',
updatedAt: FieldValue.serverTimestamp(),
});
})
// Notification Erreur
const projectData = (await refList.projects.doc(projectId).get()).data();
const projectData = (await refList.projects.doc(projectId).get()).data()
if (projectData?.userId) {
await sendNotification({
sender: "SYSTEM",
sender: 'SYSTEM',
receiver: projectData.userId,
receiverCollection: "users",
title: "Échec pochette",
message: `Impossible de générer la pochette pour "${projectData.title || "ton projet"}".`,
receiverCollection: 'users',
title: 'Échec pochette',
message: `Impossible de générer la pochette pour "${projectData.title || 'ton projet'}".`,
data: {
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
projectId,
error: error.message,
},
}).catch(() => { });
}).catch(() => {})
}
}
},
);
}
)
+38 -38
View File
@@ -1,60 +1,60 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const ORDER_TYPES = {
GIFT: "GIFT",
SONG: "SONG",
COINS: "COINS",
SUBSCRIPTION: "SUBSCRIPTION",
};
GIFT: 'GIFT',
SONG: 'SONG',
COINS: 'COINS',
SUBSCRIPTION: 'SUBSCRIPTION',
}
const ORDER_STATUS = {
PENDING: "PENDING",
APPLIED: "APPLIED",
REJECTED: "REJECTED",
};
PENDING: 'PENDING',
APPLIED: 'APPLIED',
REJECTED: 'REJECTED',
}
const ORDERS_COLLECTION = "orders";
const ORDERS_COLLECTION = 'orders'
const isFiniteNumber = (value) => {
if (typeof value === "number" && Number.isFinite(value)) {
return true;
if (typeof value === 'number' && Number.isFinite(value)) {
return true
}
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed);
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed)
}
return false;
};
return false
}
const normalizeAmount = (amount) => {
if (!isFiniteNumber(amount)) {
return null;
return null
}
return Number(amount);
};
return Number(amount)
}
const createOrderDocument = async ({
userId,
type,
amount,
songId = null,
createdBy = "system",
createdBy = 'system',
metadata = {},
orderId = null,
}) => {
if (!userId || typeof userId !== "string") {
throw new Error("[orders] Missing userId when creating order");
if (!userId || typeof userId !== 'string') {
throw new Error('[orders] Missing userId when creating order')
}
if (!Object.values(ORDER_TYPES).includes(type)) {
throw new Error(`[orders] Invalid order type "${type}"`);
throw new Error(`[orders] Invalid order type "${type}"`)
}
const normalizedAmount = normalizeAmount(amount);
const normalizedAmount = normalizeAmount(amount)
if (normalizedAmount === null || normalizedAmount === 0) {
throw new Error("[orders] Invalid order amount");
throw new Error('[orders] Invalid order amount')
}
const payload = {
@@ -63,25 +63,25 @@ const createOrderDocument = async ({
amount: normalizedAmount,
songId: type === ORDER_TYPES.SONG ? songId || null : null,
createdAt: FieldValue.serverTimestamp(),
createdBy: createdBy || "system",
createdBy: createdBy || 'system',
status: ORDER_STATUS.PENDING,
metadata: metadata || {},
};
}
const collectionRef = admin.firestore().collection(ORDERS_COLLECTION);
const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc();
const collectionRef = admin.firestore().collection(ORDERS_COLLECTION)
const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc()
if (orderId) {
const existingSnapshot = await orderRef.get();
const existingSnapshot = await orderRef.get()
if (existingSnapshot.exists) {
return { orderRef, orderId: orderRef.id };
return { orderRef, orderId: orderRef.id }
}
}
await orderRef.set(payload);
await orderRef.set(payload)
return { orderRef, orderId: orderRef.id };
};
return { orderRef, orderId: orderRef.id }
}
module.exports = {
ORDER_TYPES,
@@ -89,4 +89,4 @@ module.exports = {
ORDERS_COLLECTION,
createOrderDocument,
normalizeAmount,
};
}
+191 -220
View File
@@ -1,93 +1,86 @@
const { onCall, HttpsError } = require("firebase-functions/v2/https");
const { z } = require("genkit");
const { generateAI, analyseLyrics } = require("../helpers/gemini");
const {
SUNO_API_BASE,
SUNO_TIMESTAMPED_LYRICS_PATH,
} = require("../config/suno");
const { SUNO_API_KEY } = require("../config/keys");
const axios = require("axios");
const { FieldValue } = require("firebase-admin/firestore");
const { refList } = require("../index");
const { onCall, HttpsError } = require('firebase-functions/v2/https')
const { z } = require('genkit')
const { generateAI, analyseLyrics } = require('../helpers/gemini')
const { SUNO_API_BASE, SUNO_TIMESTAMPED_LYRICS_PATH } = require('../config/suno')
const { SUNO_API_KEY } = require('../config/keys')
const axios = require('axios')
const { FieldValue } = require('firebase-admin/firestore')
const { refList } = require('../index')
// --- CONSTANTES DE STRUCTURE ---
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
const STRUCTURE_PROMPT_LABELS = {
couplet: "couplet",
refrain: "refrain",
short_intro: "introduction instrumentale courte",
long_intro: "introduction instrumentale longue",
pre_refrain_instrumental: "pré-refrain instrumental",
pont: "pont",
solo_de_guitare: "solo de guitare",
solo_de_guitare_electrique: "solo de guitare électrique",
solo_de_batterie: "solo de batterie",
solo_de_saxophone: "solo de saxophone",
solo_de_violon: "solo de violon",
break: "break",
interlude: "interlude",
interlude_melodique: "interlude mélodique",
final_apogee: "final apogée",
arret_net: "arrêt net",
fade_out: "fade out",
transition_douce: "transition douce vers le silence",
};
couplet: 'couplet',
refrain: 'refrain',
short_intro: 'introduction instrumentale courte',
long_intro: 'introduction instrumentale longue',
pre_refrain_instrumental: 'pré-refrain instrumental',
pont: 'pont',
solo_de_guitare: 'solo de guitare',
solo_de_guitare_electrique: 'solo de guitare électrique',
solo_de_batterie: 'solo de batterie',
solo_de_saxophone: 'solo de saxophone',
solo_de_violon: 'solo de violon',
break: 'break',
interlude: 'interlude',
interlude_melodique: 'interlude mélodique',
final_apogee: 'final apogée',
arret_net: 'arrêt net',
fade_out: 'fade out',
transition_douce: 'transition douce vers le silence',
}
const STRUCTURE_ALIASES = {
"short intro": "short_intro",
"introduction instrumentale courte": "short_intro",
"intro instrumentale courte": "short_intro",
"long intro": "long_intro",
"introduction instrumentale longue": "long_intro",
"intro instrumentale longue": "long_intro",
"pré-refrain": "pre_refrain_instrumental",
"pre-refrain": "pre_refrain_instrumental",
pre_refrain: "pre_refrain_instrumental",
"pre chorus": "pre_refrain_instrumental",
"pre-chorus": "pre_refrain_instrumental",
prechorus: "pre_refrain_instrumental",
"pré-refrain instrumental": "pre_refrain_instrumental",
"pre-refrain instrumental": "pre_refrain_instrumental",
"instrumental pre-chorus": "pre_refrain_instrumental",
"instrumental pre chorus": "pre_refrain_instrumental",
bridge: "pont",
guitar_solo: "solo_de_guitare",
electric_guitar_solo: "solo_de_guitare_electrique",
drum_solo: "solo_de_batterie",
sax_solo: "solo_de_saxophone",
violin_solo: "solo_de_violon",
melodic_interlude: "interlude_melodique",
grand_finale: "final_apogee",
sudden_stop: "arret_net",
soft_transition: "transition_douce",
};
'short intro': 'short_intro',
'introduction instrumentale courte': 'short_intro',
'intro instrumentale courte': 'short_intro',
'long intro': 'long_intro',
'introduction instrumentale longue': 'long_intro',
'intro instrumentale longue': 'long_intro',
'pré-refrain': 'pre_refrain_instrumental',
'pre-refrain': 'pre_refrain_instrumental',
pre_refrain: 'pre_refrain_instrumental',
'pre chorus': 'pre_refrain_instrumental',
'pre-chorus': 'pre_refrain_instrumental',
prechorus: 'pre_refrain_instrumental',
'pré-refrain instrumental': 'pre_refrain_instrumental',
'pre-refrain instrumental': 'pre_refrain_instrumental',
'instrumental pre-chorus': 'pre_refrain_instrumental',
'instrumental pre chorus': 'pre_refrain_instrumental',
bridge: 'pont',
guitar_solo: 'solo_de_guitare',
electric_guitar_solo: 'solo_de_guitare_electrique',
drum_solo: 'solo_de_batterie',
sax_solo: 'solo_de_saxophone',
violin_solo: 'solo_de_violon',
melodic_interlude: 'interlude_melodique',
grand_finale: 'final_apogee',
sudden_stop: 'arret_net',
soft_transition: 'transition_douce',
}
// --- UTILITAIRES ---
const normalizeStructureValue = (value) => {
const raw = String(value || "")
const raw = String(value || '')
.trim()
.toLowerCase();
if (!raw) return "";
if (STRUCTURE_PROMPT_LABELS[raw]) return raw;
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw];
const sanitized = raw.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
return STRUCTURE_PROMPT_LABELS[sanitized]
? sanitized
: STRUCTURE_ALIASES[sanitized] || sanitized;
};
.toLowerCase()
if (!raw) return ''
if (STRUCTURE_PROMPT_LABELS[raw]) return raw
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw]
const sanitized = raw.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
return STRUCTURE_PROMPT_LABELS[sanitized] ? sanitized : STRUCTURE_ALIASES[sanitized] || sanitized
}
// Nettoyage simplifié : on laisse l'IA gérer la logique musicale plutôt que de supprimer brutalement.
// On s'assure juste que les clés sont propres.
const sanitizeStructureEntries = (structure = []) => {
if (!Array.isArray(structure)) return [];
return structure.map(normalizeStructureValue).filter(Boolean);
};
if (!Array.isArray(structure)) return []
return structure.map(normalizeStructureValue).filter(Boolean)
}
const mapStructureToPrompt = (structure = []) =>
sanitizeStructureEntries(structure).map(
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
);
sanitizeStructureEntries(structure).map((entry) => STRUCTURE_PROMPT_LABELS[entry] || entry)
// --- MODERATION ---
@@ -101,40 +94,37 @@ const buildModerationBrief = ({
rhymes,
}) => {
const sections = [
`<OBJECTIF>${objective || ""}</OBJECTIF>`,
`<CONTEXTE>${context || ""}</CONTEXTE>`,
`<EMOTION>${emotion || ""}</EMOTION>`,
`<STYLE>${style || ""}</STYLE>`,
`<AUDIENCE>${audience || ""}</AUDIENCE>`,
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`,
];
const promptStructure = mapStructureToPrompt(structure);
if (promptStructure.length)
sections.push(`<STRUCTURE>${promptStructure.join(" | ")}</STRUCTURE>`);
return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`;
};
`<OBJECTIF>${objective || ''}</OBJECTIF>`,
`<CONTEXTE>${context || ''}</CONTEXTE>`,
`<EMOTION>${emotion || ''}</EMOTION>`,
`<STYLE>${style || ''}</STYLE>`,
`<AUDIENCE>${audience || ''}</AUDIENCE>`,
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(', ') : rhymes || ''}</RIMES>`,
]
const promptStructure = mapStructureToPrompt(structure)
if (promptStructure.length) sections.push(`<STRUCTURE>${promptStructure.join(' | ')}</STRUCTURE>`)
return `<BRIEF_UTILISATEUR>\n${sections.join('\n')}\n</BRIEF_UTILISATEUR>`
}
// --- GENERATION DE PAROLES (MAIN) ---
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
try {
const {
objective = "",
context = "",
style = "",
audience = "",
emotion = "",
structure: rawStructure = ["couplet", "refrain", "couplet", "refrain"],
rhymes = "",
} = data;
objective = '',
context = '',
style = '',
audience = '',
emotion = '',
structure: rawStructure = ['couplet', 'refrain', 'couplet', 'refrain'],
rhymes = '',
} = data
// 1. Préparation Structure
const sanitizedStructure = sanitizeStructureEntries(rawStructure);
const sanitizedStructure = sanitizeStructureEntries(rawStructure)
const promptStructure = sanitizedStructure.length
? sanitizedStructure.map(
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
)
: [];
? sanitizedStructure.map((entry) => STRUCTURE_PROMPT_LABELS[entry] || entry)
: []
// 2. Modération "Pro" (Via Gemini 1.5 Pro)
// On supprime les regex manuelles obsolètes.
@@ -146,40 +136,34 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
emotion,
structure: promptStructure,
rhymes,
};
const moderationInput = buildModerationBrief(moderationPayload);
}
const moderationInput = buildModerationBrief(moderationPayload)
try {
const moderation = await analyseLyrics({
title: "Brief utilisateur (Pré-génération)",
title: 'Brief utilisateur (Pré-génération)',
lyrics: moderationInput,
});
})
// Si Gemini dit "Blocked", on bloque. C'est la seule autorité.
if (moderation?.blocked === true) {
console.warn("⛔ generateLyrics blocked by Gemini Pro", {
console.warn('⛔ generateLyrics blocked by Gemini Pro', {
reasons: moderation.reasons,
});
})
const summary = Array.isArray(moderation.reasons)
? moderation.reasons.slice(0, 3).join(", ")
: "Contenu non conforme";
throw new HttpsError(
"invalid-argument",
`Demande refusée par la modération : ${summary}.`,
);
? moderation.reasons.slice(0, 3).join(', ')
: 'Contenu non conforme'
throw new HttpsError('invalid-argument', `Demande refusée par la modération : ${summary}.`)
}
} catch (moderationError) {
if (moderationError instanceof HttpsError) throw moderationError;
console.error("⚠️ Moderation check error (fail open)", moderationError);
if (moderationError instanceof HttpsError) throw moderationError
console.error('⚠️ Moderation check error (fail open)', moderationError)
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique.
// Ici je fail closed par sécurité pour une app publique.
throw new HttpsError(
"internal",
"Vérification de sécurité indisponible.",
);
throw new HttpsError('internal', 'Vérification de sécurité indisponible.')
}
console.log("🎵 generateLyrics: Start generation", { style, emotion });
console.log('🎵 generateLyrics: Start generation', { style, emotion })
// 3. Le Prompt "Hit Maker"
const system = `
@@ -197,27 +181,25 @@ TES RÈGLES D'OR :
5. **VOCABULAIRE** : Adapte le niveau de langue au style (Argot pour le Rap, Soutenu pour la Chanson Française, Simple pour la Pop).
Retourne UNIQUEMENT un JSON valide.
`.trim();
`.trim()
const structureTags = (
Array.isArray(promptStructure) ? promptStructure : []
)
const structureTags = (Array.isArray(promptStructure) ? promptStructure : [])
.map((part, index) => ` <SECTION ordre="${index + 1}">${part}</SECTION>`)
.join("\n");
.join('\n')
const fallbackStructureTags = [
' <SECTION ordre="1">couplet</SECTION>',
' <SECTION ordre="2">refrain</SECTION>',
].join("\n");
].join('\n')
const prompt = `
<BRIEF_CREATIF>
<OBJECTIF>${objective || "Créer une chanson mémorable"}</OBJECTIF>
<CONTEXTE>${context || "Libre interprétation"}</CONTEXTE>
<EMOTION_DOMINANTE>${emotion || "Intense"}</EMOTION_DOMINANTE>
<STYLE_MUSICAL>${style || "Pop Moderne"}</STYLE_MUSICAL>
<CIBLE>${audience || "Tout public"}</CIBLE>
<TYPE_DE_RIMES>${rhymes || "Rimes croisées et riches"}</TYPE_DE_RIMES>
<OBJECTIF>${objective || 'Créer une chanson mémorable'}</OBJECTIF>
<CONTEXTE>${context || 'Libre interprétation'}</CONTEXTE>
<EMOTION_DOMINANTE>${emotion || 'Intense'}</EMOTION_DOMINANTE>
<STYLE_MUSICAL>${style || 'Pop Moderne'}</STYLE_MUSICAL>
<CIBLE>${audience || 'Tout public'}</CIBLE>
<TYPE_DE_RIMES>${rhymes || 'Rimes croisées et riches'}</TYPE_DE_RIMES>
</BRIEF_CREATIF>
<STRUCTURE_IMPOSEE>
@@ -231,106 +213,97 @@ ${structureTags || fallbackStructureTags}
- Si c'est "Couplet/Refrain" : Écris 4 à 12 vers.
3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre.
</CONSIGNES_GENERATION>
`.trim();
`.trim()
const lyricsSchema = z.object({
title: z.string().describe("Titre de la chanson, court et accrocheur"),
title: z.string().describe('Titre de la chanson, court et accrocheur'),
lyrics: z
.array(
z.object({
type: z
.string()
.describe(
"Type de section (copier exactement la demande structure)",
),
type: z.string().describe('Type de section (copier exactement la demande structure)'),
lyrics: z
.string()
.describe(
"Les paroles. Pour les sections instrumentales, laisser vide ou décrire l'ambiance.",
"Les paroles. Pour les sections instrumentales, laisser vide ou décrire l'ambiance."
),
}),
})
)
.describe("La structure complète de la chanson"),
.describe('La structure complète de la chanson'),
lyricsDescription: z
.string()
.describe(
"Un pitch de 2 phrases décrivant l'ambiance et le thème de la chanson pour Suno.",
"Un pitch de 2 phrases décrivant l'ambiance et le thème de la chanson pour Suno."
),
success: z.boolean(),
});
})
// Appel Gemini avec le nouveau modèle Pro configuré dans helpers/gemini
return await generateAI({
system,
prompt,
schema: lyricsSchema,
});
})
} catch (e) {
console.error("❌ generateLyrics Error:", e);
console.error('❌ generateLyrics Error:', e)
// Remontée d'erreur propre
if (e instanceof HttpsError) throw e;
throw new HttpsError(
"internal",
"Erreur lors de la génération des paroles.",
);
if (e instanceof HttpsError) throw e
throw new HttpsError('internal', 'Erreur lors de la génération des paroles.')
}
});
})
// --- ANALYSE TOXICITÉ (EXISTANTE, NETTOYÉE) ---
// Cette fonction reste utile pour l'analyse POST-création ou pour l'interface UI
const severityScore = (severity = "") => {
const normalized = String(severity || "").toLowerCase();
if (normalized === "critical") return 4;
if (normalized === "high") return 3;
if (normalized === "medium") return 2;
if (normalized === "low") return 1;
return 0;
};
const severityScore = (severity = '') => {
const normalized = String(severity || '').toLowerCase()
if (normalized === 'critical') return 4
if (normalized === 'high') return 3
if (normalized === 'medium') return 2
if (normalized === 'low') return 1
return 0
}
const softenModerationDecision = (rawResult = {}) => {
const result = { ...rawResult };
const excerpts = Array.isArray(result.excerpts) ? result.excerpts : [];
const reasons = Array.isArray(result.reasons) ? result.reasons : [];
const result = { ...rawResult }
const excerpts = Array.isArray(result.excerpts) ? result.excerpts : []
const reasons = Array.isArray(result.reasons) ? result.reasons : []
const highestSeverity = excerpts.reduce(
(max, excerpt) => Math.max(max, severityScore(excerpt?.severity)),
0,
);
0
)
const score =
typeof result.score === "number" && !Number.isNaN(result.score)
typeof result.score === 'number' && !Number.isNaN(result.score)
? Math.min(Math.max(result.score, 0), 1)
: 0;
: 0
// Détection de contexte narratif pour être plus indulgent
const narrativeHint = reasons.some((reason) =>
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(
reason || "",
),
);
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(reason || '')
)
const adjustments = [];
const adjustments = []
// Logique d'adoucissement : Si c'est "High" severity mais narratif, on peut parfois débloquer (selon ta politique).
// Ici on reste prudent sur le block, mais on adoucit le flag.
if (result.blocked) {
// Débloque uniquement les erreurs manifestes (score bas mais blocked true par erreur)
if (highestSeverity <= 1 && score < 0.65) {
result.blocked = false;
adjustments.push("auto-unblock-low-severity");
result.blocked = false
adjustments.push('auto-unblock-low-severity')
}
}
if (!result.blocked && result.flagged) {
const lowSignal = highestSeverity <= 1 && score < 0.4;
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55;
const lowSignal = highestSeverity <= 1 && score < 0.4
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55
if (lowSignal) {
result.flagged = false;
adjustments.push("drop-flag-low-signal");
result.flagged = false
adjustments.push('drop-flag-low-signal')
} else if (contextual) {
result.flagged = false;
adjustments.push("drop-flag-contextual");
result.flagged = false
adjustments.push('drop-flag-contextual')
}
}
@@ -338,8 +311,8 @@ const softenModerationDecision = (rawResult = {}) => {
...result,
moderationAdjustments: adjustments,
moderationCalibration: { highestSeverity, score },
};
};
}
}
exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
try {
@@ -356,93 +329,91 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
z.object({
type: z.string().optional(),
lyrics: z.string().optional(),
}),
})
),
])
.describe("Paroles à analyser"),
});
.describe('Paroles à analyser'),
})
const parsed = requestSchema.parse(data || {});
const parsed = requestSchema.parse(data || {})
const aiResult = await analyseLyrics({
title: parsed.title || "",
title: parsed.title || '',
lyrics: parsed.lyrics,
});
const result = softenModerationDecision(aiResult);
})
const result = softenModerationDecision(aiResult)
const highCats = Object.entries(result.categories || {}) // Note: categories n'est pas dans le schema Zod actuel de Gemini, à vérifier si besoin
.filter(([, v]) => (typeof v === "number" ? v : 0) >= 0.5)
.filter(([, v]) => (typeof v === 'number' ? v : 0) >= 0.5)
.map(([k]) => k)
.slice(0, 5);
.slice(0, 5)
let errorCode = "OK";
let message = "Analyse effectuée: aucun blocage.";
let errorCode = 'OK'
let message = 'Analyse effectuée: aucun blocage.'
if (result.blocked) {
errorCode = "TOXIC_CONTENT_BLOCKED";
message = `Contenu bloqué par sécurité.`;
errorCode = 'TOXIC_CONTENT_BLOCKED'
message = `Contenu bloqué par sécurité.`
} else if (result.flagged) {
errorCode = "TOXIC_CONTENT_FLAGGED";
message = `Attention: contenu sensible détecté.`;
errorCode = 'TOXIC_CONTENT_FLAGGED'
message = `Attention: contenu sensible détecté.`
}
return { success: !result.blocked, errorCode, message, result };
return { success: !result.blocked, errorCode, message, result }
} catch (err) {
console.error("analyseLyricsToxicity failed", err?.message || err);
console.error('analyseLyricsToxicity failed', err?.message || err)
return {
success: false,
errorCode: "ANALYSE_FAILED",
message: "Erreur analyse toxicité.",
};
errorCode: 'ANALYSE_FAILED',
message: 'Erreur analyse toxicité.',
}
}
});
})
// --- SUNO TIMESTAMPS (EXISTANT) ---
async function getSunoTimestamps(projectId) {
try {
if (!projectId || typeof projectId !== "string")
throw new Error("projectId invalide");
if (!projectId || typeof projectId !== 'string') throw new Error('projectId invalide')
const docSnap = await refList.projects.doc(projectId).get();
if (!docSnap.exists) throw new Error("Projet introuvable");
const docSnap = await refList.projects.doc(projectId).get()
if (!docSnap.exists) throw new Error('Projet introuvable')
const { sunoTaskId, songIndex } = docSnap.data();
if (!sunoTaskId) throw new Error("TaskId manquant");
if (songIndex === undefined || songIndex < 0)
throw new Error("musicIndex invalide");
const { sunoTaskId, songIndex } = docSnap.data()
if (!sunoTaskId) throw new Error('TaskId manquant')
if (songIndex === undefined || songIndex < 0) throw new Error('musicIndex invalide')
console.log("🔎 getSunoTimestamps", { sunoTaskId, songIndex });
console.log('🔎 getSunoTimestamps', { sunoTaskId, songIndex })
const response = await axios.post(
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
{ taskId: sunoTaskId, musicIndex: songIndex },
{
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
Authorization: `Bearer ${SUNO_API_KEY}`,
},
timeout: 30000,
},
);
}
)
const dataToReturn = response.data?.data || response.data || {};
const dataToReturn = response.data?.data || response.data || {}
await refList.projects.doc(projectId).set(
{
musicTimestamps: { [songIndex]: dataToReturn },
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
{ merge: true }
)
return { success: true };
return { success: true }
} catch (error) {
console.error("❌ getSunoTimestamps Error:", error.message);
console.error('❌ getSunoTimestamps Error:', error.message)
return {
success: false,
error: { message: error.message, type: "INTERNAL_ERROR" },
};
error: { message: error.message, type: 'INTERNAL_ERROR' },
}
}
}
exports.getSunoTimestamps = getSunoTimestamps;
exports.getSunoTimestamps = getSunoTimestamps
+340 -387
View File
File diff suppressed because it is too large Load Diff
+239 -284
View File
@@ -1,100 +1,88 @@
const {
onDocumentCreated,
onDocumentWritten,
} = require("firebase-functions/v2/firestore");
const { FieldValue } = require("firebase-admin/firestore");
const { refList, ALERT_TYPE } = require("../index");
const { Expo } = require("expo-server-sdk");
const { Resend } = require("resend");
const { basicTemplate } = require("../helpers/email");
const { RESEND_API_KEY } = require("../config/keys");
const { onDocumentCreated, onDocumentWritten } = require('firebase-functions/v2/firestore')
const { FieldValue } = require('firebase-admin/firestore')
const { refList, ALERT_TYPE } = require('../index')
const { Expo } = require('expo-server-sdk')
const { Resend } = require('resend')
const { basicTemplate } = require('../helpers/email')
const { RESEND_API_KEY } = require('../config/keys')
const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null;
const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null
// Initialisation de Expo SDK
let expo = new Expo();
const EMAIL_FROM = "MusicLand <musicland@musicland.ai>";
const DEFAULT_EMAIL_TITLE = "MusicLand";
let expo = new Expo()
const EMAIL_FROM = 'MusicLand <musicland@musicland.ai>'
const DEFAULT_EMAIL_TITLE = 'MusicLand'
function getCollectionRef(collectionName = "") {
const ref = refList?.[collectionName];
function getCollectionRef(collectionName = '') {
const ref = refList?.[collectionName]
if (!ref) {
throw new Error(`Unknown collection "${collectionName}"`);
throw new Error(`Unknown collection "${collectionName}"`)
}
return ref;
return ref
}
function cleanString(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function buildNotificationEmailPayload({
title = "",
message = "",
template = {},
} = {}) {
const fallbackTitle = cleanString(title) || DEFAULT_EMAIL_TITLE;
const fallbackContent = cleanString(message) || "";
const overrides = template && typeof template === "object" ? template : {};
function buildNotificationEmailPayload({ title = '', message = '', template = {} } = {}) {
const fallbackTitle = cleanString(title) || DEFAULT_EMAIL_TITLE
const fallbackContent = cleanString(message) || ''
const overrides = template && typeof template === 'object' ? template : {}
const subject = cleanString(overrides.subject) || fallbackTitle;
const emailTitle = cleanString(overrides.title) || fallbackTitle;
const content = cleanString(overrides.content) || fallbackContent;
const subject = cleanString(overrides.subject) || fallbackTitle
const emailTitle = cleanString(overrides.title) || fallbackTitle
const content = cleanString(overrides.content) || fallbackContent
let button = null;
if (overrides.button && typeof overrides.button === "object") {
const buttonUrl =
cleanString(overrides.button.url) || cleanString(overrides.button.href);
let button = null
if (overrides.button && typeof overrides.button === 'object') {
const buttonUrl = cleanString(overrides.button.url) || cleanString(overrides.button.href)
if (buttonUrl) {
button = {
url: buttonUrl,
label:
cleanString(overrides.button.label) ||
cleanString(overrides.button.text) ||
undefined,
};
cleanString(overrides.button.label) || cleanString(overrides.button.text) || undefined,
}
}
}
const templatePayload = { title: emailTitle, content };
const templatePayload = { title: emailTitle, content }
if (button) {
templatePayload.button = button;
templatePayload.button = button
}
return {
subject,
html: basicTemplate(templatePayload),
};
}
}
exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
{ region: "europe-west1", document: "notifications/{notificationId}" },
{ region: 'europe-west1', document: 'notifications/{notificationId}' },
async (event) => {
try {
const {
receiver = null,
title = "MusicLand",
receiverCollection = "users",
message = "",
title = 'MusicLand',
receiverCollection = 'users',
message = '',
data: notifData = {},
mailOnly = false,
} = event.data.data();
} = event.data.data()
if (!receiver || !message) {
throw new Error("Receiver and message are required");
throw new Error('Receiver and message are required')
}
const receiverSnap = await getCollectionRef(receiverCollection)
.doc(receiver)
.get();
const receiverData = receiverSnap.exists ? receiverSnap.data() : {};
const receiverSnap = await getCollectionRef(receiverCollection).doc(receiver).get()
const receiverData = receiverSnap.exists ? receiverSnap.data() : {}
const {
pushToken = null,
pushTokens = [],
email: receiverEmail = "",
email: receiverEmail = '',
emailNotifications = false,
} = receiverData;
} = receiverData
if (!mailOnly) {
try {
@@ -102,129 +90,124 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
[]
.concat(Array.isArray(pushTokens) ? pushTokens : [])
.concat(pushToken ? [pushToken] : [])
.filter(Boolean),
);
const tokens = Array.from(tokensSet);
.filter(Boolean)
)
const tokens = Array.from(tokensSet)
if (!tokens?.length) {
await sendExpoNotification({
tokens,
receiverId: receiver,
receiverCollection,
title: title || "MusicLand",
title: title || 'MusicLand',
message: message,
data: notifData || {},
});
})
} else {
console.log("User push token not found");
console.log('User push token not found')
}
} catch (e) {
console.log("Error sending notif:", e);
console.log('Error sending notif:', e)
}
}
if ((emailNotifications || mailOnly) && !!receiverEmail) {
if (!resendInstance) {
console.warn(
"Resend client not configured; unable to send notification email.",
);
console.warn('Resend client not configured; unable to send notification email.')
} else {
try {
const { subject, html } = buildNotificationEmailPayload({
title,
message,
template: notifData?.email,
});
})
await resendInstance.emails.send({
from: EMAIL_FROM,
to: [receiverEmail],
subject,
html,
});
})
} catch (e) {
console.log("Error sending email:", e);
console.log('Error sending email:', e)
}
}
} else {
console.log(
`[sendNotificationWhenDocIsCreated] Email not sent (${receiverEmail ? "user preference" : "missing email"}) for user ${receiver} and type ${notifData?.type || "UNKNOWN"}`,
);
`[sendNotificationWhenDocIsCreated] Email not sent (${receiverEmail ? 'user preference' : 'missing email'}) for user ${receiver} and type ${notifData?.type || 'UNKNOWN'}`
)
}
} catch (e) {
console.log(e);
return e;
console.log(e)
return e
}
},
);
}
)
// Fonction pour envoyer la notification via Expo SDK
async function sendExpoNotification({
tokens = [],
title = "",
message = "",
title = '',
message = '',
data = {},
receiverId = null,
receiverCollection = "users",
receiverCollection = 'users',
}) {
try {
const candidateTokens = Array.isArray(tokens) ? tokens : [tokens];
const validTokens = [];
const invalidTokens = [];
const candidateTokens = Array.isArray(tokens) ? tokens : [tokens]
const validTokens = []
const invalidTokens = []
candidateTokens.forEach((token) => {
if (Expo.isExpoPushToken(token)) {
validTokens.push(token);
validTokens.push(token)
} else if (token) {
invalidTokens.push(token);
invalidTokens.push(token)
}
});
})
if (invalidTokens.length && receiverId) {
await removeInvalidTokens({
tokens: invalidTokens,
receiverId,
receiverCollection,
});
})
}
if (!validTokens.length) {
console.warn("No valid Expo push tokens to send notification");
return { sent: false };
console.warn('No valid Expo push tokens to send notification')
return { sent: false }
}
const messages = validTokens.map((token) => ({
to: token,
sound: "default",
sound: 'default',
title: title,
body: message,
data: data || {},
priority: "high",
priority: 'high',
badge: 1,
channelId: "default",
}));
channelId: 'default',
}))
const chunks = expo.chunkPushNotifications(messages);
const receipts = [];
const tokensToPrune = new Set();
const chunks = expo.chunkPushNotifications(messages)
const receipts = []
const tokensToPrune = new Set()
for (const chunk of chunks) {
const chunkReceipts = await expo.sendPushNotificationsAsync(chunk);
const chunkReceipts = await expo.sendPushNotificationsAsync(chunk)
chunkReceipts.forEach((receipt, index) => {
if (receipt?.status === "error") {
const errorCode = receipt?.details?.error || receipt?.details?.code;
console.log("Error sending notification:", receipt);
if (
errorCode === "DeviceNotRegistered" ||
errorCode === "PushTokenNotRegistered"
) {
const token = chunk[index]?.to;
if (receipt?.status === 'error') {
const errorCode = receipt?.details?.error || receipt?.details?.code
console.log('Error sending notification:', receipt)
if (errorCode === 'DeviceNotRegistered' || errorCode === 'PushTokenNotRegistered') {
const token = chunk[index]?.to
if (token) {
tokensToPrune.add(token);
tokensToPrune.add(token)
}
}
}
});
receipts.push(...chunkReceipts);
})
receipts.push(...chunkReceipts)
}
if (tokensToPrune.size && receiverId) {
@@ -232,68 +215,64 @@ async function sendExpoNotification({
tokens: Array.from(tokensToPrune),
receiverId,
receiverCollection,
});
})
}
console.log("Sent push notifications:", receipts);
console.log('Sent push notifications:', receipts)
return { sent: true };
return { sent: true }
} catch (e) {
console.log("Error sending notification:", e);
throw e;
console.log('Error sending notification:', e)
throw e
}
}
async function removeInvalidTokens({
tokens = [],
receiverId,
receiverCollection,
}) {
async function removeInvalidTokens({ tokens = [], receiverId, receiverCollection }) {
try {
if (!receiverId || !tokens.length) {
return;
return
}
const uniqueTokens = Array.from(new Set(tokens.filter(Boolean)));
const uniqueTokens = Array.from(new Set(tokens.filter(Boolean)))
if (!uniqueTokens.length) {
return;
return
}
const docRef = getCollectionRef(receiverCollection).doc(receiverId);
const userSnap = await docRef.get();
const userData = userSnap?.data() || {};
const docRef = getCollectionRef(receiverCollection).doc(receiverId)
const userSnap = await docRef.get()
const userData = userSnap?.data() || {}
const updates = {
pushTokens: FieldValue.arrayRemove(...uniqueTokens),
};
if (uniqueTokens.includes(userData?.pushToken)) {
updates.pushToken = FieldValue.delete();
}
await docRef.set(updates, { merge: true });
if (uniqueTokens.includes(userData?.pushToken)) {
updates.pushToken = FieldValue.delete()
}
await docRef.set(updates, { merge: true })
console.log(
"Pruned invalid push tokens",
JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2),
);
'Pruned invalid push tokens',
JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2)
)
} catch (error) {
console.log("Failed to prune invalid push tokens:", error);
console.log('Failed to prune invalid push tokens:', error)
}
}
// Fonction pour ajouter une notification à la base de données
const sendNotification = async ({
sender = "SYSTEM",
sender = 'SYSTEM',
receiver = null,
receiverCollection = "users",
title = "",
receiverCollection = 'users',
title = '',
message = null,
mailOnly = false,
data = {},
}) => {
try {
if (!receiver || !message) {
throw new Error("Receiver and message are required");
throw new Error('Receiver and message are required')
}
const payload = {
sender,
@@ -306,81 +285,78 @@ const sendNotification = async ({
readAt: null,
mailOnly,
data,
};
const { id } = await refList.notifications.add(payload);
}
const { id } = await refList.notifications.add(payload)
console.log(
"[sendNotification] Notification created",
JSON.stringify({ id, receiver, receiverCollection }, null, 2),
);
'[sendNotification] Notification created',
JSON.stringify({ id, receiver, receiverCollection }, null, 2)
)
return id;
return id
} catch (e) {
console.log("[sendNotification] Error creating notification:", e);
console.log('[sendNotification] Error creating notification:', e)
}
};
}
exports.sendNotification = sendNotification;
exports.sendNotification = sendNotification
exports.createProjectCommentNotification = onDocumentCreated(
{
region: "europe-west1",
document: "projects/{projectId}/comments/{commentId}",
region: 'europe-west1',
document: 'projects/{projectId}/comments/{commentId}',
},
async (event) => {
try {
console.log(
"[createProjectCommentNotification] Trigger received",
JSON.stringify(event.params || {}, null, 2),
);
const { data: snap } = event;
const { projectId, commentId } = event.params || {};
const comment = snap?.data();
'[createProjectCommentNotification] Trigger received',
JSON.stringify(event.params || {}, null, 2)
)
const { data: snap } = event
const { projectId, commentId } = event.params || {}
const comment = snap?.data()
if (!projectId || !comment) {
console.log(
"[createProjectCommentNotification] Missing project/comment data",
{ hasProjectId: !!projectId, hasComment: !!comment },
);
return null;
console.log('[createProjectCommentNotification] Missing project/comment data', {
hasProjectId: !!projectId,
hasComment: !!comment,
})
return null
}
console.log(
"[createProjectCommentNotification] Comment payload",
JSON.stringify(comment, null, 2),
);
'[createProjectCommentNotification] Comment payload',
JSON.stringify(comment, null, 2)
)
const projectSnap = await refList.projects.doc(projectId).get();
const projectSnap = await refList.projects.doc(projectId).get()
if (!projectSnap.exists) {
console.log(
"[createProjectCommentNotification] Project not found",
projectId,
);
return null;
console.log('[createProjectCommentNotification] Project not found', projectId)
return null
}
const project = projectSnap.data() || {};
const receiver = project.userId || null;
const project = projectSnap.data() || {}
const receiver = project.userId || null
if (!receiver || receiver === comment.userId) {
console.log(
"[createProjectCommentNotification] Invalid receiver",
JSON.stringify({ receiver, commentUserId: comment.userId }),
);
return null;
'[createProjectCommentNotification] Invalid receiver',
JSON.stringify({ receiver, commentUserId: comment.userId })
)
return null
}
const commenterName =
typeof comment?.userName === "string" && comment.userName.trim()
typeof comment?.userName === 'string' && comment.userName.trim()
? comment.userName.trim()
: "Un utilisateur";
: 'Un utilisateur'
const projectTitle =
typeof project.title === "string" && project.title.trim()
typeof project.title === 'string' && project.title.trim()
? project.title.trim()
: "ton projet";
const message = `${commenterName} a commenté ton projet "${projectTitle}"`;
: 'ton projet'
const message = `${commenterName} a commenté ton projet "${projectTitle}"`
console.log(
"[createProjectCommentNotification] Creating notification",
'[createProjectCommentNotification] Creating notification',
JSON.stringify(
{
receiver,
@@ -389,15 +365,15 @@ exports.createProjectCommentNotification = onDocumentCreated(
projectId,
},
null,
2,
),
);
2
)
)
await sendNotification({
sender: comment.userId || "SYSTEM",
sender: comment.userId || 'SYSTEM',
receiver,
receiverCollection: "users",
title: "Nouveau commentaire",
receiverCollection: 'users',
title: 'Nouveau commentaire',
message,
data: {
type: ALERT_TYPE?.NEW_COMMENT,
@@ -405,109 +381,94 @@ exports.createProjectCommentNotification = onDocumentCreated(
commentId,
commenterId: comment.userId || null,
commenterName: commenterName,
commenterProfilePicture: comment?.profilePicture || "",
text:
typeof comment?.text === "string" && comment.text.trim()
? comment.text.trim()
: "",
commenterProfilePicture: comment?.profilePicture || '',
text: typeof comment?.text === 'string' && comment.text.trim() ? comment.text.trim() : '',
},
});
})
console.log(
"[createProjectCommentNotification] Notification creation complete",
);
console.log('[createProjectCommentNotification] Notification creation complete')
return null;
return null
} catch (error) {
console.log("createProjectCommentNotification error:", error);
return error;
console.log('createProjectCommentNotification error:', error)
return error
}
},
);
}
)
exports.createProjectLikeNotification = onDocumentWritten(
{
region: "europe-west1",
document: "projects/{projectId}",
region: 'europe-west1',
document: 'projects/{projectId}',
},
async (event) => {
try {
const { projectId } = event.params || {};
const before = event?.data?.before?.data() || {};
const after = event?.data?.after?.data() || {};
const { projectId } = event.params || {}
const before = event?.data?.before?.data() || {}
const after = event?.data?.after?.data() || {}
if (!projectId || !after) {
return null;
return null
}
const ownerId = after.userId || null;
const ownerId = after.userId || null
if (!ownerId) {
return null;
return null
}
const beforeSongLikes = Array.isArray(before?.likes?.song)
? before.likes.song
: [];
const afterSongLikes = Array.isArray(after?.likes?.song)
? after.likes.song
: [];
const beforeSongLikes = Array.isArray(before?.likes?.song) ? before.likes.song : []
const afterSongLikes = Array.isArray(after?.likes?.song) ? after.likes.song : []
const beforePlaybackLikes = Array.isArray(before?.likes?.playback)
? before.likes.playback
: [];
const afterPlaybackLikes = Array.isArray(after?.likes?.playback)
? after.likes.playback
: [];
: []
const afterPlaybackLikes = Array.isArray(after?.likes?.playback) ? after.likes.playback : []
const beforeSongSet = new Set(beforeSongLikes);
const beforePlaybackSet = new Set(beforePlaybackLikes);
const beforeSongSet = new Set(beforeSongLikes)
const beforePlaybackSet = new Set(beforePlaybackLikes)
const newSongLikers = afterSongLikes.filter(
(uid) => uid && !beforeSongSet.has(uid),
);
const newSongLikers = afterSongLikes.filter((uid) => uid && !beforeSongSet.has(uid))
const newPlaybackLikers = afterPlaybackLikes.filter(
(uid) => uid && !beforePlaybackSet.has(uid),
);
(uid) => uid && !beforePlaybackSet.has(uid)
)
const newLikers = [];
const newLikers = []
newSongLikers.forEach((uid) => {
newLikers.push({ likerId: uid, likeType: "song" });
});
newLikers.push({ likerId: uid, likeType: 'song' })
})
newPlaybackLikers.forEach((uid) => {
newLikers.push({ likerId: uid, likeType: "playback" });
});
newLikers.push({ likerId: uid, likeType: 'playback' })
})
if (!newLikers.length) {
return null;
return null
}
const projectTitle =
typeof after.title === "string" && after.title.trim()
? after.title.trim()
: "ton projet";
typeof after.title === 'string' && after.title.trim() ? after.title.trim() : 'ton projet'
await Promise.all(
newLikers.map(async ({ likerId, likeType }) => {
if (!likerId || likerId === ownerId) {
return null;
return null
}
const likerSnap = await refList.users.doc(likerId).get();
const liker = likerSnap?.data() || {};
const likerSnap = await refList.users.doc(likerId).get()
const liker = likerSnap?.data() || {}
const likerName =
typeof liker?.userName === "string" && liker.userName.trim()
typeof liker?.userName === 'string' && liker.userName.trim()
? liker.userName.trim()
: "Un utilisateur";
: 'Un utilisateur'
const isPlaybackLike = likeType === "playback";
const assetLabel = isPlaybackLike ? "ton playback" : "ta musique";
const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"`;
const isPlaybackLike = likeType === 'playback'
const assetLabel = isPlaybackLike ? 'ton playback' : 'ta musique'
const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"`
await sendNotification({
sender: likerId,
receiver: ownerId,
receiverCollection: "users",
title: "Nouveau like",
receiverCollection: 'users',
title: 'Nouveau like',
message,
data: {
type: ALERT_TYPE?.NEW_LIKE,
@@ -516,92 +477,86 @@ exports.createProjectLikeNotification = onDocumentWritten(
likerName,
likeType,
},
});
})
return null;
}),
);
return null
})
)
return null;
return null
} catch (error) {
console.log("[createProjectLikeNotification] error:", error);
return error;
console.log('[createProjectLikeNotification] error:', error)
return error
}
},
);
}
)
exports.createNewFollowerNotification = onDocumentWritten(
{
region: "europe-west1",
document: "users/{userId}",
region: 'europe-west1',
document: 'users/{userId}',
},
async (event) => {
try {
const { userId } = event.params || {};
const before = event?.data?.before?.data() || {};
const after = event?.data?.after?.data() || {};
const { userId } = event.params || {}
const before = event?.data?.before?.data() || {}
const after = event?.data?.after?.data() || {}
if (!userId || !after) {
return null;
return null
}
const beforeFollowers = Array.isArray(before?.followedBy)
? before.followedBy
: [];
const afterFollowers = Array.isArray(after?.followedBy)
? after.followedBy
: [];
const beforeFollowers = Array.isArray(before?.followedBy) ? before.followedBy : []
const afterFollowers = Array.isArray(after?.followedBy) ? after.followedBy : []
if (afterFollowers.length <= beforeFollowers.length) {
return null;
return null
}
const previousSet = new Set(beforeFollowers);
const newFollowers = afterFollowers.filter(
(uid) => !previousSet.has(uid),
);
const previousSet = new Set(beforeFollowers)
const newFollowers = afterFollowers.filter((uid) => !previousSet.has(uid))
if (!newFollowers.length) {
return null;
return null
}
await Promise.all(
newFollowers.map(async (followerId) => {
if (!followerId || followerId === userId) {
return null;
return null
}
const followerSnap = await refList.users.doc(followerId).get();
const follower = followerSnap?.data() || {};
const followerSnap = await refList.users.doc(followerId).get()
const follower = followerSnap?.data() || {}
const followerName =
typeof follower?.userName === "string" && follower.userName.trim()
typeof follower?.userName === 'string' && follower.userName.trim()
? follower.userName.trim()
: "Un utilisateur";
: 'Un utilisateur'
const message = `${followerName} te suit maintenant`;
const message = `${followerName} te suit maintenant`
await sendNotification({
sender: followerId,
receiver: userId,
receiverCollection: "users",
title: "Nouvel abonné",
receiverCollection: 'users',
title: 'Nouvel abonné',
message,
data: {
type: ALERT_TYPE?.NEW_FOLLOWER,
followerId,
followerName,
followerProfilePicture: follower?.profilePicture || "",
followerProfilePicture: follower?.profilePicture || '',
},
});
})
return null;
}),
);
return null
})
)
return null;
return null
} catch (error) {
console.log("[createNewFollowerNotification] error:", error);
return error;
console.log('[createNewFollowerNotification] error:', error)
return error
}
},
);
}
)
+174 -205
View File
@@ -1,86 +1,81 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const { onDocumentCreated } = require("firebase-functions/firestore");
const { HttpsError, onCall } = require("firebase-functions/https");
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const { onDocumentCreated } = require('firebase-functions/firestore')
const { HttpsError, onCall } = require('firebase-functions/https')
const { REGION, ALERT_TYPE } = require("../index");
const { sendNotification } = require("./notifications");
const { REGION, ALERT_TYPE } = require('../index')
const { sendNotification } = require('./notifications')
const {
ORDER_TYPES,
ORDER_STATUS,
ORDERS_COLLECTION,
createOrderDocument,
normalizeAmount,
} = require("./helpers/orders");
} = require('./helpers/orders')
const USERS_COLLECTION = "users";
const USERS_COLLECTION = 'users'
const formatCoinsText = (value) => {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
if (typeof value !== 'number' || Number.isNaN(value)) {
return null
}
const absoluteValue = Math.abs(value);
const absoluteValue = Math.abs(value)
if (!Number.isFinite(absoluteValue)) {
return null;
return null
}
const formatted = Number.isInteger(absoluteValue)
? `${absoluteValue}`
: absoluteValue.toFixed(2);
const suffix = absoluteValue === 1 ? "crédit" : "crédits";
const formatted = Number.isInteger(absoluteValue) ? `${absoluteValue}` : absoluteValue.toFixed(2)
const suffix = absoluteValue === 1 ? 'crédit' : 'crédits'
return `${formatted} ${suffix}`;
};
return `${formatted} ${suffix}`
}
const buildOrderNotificationContent = ({ amount, orderType, balanceAfter }) => {
if (typeof amount !== "number" || Number.isNaN(amount) || amount === 0) {
return null;
if (typeof amount !== 'number' || Number.isNaN(amount) || amount === 0) {
return null
}
const coinsText = formatCoinsText(amount);
const coinsText = formatCoinsText(amount)
if (!coinsText) {
return null;
return null
}
const balanceText = formatCoinsText(balanceAfter);
const balanceSentence = balanceText
? ` Ton solde est maintenant de ${balanceText}.`
: "";
const balanceText = formatCoinsText(balanceAfter)
const balanceSentence = balanceText ? ` Ton solde est maintenant de ${balanceText}.` : ''
if (amount > 0) {
if (orderType === ORDER_TYPES.COINS) {
return {
title: "Crédits achetés",
title: 'Crédits achetés',
message: `Ton achat de ${coinsText} est confirmé.${balanceSentence}`,
action: "PURCHASED",
};
action: 'PURCHASED',
}
}
if (orderType === ORDER_TYPES.GIFT) {
return {
title: "Crédits reçus",
title: 'Crédits reçus',
message: `Tu as reçu ${coinsText}.${balanceSentence}`,
action: "EARNED",
};
action: 'EARNED',
}
}
return {
title: "Crédits ajoutés",
title: 'Crédits ajoutés',
message: `Ton solde augmente de ${coinsText}.${balanceSentence}`,
action: "CREDITED",
};
action: 'CREDITED',
}
}
const reason =
orderType === ORDER_TYPES.SONG ? " pour générer un nouveau son" : "";
const reason = orderType === ORDER_TYPES.SONG ? ' pour générer un nouveau son' : ''
return {
title: "Crédits dépensés",
title: 'Crédits dépensés',
message: `Tu as dépensé ${coinsText}${reason}.${balanceSentence}`,
action: "SPENT",
};
};
action: 'SPENT',
}
}
const notifyOrderApplied = async ({
userId,
@@ -95,226 +90,200 @@ const notifyOrderApplied = async ({
amount,
orderType,
balanceAfter,
});
})
if (!content || !userId) {
return;
return
}
try {
await sendNotification({
sender: "SYSTEM",
sender: 'SYSTEM',
receiver: userId,
receiverCollection: USERS_COLLECTION,
title: content.title,
message: content.message,
data: {
type: ALERT_TYPE?.CREDITS_UPDATED || "CREDITS_UPDATED",
type: ALERT_TYPE?.CREDITS_UPDATED || 'CREDITS_UPDATED',
orderId,
orderType: orderType || null,
amount,
balanceBefore,
balanceAfter,
action: content.action,
source:
typeof metadata?.source === "string" ? metadata.source : null,
source: typeof metadata?.source === 'string' ? metadata.source : null,
metadata: metadata || {},
},
});
})
} catch (error) {
console.error(
"[orders-onOrderCreated] Failed to send notification",
orderId,
error,
);
console.error('[orders-onOrderCreated] Failed to send notification', orderId, error)
}
};
}
const onOrderCreated = onDocumentCreated(
`${ORDERS_COLLECTION}/{orderId}`,
async (event) => {
const orderRef = event?.data?.ref;
const orderData = event?.data?.data();
const onOrderCreated = onDocumentCreated(`${ORDERS_COLLECTION}/{orderId}`, async (event) => {
const orderRef = event?.data?.ref
const orderData = event?.data?.data()
if (!orderRef || !orderData) {
return;
}
if (!orderRef || !orderData) {
return
}
if (orderData?.processedAt) {
return;
}
if (orderData?.processedAt) {
return
}
const userId =
typeof orderData.userId === "string" ? orderData.userId.trim() : "";
const amount = normalizeAmount(orderData.amount);
const userId = typeof orderData.userId === 'string' ? orderData.userId.trim() : ''
const amount = normalizeAmount(orderData.amount)
if (!userId) {
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: "USER_NOT_FOUND",
},
{ merge: true },
);
return;
}
if (!userId) {
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: 'USER_NOT_FOUND',
},
{ merge: true }
)
return
}
if (amount === null || amount === 0) {
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: "INVALID_AMOUNT",
},
{ merge: true },
);
return;
}
if (amount === null || amount === 0) {
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: 'INVALID_AMOUNT',
},
{ merge: true }
)
return
}
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId);
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId)
let notificationContext = null;
let notificationContext = null
try {
await admin.firestore().runTransaction(async (transaction) => {
const userSnapshot = await transaction.get(userRef);
const userData = userSnapshot?.data() || {};
const currentBalanceValue = normalizeAmount(userData?.coins);
const currentBalance =
currentBalanceValue !== null ? currentBalanceValue : 0;
try {
await admin.firestore().runTransaction(async (transaction) => {
const userSnapshot = await transaction.get(userRef)
const userData = userSnapshot?.data() || {}
const currentBalanceValue = normalizeAmount(userData?.coins)
const currentBalance = currentBalanceValue !== null ? currentBalanceValue : 0
const nextBalance = currentBalance + amount;
if (
amount < 0 &&
nextBalance < 0 &&
orderData?.type === ORDER_TYPES.SONG
) {
transaction.set(
orderRef,
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: "INSUFFICIENT_FUNDS",
balanceBefore: currentBalance,
balanceAfter: currentBalance,
},
{ merge: true },
);
return;
}
if (userSnapshot?.exists) {
transaction.update(userRef, {
coins: nextBalance,
updatedAt: FieldValue.serverTimestamp(),
});
} else {
transaction.set(
userRef,
{
coins: nextBalance,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
}
const nextBalance = currentBalance + amount
if (amount < 0 && nextBalance < 0 && orderData?.type === ORDER_TYPES.SONG) {
transaction.set(
orderRef,
{
status: ORDER_STATUS.APPLIED,
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: 'INSUFFICIENT_FUNDS',
balanceBefore: currentBalance,
balanceAfter: nextBalance,
balanceAfter: currentBalance,
},
{ merge: true },
);
{ merge: true }
)
return
}
notificationContext = {
if (userSnapshot?.exists) {
transaction.update(userRef, {
coins: nextBalance,
updatedAt: FieldValue.serverTimestamp(),
})
} else {
transaction.set(
userRef,
{
coins: nextBalance,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
}
transaction.set(
orderRef,
{
status: ORDER_STATUS.APPLIED,
processedAt: FieldValue.serverTimestamp(),
balanceBefore: currentBalance,
balanceAfter: nextBalance,
};
});
} catch (error) {
console.error(
"[orders-onOrderCreated] Failed to process order",
orderRef.id,
error,
);
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: "PROCESSING_ERROR",
errorMessage: error?.message || String(error),
},
{ merge: true },
);
return;
}
{ merge: true }
)
if (notificationContext) {
await notifyOrderApplied({
userId,
orderId: orderRef.id,
amount,
orderType:
typeof orderData?.type === "string" ? orderData.type : null,
balanceBefore: notificationContext.balanceBefore,
balanceAfter: notificationContext.balanceAfter,
metadata: orderData?.metadata || {},
});
}
},
);
notificationContext = {
balanceBefore: currentBalance,
balanceAfter: nextBalance,
}
})
} catch (error) {
console.error('[orders-onOrderCreated] Failed to process order', orderRef.id, error)
const createSongOrder = onCall({ region: REGION }, async (request) => {
const { auth, data } = request || {};
if (!auth?.uid) {
throw new HttpsError("unauthenticated", "Authentification requise.");
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: 'PROCESSING_ERROR',
errorMessage: error?.message || String(error),
},
{ merge: true }
)
return
}
const amount = normalizeAmount(data?.amount);
if (notificationContext) {
await notifyOrderApplied({
userId,
orderId: orderRef.id,
amount,
orderType: typeof orderData?.type === 'string' ? orderData.type : null,
balanceBefore: notificationContext.balanceBefore,
balanceAfter: notificationContext.balanceAfter,
metadata: orderData?.metadata || {},
})
}
})
const createSongOrder = onCall({ region: REGION }, async (request) => {
const { auth, data } = request || {}
if (!auth?.uid) {
throw new HttpsError('unauthenticated', 'Authentification requise.')
}
const amount = normalizeAmount(data?.amount)
if (amount === null || amount >= 0) {
throw new HttpsError(
"invalid-argument",
"Le montant doit être négatif pour un achat de musique.",
);
'invalid-argument',
'Le montant doit être négatif pour un achat de musique.'
)
}
const songId =
typeof data?.songId === "string" && data.songId.trim()
? data.songId.trim()
: null;
const songId = typeof data?.songId === 'string' && data.songId.trim() ? data.songId.trim() : null
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid);
const userSnapshot = await userRef.get();
const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins);
const currentCoins =
currentCoinsValue !== null ? currentCoinsValue : 0;
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid)
const userSnapshot = await userRef.get()
const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins)
const currentCoins = currentCoinsValue !== null ? currentCoinsValue : 0
if (currentCoins + amount < 0) {
throw new HttpsError(
"failed-precondition",
"Crédits insuffisants pour finaliser l'opération.",
);
throw new HttpsError('failed-precondition', "Crédits insuffisants pour finaliser l'opération.")
}
const metadata = {
source:
typeof data?.source === "string" && data.source.trim()
typeof data?.source === 'string' && data.source.trim()
? data.source.trim()
: "music_generation",
};
: 'music_generation',
}
if (typeof data?.requestId === "string" && data.requestId.trim()) {
metadata.requestId = data.requestId.trim();
if (typeof data?.requestId === 'string' && data.requestId.trim()) {
metadata.requestId = data.requestId.trim()
}
const { orderId } = await createOrderDocument({
@@ -324,12 +293,12 @@ const createSongOrder = onCall({ region: REGION }, async (request) => {
songId,
createdBy: auth.uid,
metadata,
});
})
return { orderId };
});
return { orderId }
})
module.exports = {
onOrderCreated,
createSongOrder,
};
}
+119 -145
View File
@@ -1,151 +1,134 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const { onSchedule } = require("firebase-functions/v2/scheduler");
const _ = require("lodash");
const { refList, db, ALERT_TYPE } = require("../index");
const { batchFirestore } = require("../helpers/firebase");
const { BATCH_TYPE } = require("../config/types");
const {
buildMonthKey,
buildPreviousMonthContext,
} = require("../helpers/stats");
const { sendNotification } = require("./notifications");
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const { onSchedule } = require('firebase-functions/v2/scheduler')
const _ = require('lodash')
const { refList, db, ALERT_TYPE } = require('../index')
const { batchFirestore } = require('../helpers/firebase')
const { BATCH_TYPE } = require('../config/types')
const { buildMonthKey, buildPreviousMonthContext } = require('../helpers/stats')
const { sendNotification } = require('./notifications')
const DISTRIBUTION_REVENUE_BASELINE = 1000;
const DISTRIBUTION_RATIO = 0.3;
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(["active"]);
const DISTRIBUTION_REVENUE_BASELINE = 1000
const DISTRIBUTION_RATIO = 0.3
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['active'])
const hasActiveSubscription = (userData) => {
if (!userData || typeof userData !== "object") {
return false;
if (!userData || typeof userData !== 'object') {
return false
}
const isPremium = userData.isPremium === true;
const isPremium = userData.isPremium === true
if (!isPremium) {
return false;
return false
}
const status =
typeof userData.stripeSubscriptionStatus === "string"
typeof userData.stripeSubscriptionStatus === 'string'
? userData.stripeSubscriptionStatus.trim().toLowerCase()
: null;
: null
if (status && ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
return true;
return true
}
const billingPeriod =
typeof userData.premiumBillingPeriod === "string"
typeof userData.premiumBillingPeriod === 'string'
? userData.premiumBillingPeriod.trim().toLowerCase()
: null;
if (billingPeriod === "monthly" || billingPeriod === "annual") {
: null
if (billingPeriod === 'monthly' || billingPeriod === 'annual') {
// Fallback: billing period is set only for active subscribers.
return true;
return true
}
return false;
};
return false
}
exports.distributeMonthlyPayouts = onSchedule(
{
schedule: "0 1 1 * *",
timeZone: "Europe/Paris",
schedule: '0 1 1 * *',
timeZone: 'Europe/Paris',
},
async (event) => {
const { scheduleTime } = event;
const context = buildPreviousMonthContext(
scheduleTime ? new Date(scheduleTime) : new Date(),
);
const monthKey = buildMonthKey(context.rangeStart);
const now = admin.firestore.Timestamp.now();
const { scheduleTime } = event
const context = buildPreviousMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
const monthKey = buildMonthKey(context.rangeStart)
const now = admin.firestore.Timestamp.now()
const statsSnapshot = await db
.collectionGroup("monthlyListens")
.where("monthKey", "==", monthKey)
.orderBy("streams", "desc")
.get();
.collectionGroup('monthlyListens')
.where('monthKey', '==', monthKey)
.orderBy('streams', 'desc')
.get()
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey);
const totalsSnapshot = await totalsDocRef.get();
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey)
const totalsSnapshot = await totalsDocRef.get()
const entries = _.chain(statsSnapshot.docs)
.map((doc) => {
const data = doc.data() || {};
const data = doc.data() || {}
return {
projectId:
_.get(data, "projectId") || doc.ref.parent.parent?.id || null,
userId: _.get(data, "userId", null),
streams: _.toFinite(_.get(data, "streams", 0)),
projectId: _.get(data, 'projectId') || doc.ref.parent.parent?.id || null,
userId: _.get(data, 'userId', null),
streams: _.toFinite(_.get(data, 'streams', 0)),
statsDocPath: doc.ref.path,
};
}
})
.filter((entry) => entry.projectId && entry.streams > 0)
.orderBy(["streams"], ["desc"])
.value();
.orderBy(['streams'], ['desc'])
.value()
const userEligibilityMap = {};
const userIds = _.uniq(
entries.map((entry) => entry.userId).filter((userId) => !!userId),
);
const userEligibilityMap = {}
const userIds = _.uniq(entries.map((entry) => entry.userId).filter((userId) => !!userId))
if (userIds.length) {
const chunkSize = 300;
const chunkSize = 300
for (let index = 0; index < userIds.length; index += chunkSize) {
const chunk = userIds.slice(index, 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();
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;
console.warn('[distributeMonthlyPayouts] Unable to load user profile', {
userId,
error: error?.message || String(error),
})
return null
}
}),
);
})
)
snapshots.forEach((snapshot, snapshotIndex) => {
const userId = chunk[snapshotIndex];
const userId = chunk[snapshotIndex]
if (snapshot?.exists) {
userEligibilityMap[userId] = hasActiveSubscription(
snapshot.data(),
);
userEligibilityMap[userId] = hasActiveSubscription(snapshot.data())
} else {
userEligibilityMap[userId] = false;
userEligibilityMap[userId] = false
}
});
})
}
}
const eligibleEntries = entries.filter(
(entry) =>
!!entry.userId && userEligibilityMap[entry.userId] === true,
);
const eligibleTotalStreams = _.sumBy(eligibleEntries, "streams");
(entry) => !!entry.userId && userEligibilityMap[entry.userId] === true
)
const eligibleTotalStreams = _.sumBy(eligibleEntries, 'streams')
const payoutsTotalStreamsFromDocs = _.sumBy(entries, "streams");
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null;
let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0));
const payoutsTotalStreamsFromDocs = _.sumBy(entries, 'streams')
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null
let totalStreams = _.toFinite(_.get(totalsData, 'totalStreams', 0))
if (!totalStreams || totalStreams < payoutsTotalStreamsFromDocs) {
totalStreams = payoutsTotalStreamsFromDocs;
totalStreams = payoutsTotalStreamsFromDocs
}
const payoutPool = _.round(
DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO,
2,
);
const payoutPool = _.round(DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO, 2)
let allocations = _.map(eligibleEntries, (entry) => {
if (!eligibleTotalStreams) return 0;
const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams;
return _.round(rawAmount, 2);
});
if (!eligibleTotalStreams) return 0
const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams
return _.round(rawAmount, 2)
})
if (allocations.length > 0) {
const allocatedTotal = _.round(_.sum(allocations), 2);
const remainder = _.round(payoutPool - allocatedTotal, 2);
const allocatedTotal = _.round(_.sum(allocations), 2)
const remainder = _.round(payoutPool - allocatedTotal, 2)
if (remainder !== 0) {
allocations[0] = _.round(allocations[0] + remainder, 2);
allocations[0] = _.round(allocations[0] + remainder, 2)
}
}
@@ -154,40 +137,36 @@ exports.distributeMonthlyPayouts = onSchedule(
projectId: entry.projectId,
userId: entry.userId,
streams: entry.streams,
share: eligibleTotalStreams
? _.round(entry.streams / eligibleTotalStreams, 6)
: 0,
share: eligibleTotalStreams ? _.round(entry.streams / eligibleTotalStreams, 6) : 0,
amount: allocations[idx],
statsDocPath: entry.statsDocPath,
}));
}))
const userDocs = _(payouts)
.filter((payout) => !!payout.userId)
.groupBy("userId")
.groupBy('userId')
.map((projects, userId) => {
const sortedProjects = _.orderBy(projects, ["amount"], ["desc"]).map(
(project) => ({
projectId: project.projectId,
rank: project.rank,
amount: project.amount,
streams: project.streams,
share: project.share,
statsDocPath: project.statsDocPath,
}),
);
const sortedProjects = _.orderBy(projects, ['amount'], ['desc']).map((project) => ({
projectId: project.projectId,
rank: project.rank,
amount: project.amount,
streams: project.streams,
share: project.share,
statsDocPath: project.statsDocPath,
}))
return {
userId,
totalAmount: _.round(_.sumBy(projects, "amount"), 2),
totalStreams: _.sumBy(projects, "streams"),
totalAmount: _.round(_.sumBy(projects, 'amount'), 2),
totalStreams: _.sumBy(projects, 'streams'),
projects: sortedProjects,
};
}
})
.value();
.value()
if (userDocs.length) {
const docs = userDocs.map((userData) => {
const docId = `${monthKey}_${userData.userId}`;
const docId = `${monthKey}_${userData.userId}`
return {
ref: refList.monthlyPayoutEntries.doc(docId),
data: {
@@ -200,35 +179,33 @@ exports.distributeMonthlyPayouts = onSchedule(
computedAt: now,
updatedAt: FieldValue.serverTimestamp(),
},
};
});
}
})
await batchFirestore({
docs,
type: BATCH_TYPE.UPDATE,
});
})
const payoutLabel = `${String(context.month).padStart(2, "0")}/${
context.year
}`;
const payoutLabel = `${String(context.month).padStart(2, '0')}/${context.year}`
await Promise.all(
userDocs.map(async (userData) => {
const receiverId =
typeof userData.userId === "string" && userData.userId.trim()
typeof userData.userId === 'string' && userData.userId.trim()
? userData.userId.trim()
: null;
const amount = Number(userData.totalAmount) || 0;
: null
const amount = Number(userData.totalAmount) || 0
if (!receiverId || amount <= 0) {
return null;
return null
}
const amountLabel = amount.toFixed(2);
const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`;
const amountLabel = amount.toFixed(2)
const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`
try {
await sendNotification({
sender: "SYSTEM",
sender: 'SYSTEM',
receiver: receiverId,
receiverCollection: "users",
title: "Revenus disponibles",
receiverCollection: 'users',
title: 'Revenus disponibles',
message,
data: {
type: ALERT_TYPE?.PAYOUT_AVAILABLE,
@@ -239,19 +216,16 @@ exports.distributeMonthlyPayouts = onSchedule(
totalStreams: userData.totalStreams,
projects: userData.projects,
},
});
})
} catch (notifError) {
console.log(
"[distributeMonthlyPayouts] Failed to send payout notification:",
{
userId: receiverId,
error: notifError?.message || String(notifError),
},
);
console.log('[distributeMonthlyPayouts] Failed to send payout notification:', {
userId: receiverId,
error: notifError?.message || String(notifError),
})
}
return null;
}),
);
return null
})
)
}
const summary = {
@@ -270,14 +244,14 @@ exports.distributeMonthlyPayouts = onSchedule(
totalRecipients: payouts.length,
totalEntries: entries.length,
eligibleEntries: eligibleEntries.length,
totalAllocated: _.round(_.sumBy(payouts, "amount"), 2),
totalAllocated: _.round(_.sumBy(payouts, 'amount'), 2),
payouts,
status: payouts.length ? "computed" : "no-data",
status: payouts.length ? 'computed' : 'no-data',
updatedAt: FieldValue.serverTimestamp(),
computedAt: now,
};
}
const docRef = refList.monthlyPayouts.doc(monthKey);
await docRef.set(summary, { merge: true });
},
);
const docRef = refList.monthlyPayouts.doc(monthKey)
await docRef.set(summary, { merge: true })
}
)
+96 -107
View File
@@ -1,115 +1,104 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const { onDocumentWritten } = require("firebase-functions/firestore");
const _ = require("lodash");
const { refList, db } = require("../index");
const { getSunoTimestamps } = require("./lyrics");
const { deleteFolder } = require("../helpers/firebase");
const { buildMonthKey } = require("../helpers/stats");
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const { onDocumentWritten } = require('firebase-functions/firestore')
const _ = require('lodash')
const { refList, db } = require('../index')
const { getSunoTimestamps } = require('./lyrics')
const { deleteFolder } = require('../helpers/firebase')
const { buildMonthKey } = require('../helpers/stats')
exports.onProjectWritten = onDocumentWritten(
"projects/{projectId}",
async (projectSnap) => {
try {
const { projectId } = projectSnap.params;
const beforeData = projectSnap?.data?.before?.data() || null;
const afterData = projectSnap?.data?.after?.data() || null;
exports.onProjectWritten = 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) {
const { userId } = beforeData || {};
if (!afterData) {
const { userId } = beforeData || {}
if (userId) {
await deleteFolder(`users/${userId}/projects/${projectId}/`);
}
const snapshot = await refList.tasks
.where("projectId", "==", projectId)
.get();
if (!snapshot.empty) {
const batch = db.batch();
snapshot.forEach((doc) => {
batch.delete(doc.ref);
});
await batch.commit();
}
return null;
} else if (!beforeData) {
//song create
} else {
if (!beforeData?.songUrl && afterData?.songUrl) {
await getSunoTimestamps(projectId);
await refList.projects.doc(projectId).update({ hasSong: true });
}
if (!beforeData?.playbackUrl && afterData?.playbackUrl) {
await refList.projects.doc(projectId).update({ hasPlayback: true });
}
if (userId) {
await deleteFolder(`users/${userId}/projects/${projectId}/`)
}
const beforeViews = _.toFinite(_.get(beforeData, "views", 0));
const afterViews = _.toFinite(_.get(afterData, "views", 0));
const delta = afterViews - beforeViews;
if (delta > 0) {
const userIdRaw = _.get(afterData, "userId", null);
const userId =
_.isString(userIdRaw) && _.trim(userIdRaw).length
? _.trim(userIdRaw)
: null;
const now = admin.firestore.Timestamp.now();
const monthKey = buildMonthKey(now);
const statsDocRef = refList.projectStreamStats
.doc(projectId)
.collection("monthlyListens")
.doc(monthKey);
const totalsDocRef =
refList.projectStreamStatsMonthlyTotals.doc(monthKey);
await db.runTransaction(async (transaction) => {
const statsSnapshot = await transaction.get(statsDocRef);
const totalsSnapshot = await transaction.get(totalsDocRef);
const updatePayload = {
projectId,
userId,
monthKey,
updatedAt: now,
lastStreamAt: now,
lastDelta: delta,
streams: FieldValue.increment(delta),
};
const hasFirstStream =
statsSnapshot.exists && statsSnapshot.data()?.firstStreamAt;
if (!hasFirstStream) {
updatePayload.firstStreamAt = now;
}
transaction.set(statsDocRef, updatePayload, { merge: true });
const totalsUpdatePayload = {
monthKey,
updatedAt: now,
lastStreamAt: now,
lastDelta: delta,
totalStreams: FieldValue.increment(delta),
};
const totalsHasFirstStream =
totalsSnapshot.exists && totalsSnapshot.data()?.firstStreamAt;
if (!totalsHasFirstStream) {
totalsUpdatePayload.firstStreamAt = now;
}
transaction.set(totalsDocRef, totalsUpdatePayload, { merge: true });
});
const snapshot = await refList.tasks.where('projectId', '==', projectId).get()
if (!snapshot.empty) {
const batch = db.batch()
snapshot.forEach((doc) => {
batch.delete(doc.ref)
})
await batch.commit()
}
return null;
} catch (error) {
console.log("onProjectViewsIncrement error", {
message: error?.message || String(error || ""),
});
return null;
return null
} else if (!beforeData) {
//song create
} else {
if (!beforeData?.songUrl && afterData?.songUrl) {
await getSunoTimestamps(projectId)
await refList.projects.doc(projectId).update({ hasSong: true })
}
if (!beforeData?.playbackUrl && afterData?.playbackUrl) {
await refList.projects.doc(projectId).update({ hasPlayback: true })
}
}
},
);
const beforeViews = _.toFinite(_.get(beforeData, 'views', 0))
const afterViews = _.toFinite(_.get(afterData, 'views', 0))
const delta = afterViews - beforeViews
if (delta > 0) {
const userIdRaw = _.get(afterData, 'userId', null)
const userId = _.isString(userIdRaw) && _.trim(userIdRaw).length ? _.trim(userIdRaw) : null
const now = admin.firestore.Timestamp.now()
const monthKey = buildMonthKey(now)
const statsDocRef = refList.projectStreamStats
.doc(projectId)
.collection('monthlyListens')
.doc(monthKey)
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey)
await db.runTransaction(async (transaction) => {
const statsSnapshot = await transaction.get(statsDocRef)
const totalsSnapshot = await transaction.get(totalsDocRef)
const updatePayload = {
projectId,
userId,
monthKey,
updatedAt: now,
lastStreamAt: now,
lastDelta: delta,
streams: FieldValue.increment(delta),
}
const hasFirstStream = statsSnapshot.exists && statsSnapshot.data()?.firstStreamAt
if (!hasFirstStream) {
updatePayload.firstStreamAt = now
}
transaction.set(statsDocRef, updatePayload, { merge: true })
const totalsUpdatePayload = {
monthKey,
updatedAt: now,
lastStreamAt: now,
lastDelta: delta,
totalStreams: FieldValue.increment(delta),
}
const totalsHasFirstStream = totalsSnapshot.exists && totalsSnapshot.data()?.firstStreamAt
if (!totalsHasFirstStream) {
totalsUpdatePayload.firstStreamAt = now
}
transaction.set(totalsDocRef, totalsUpdatePayload, { merge: true })
})
}
return null
} catch (error) {
console.log('onProjectViewsIncrement error', {
message: error?.message || String(error || ''),
})
return null
}
})
+32 -39
View File
@@ -1,22 +1,22 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const { onSchedule } = require("firebase-functions/v2/scheduler");
const { refList } = require("../index");
const firestore = refList.projects.firestore;
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const { onSchedule } = require('firebase-functions/v2/scheduler')
const { refList } = require('../index')
const firestore = refList.projects.firestore
function buildMonthContext(referenceDate) {
const current = referenceDate ? new Date(referenceDate) : new Date();
current.setHours(0, 0, 0, 0);
current.setDate(1);
const current = referenceDate ? new Date(referenceDate) : new Date()
current.setHours(0, 0, 0, 0)
current.setDate(1)
const target = new Date(current);
target.setMonth(target.getMonth() - 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);
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,
@@ -24,27 +24,22 @@ function buildMonthContext(referenceDate) {
monthKey,
rangeStart,
rangeEnd,
};
}
}
exports.snapshotMonthlyTopSongs = onSchedule(
{
schedule: "5 0 1 * *",
timeZone: "Europe/Paris",
schedule: '5 0 1 * *',
timeZone: 'Europe/Paris',
},
async (event) => {
const { scheduleTime } = event;
const context = buildMonthContext(
scheduleTime ? new Date(scheduleTime) : new Date()
);
const { scheduleTime } = event
const context = buildMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
const topProjectsSnap = await refList.projects
.orderBy("views", "desc")
.limit(3)
.get();
const topProjectsSnap = await refList.projects.orderBy('views', 'desc').limit(3).get()
const topProjects = topProjectsSnap.docs.map((doc, index) => {
const data = doc.data() || {};
const data = doc.data() || {}
return {
rank: index + 1,
projectId: doc.id,
@@ -54,14 +49,12 @@ exports.snapshotMonthlyTopSongs = onSchedule(
coverUrl: data.coverUrl || null,
songUrl: data.songUrl || null,
views: data.views || 0,
};
});
}
})
const docRef = firestore
.collection("monthlyTopSongs")
.doc(context.monthKey);
const docRef = firestore.collection('monthlyTopSongs').doc(context.monthKey)
const existingSnapshot = await docRef.get();
const existingSnapshot = await docRef.get()
const payload = {
monthKey: context.monthKey,
month: context.month,
@@ -73,12 +66,12 @@ exports.snapshotMonthlyTopSongs = onSchedule(
topProjects,
totalProjects: topProjects.length,
updatedAt: FieldValue.serverTimestamp(),
};
if (!existingSnapshot.exists) {
payload.createdAt = FieldValue.serverTimestamp();
}
await docRef.set(payload, { merge: true });
if (!existingSnapshot.exists) {
payload.createdAt = FieldValue.serverTimestamp()
}
await docRef.set(payload, { merge: true })
}
);
)
+246 -323
View File
@@ -1,15 +1,15 @@
const admin = require("firebase-admin");
const { HttpsError, onCall } = require("firebase-functions/https");
const admin = require('firebase-admin')
const { HttpsError, onCall } = require('firebase-functions/https')
let FieldValue = null;
let FieldValue = null
try {
({ FieldValue } = require("firebase-admin/firestore"));
;({ FieldValue } = require('firebase-admin/firestore'))
} catch (error) {
console.warn("[stripe] FieldValue import failed", error?.message);
console.warn('[stripe] FieldValue import failed', error?.message)
}
const { REGION, refsList } = require("../index");
const STRIPE_MODE = "test";
const { REGION, refsList } = require('../index')
const STRIPE_MODE = 'test'
const {
getStripeClient,
getReturnUrls,
@@ -19,23 +19,20 @@ const {
formatCheckoutSessionResponse,
getPortalConfigurationId,
mapStripeErrorToHttps,
} = require("../helpers/stripe");
} = require('../helpers/stripe')
const paymentsCollection = admin.firestore().collection("payments");
const paymentsCollection = admin.firestore().collection('payments')
const getServerTimestamp = () => {
if (FieldValue?.serverTimestamp) {
return FieldValue.serverTimestamp();
return FieldValue.serverTimestamp()
}
const fallback = admin.firestore?.FieldValue;
const fallback = admin.firestore?.FieldValue
if (fallback?.serverTimestamp) {
return fallback.serverTimestamp();
return fallback.serverTimestamp()
}
throw new HttpsError(
"failed-precondition",
"Firestore FieldValue.serverTimestamp indisponible.",
);
};
throw new HttpsError('failed-precondition', 'Firestore FieldValue.serverTimestamp indisponible.')
}
const normalizePaymentIntent = (paymentIntent) => ({
id: paymentIntent.id,
@@ -48,53 +45,44 @@ const normalizePaymentIntent = (paymentIntent) => ({
created: paymentIntent.created,
latest_charge: paymentIntent.latest_charge,
metadata: paymentIntent.metadata,
});
})
const createCheckoutSession = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour créer une session Stripe.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour créer une session Stripe.')
}
const requestedUserId =
typeof request?.data?.userID === "string"
? request.data.userID.trim()
: null;
typeof request?.data?.userID === 'string' ? request.data.userID.trim() : null
if (requestedUserId && requestedUserId !== uid) {
throw new HttpsError(
"permission-denied",
"Tu ne peux créer une session que pour ton propre compte.",
);
'permission-denied',
'Tu ne peux créer une session que pour ton propre compte.'
)
}
const productList = Array.isArray(request?.data?.productList)
? request.data.productList
: [];
const productList = Array.isArray(request?.data?.productList) ? request.data.productList : []
const stripe = getStripeClient();
const { lineItems, summary, hasSubscription } =
await buildCheckoutLineItems(productList, { stripe });
const stripe = getStripeClient()
const { lineItems, summary, hasSubscription } = await buildCheckoutLineItems(productList, {
stripe,
})
const mode = hasSubscription ? "subscription" : "payment";
const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls);
const mode = hasSubscription ? 'subscription' : 'payment'
const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls)
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: true,
});
})
if (!customerId) {
throw new HttpsError(
"internal",
"Impossible de retrouver le client Stripe associé.",
);
throw new HttpsError('internal', 'Impossible de retrouver le client Stripe associé.')
}
const session = await stripe.checkout.sessions.create({
@@ -107,12 +95,12 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => {
metadata: {
firebaseUID: uid,
},
});
})
await paymentsCollection.doc(session.id).set({
userId: uid,
customerId,
status: session.status || "created",
status: session.status || 'created',
mode,
createdAt: getServerTimestamp(),
updatedAt: getServerTimestamp(),
@@ -123,59 +111,53 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => {
amountTotal: session.amount_total,
currency: session.currency,
paymentStatus: session.payment_status,
});
})
return formatCheckoutSessionResponse(session);
return formatCheckoutSessionResponse(session)
} catch (error) {
console.error("[createCheckoutSession] error", error);
console.error('[createCheckoutSession] error', error)
if (error instanceof HttpsError) {
throw error;
throw error
}
throw mapStripeErrorToHttps(
error,
"Création de la session Stripe impossible.",
);
throw mapStripeErrorToHttps(error, 'Création de la session Stripe impossible.')
}
});
})
const getPremiumStatus = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour consulter ton statut Stripe.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour consulter ton statut Stripe.')
}
const stripe = getStripeClient();
const stripe = getStripeClient()
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: false,
});
})
if (!customerId) {
return {
customerId: null,
subscriptions: [],
invoices: [],
};
}
}
const [subscriptions, invoices] = await Promise.all([
stripe.subscriptions.list({
customer: customerId,
status: "all",
expand: ["data.items.data.price"],
status: 'all',
expand: ['data.items.data.price'],
limit: 20,
}),
stripe.invoices.list({
customer: customerId,
limit: 20,
}),
]);
])
const formattedSubscriptions = subscriptions.data.map((subscription) => ({
id: subscription.id,
@@ -193,7 +175,7 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => {
currency: item.price?.currency,
recurring: item.price?.recurring,
})),
}));
}))
const formattedInvoices = invoices.data.map((invoice) => ({
id: invoice.id,
@@ -205,179 +187,153 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => {
hosted_invoice_url: invoice.hosted_invoice_url,
invoice_pdf: invoice.invoice_pdf,
created: invoice.created,
}));
}))
return {
customerId,
subscriptions: formattedSubscriptions,
invoices: formattedInvoices,
};
}
} catch (error) {
console.error("[getPremiumStatus] error", error);
console.error('[getPremiumStatus] error', error)
if (error instanceof HttpsError) {
throw error;
throw error
}
throw mapStripeErrorToHttps(
error,
"Impossible de récupérer le statut Stripe.",
);
throw mapStripeErrorToHttps(error, 'Impossible de récupérer le statut Stripe.')
}
});
})
const createStripeCustomerPortalSession = onCall(
{ region: REGION },
async (request) => {
try {
const uid = request?.auth?.uid;
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour ouvrir le portail client Stripe.",
);
}
const stripe = getStripeClient();
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: false,
});
if (!customerId) {
throw new HttpsError(
"failed-precondition",
"Aucun client Stripe associé à cet utilisateur.",
);
}
const portalConfigurationId = await getPortalConfigurationId(stripe);
if (!portalConfigurationId) {
const modeLabel = STRIPE_MODE === "prod" ? "production" : "test";
const envKeySuffix = STRIPE_MODE === "prod" ? "PROD" : "TEST";
throw new HttpsError(
"failed-precondition",
`Configure le portail client Stripe en mode ${modeLabel} ou renseigne STRIPE_PORTAL_CONFIGURATION_${envKeySuffix}.`,
);
}
const { successUrl } = getReturnUrls();
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: successUrl,
configuration: portalConfigurationId,
});
return {
id: portalSession.id,
url: portalSession.url,
created: portalSession.created,
};
} catch (error) {
console.error("[createStripeCustomerPortalSession] error", error);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Ouverture du portail Stripe impossible.",
);
const createStripeCustomerPortalSession = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError('unauthenticated', 'Connecte-toi pour ouvrir le portail client Stripe.')
}
},
);
const stripe = getStripeClient()
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: false,
})
if (!customerId) {
throw new HttpsError('failed-precondition', 'Aucun client Stripe associé à cet utilisateur.')
}
const portalConfigurationId = await getPortalConfigurationId(stripe)
if (!portalConfigurationId) {
const modeLabel = STRIPE_MODE === 'prod' ? 'production' : 'test'
const envKeySuffix = STRIPE_MODE === 'prod' ? 'PROD' : 'TEST'
throw new HttpsError(
'failed-precondition',
`Configure le portail client Stripe en mode ${modeLabel} ou renseigne STRIPE_PORTAL_CONFIGURATION_${envKeySuffix}.`
)
}
const { successUrl } = getReturnUrls()
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: successUrl,
configuration: portalConfigurationId,
})
return {
id: portalSession.id,
url: portalSession.url,
created: portalSession.created,
}
} catch (error) {
console.error('[createStripeCustomerPortalSession] error', error)
if (error instanceof HttpsError) {
throw error
}
throw mapStripeErrorToHttps(error, 'Ouverture du portail Stripe impossible.')
}
})
const resolveStripeConnectAccountId = (userData) => {
if (!userData || typeof userData !== "object") {
return null;
if (!userData || typeof userData !== 'object') {
return null
}
const candidatePaths = [
["stripeConnectAccountId"],
["stripeConnectId"],
["stripeAccountId"],
["stripeConnectAccount"],
["stripeAccount"],
["stripe", "connectAccountId"],
["stripe", "accountId"],
["providers", "stripeConnect", "accountId"],
];
['stripeConnectAccountId'],
['stripeConnectId'],
['stripeAccountId'],
['stripeConnectAccount'],
['stripeAccount'],
['stripe', 'connectAccountId'],
['stripe', 'accountId'],
['providers', 'stripeConnect', 'accountId'],
]
for (const path of candidatePaths) {
let current = userData;
let current = userData
for (const key of path) {
if (!current || typeof current !== "object") {
current = null;
break;
if (!current || typeof current !== 'object') {
current = null
break
}
current = current[key];
current = current[key]
}
if (typeof current === "string" && current.trim()) {
return current.trim();
if (typeof current === 'string' && current.trim()) {
return current.trim()
}
}
return null;
};
return null
}
const ensureStripeConnectAccount = async ({
uid,
stripe,
userRef,
userData,
}) => {
const ensureStripeConnectAccount = async ({ uid, stripe, userRef, userData }) => {
if (!uid || !stripe) {
return { connectAccountId: null, userData, createdAccount: false };
return { connectAccountId: null, userData, createdAccount: false }
}
let connectAccountId = resolveStripeConnectAccountId(userData);
let connectAccountId = resolveStripeConnectAccountId(userData)
if (connectAccountId) {
return { connectAccountId, userData, createdAccount: false };
return { connectAccountId, userData, createdAccount: false }
}
let authRecord = null;
let authRecord = null
try {
authRecord = await admin.auth().getUser(uid);
authRecord = await admin.auth().getUser(uid)
} catch (error) {
console.warn(
"[ensureStripeConnectAccount] Impossible de récupérer auth user",
error,
);
console.warn('[ensureStripeConnectAccount] Impossible de récupérer auth user', error)
}
const email = userData?.email || authRecord?.email || undefined;
const email = userData?.email || authRecord?.email || undefined
const accountParams = {
type: "express",
type: 'express',
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
metadata: {
firebaseUID: uid,
appMode: STRIPE_MODE || "test",
appMode: STRIPE_MODE || 'test',
},
};
if (email) {
accountParams.email = email;
}
const account = await stripe.accounts.create(accountParams);
connectAccountId = account?.id;
if (email) {
accountParams.email = email
}
const account = await stripe.accounts.create(accountParams)
connectAccountId = account?.id
if (!connectAccountId) {
throw new HttpsError(
"internal",
"Stripe na pas renvoyé didentifiant de compte Connect.",
);
throw new HttpsError('internal', 'Stripe na pas renvoyé didentifiant de compte Connect.')
}
const providersConnect = {
...(userData?.providers?.stripeConnect || {}),
accountId: connectAccountId,
};
}
if (userRef) {
await userRef.set(
@@ -391,8 +347,8 @@ const ensureStripeConnectAccount = async ({
},
},
},
{ merge: true },
);
{ merge: true }
)
}
return {
@@ -406,170 +362,140 @@ const ensureStripeConnectAccount = async ({
},
},
createdAccount: true,
};
};
}
}
const createStripeConnectLoginLink = onCall(
{ region: REGION },
async (request) => {
try {
const uid = request?.auth?.uid;
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour ouvrir Stripe Connect.",
);
}
const userRef = refsList?.users?.doc(uid);
const snapshot = userRef ? await userRef.get() : null;
let userData = snapshot?.exists ? snapshot.data() : null;
const stripe = getStripeClient();
const ensureResult = await ensureStripeConnectAccount({
uid,
stripe,
userRef,
userData,
});
const connectAccountId = ensureResult.connectAccountId;
userData = ensureResult.userData;
if (!connectAccountId) {
throw new HttpsError(
"failed-precondition",
"Impossible de créer un compte Stripe Connect pour cet utilisateur.",
);
}
const redirectUrl = getReturnBaseUrl();
const loginLink = await stripe.accounts.createLoginLink(
connectAccountId,
redirectUrl
? {
redirect_url: redirectUrl,
}
: undefined,
);
if (!loginLink?.url) {
throw new HttpsError(
"internal",
"Stripe Connect na pas renvoyé de lien de connexion.",
);
}
return {
id: loginLink.id,
url: loginLink.url,
created: loginLink.created,
connectAccountId,
createdAccount: ensureResult.createdAccount,
};
} catch (error) {
console.error("[createStripeConnectLoginLink] error", error);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Ouverture de Stripe Connect impossible.",
);
const createStripeConnectLoginLink = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError('unauthenticated', 'Connecte-toi pour ouvrir Stripe Connect.')
}
},
);
const userRef = refsList?.users?.doc(uid)
const snapshot = userRef ? await userRef.get() : null
let userData = snapshot?.exists ? snapshot.data() : null
const stripe = getStripeClient()
const ensureResult = await ensureStripeConnectAccount({
uid,
stripe,
userRef,
userData,
})
const connectAccountId = ensureResult.connectAccountId
userData = ensureResult.userData
if (!connectAccountId) {
throw new HttpsError(
'failed-precondition',
'Impossible de créer un compte Stripe Connect pour cet utilisateur.'
)
}
const redirectUrl = getReturnBaseUrl()
const loginLink = await stripe.accounts.createLoginLink(
connectAccountId,
redirectUrl
? {
redirect_url: redirectUrl,
}
: undefined
)
if (!loginLink?.url) {
throw new HttpsError('internal', 'Stripe Connect na pas renvoyé de lien de connexion.')
}
return {
id: loginLink.id,
url: loginLink.url,
created: loginLink.created,
connectAccountId,
createdAccount: ensureResult.createdAccount,
}
} catch (error) {
console.error('[createStripeConnectLoginLink] error', error)
if (error instanceof HttpsError) {
throw error
}
throw mapStripeErrorToHttps(error, 'Ouverture de Stripe Connect impossible.')
}
})
const verifyStripePayment = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour vérifier un paiement Stripe.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour vérifier un paiement Stripe.')
}
const rawPaymentId =
typeof request?.data?.paymentId === "string"
? request.data.paymentId.trim()
: "";
typeof request?.data?.paymentId === 'string' ? request.data.paymentId.trim() : ''
if (!rawPaymentId) {
throw new HttpsError(
"invalid-argument",
"Fournis un identifiant de paiement Stripe.",
);
throw new HttpsError('invalid-argument', 'Fournis un identifiant de paiement Stripe.')
}
const stripe = getStripeClient();
const stripe = getStripeClient()
const fetchPaymentIntent = async (paymentIntentId) =>
stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ["latest_charge"],
});
expand: ['latest_charge'],
})
const fetchCheckoutSession = async (sessionId) =>
stripe.checkout.sessions.retrieve(sessionId, {
expand: ["payment_intent"],
});
expand: ['payment_intent'],
})
let paymentIntent = null;
let checkoutSession = null;
let paymentType = null;
let paymentIntent = null
let checkoutSession = null
let paymentType = null
if (rawPaymentId.startsWith("pi_")) {
paymentIntent = await fetchPaymentIntent(rawPaymentId);
paymentType = "payment_intent";
} else if (rawPaymentId.startsWith("cs_")) {
checkoutSession = await fetchCheckoutSession(rawPaymentId);
if (rawPaymentId.startsWith('pi_')) {
paymentIntent = await fetchPaymentIntent(rawPaymentId)
paymentType = 'payment_intent'
} else if (rawPaymentId.startsWith('cs_')) {
checkoutSession = await fetchCheckoutSession(rawPaymentId)
paymentIntent =
checkoutSession?.payment_intent &&
typeof checkoutSession.payment_intent === "object"
checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object'
? checkoutSession.payment_intent
: checkoutSession?.payment_intent
? await fetchPaymentIntent(checkoutSession.payment_intent)
: null;
paymentType = "checkout_session";
: null
paymentType = 'checkout_session'
} else {
try {
paymentIntent = await fetchPaymentIntent(rawPaymentId);
paymentType = "payment_intent";
paymentIntent = await fetchPaymentIntent(rawPaymentId)
paymentType = 'payment_intent'
} catch (intentError) {
try {
checkoutSession = await fetchCheckoutSession(rawPaymentId);
paymentType = "checkout_session";
checkoutSession = await fetchCheckoutSession(rawPaymentId)
paymentType = 'checkout_session'
paymentIntent =
checkoutSession?.payment_intent &&
typeof checkoutSession.payment_intent === "object"
checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object'
? checkoutSession.payment_intent
: checkoutSession?.payment_intent
? await fetchPaymentIntent(checkoutSession.payment_intent)
: null;
: null
} catch (sessionError) {
console.error("[verifyStripePayment] lookup failure", {
console.error('[verifyStripePayment] lookup failure', {
intentError,
sessionError,
});
throw new HttpsError(
"not-found",
"Aucun paiement Stripe trouvé avec cet identifiant.",
);
})
throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.')
}
}
}
if (!paymentIntent && !checkoutSession) {
throw new HttpsError(
"not-found",
"Aucun paiement Stripe trouvé avec cet identifiant.",
);
throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.')
}
const normalizedIntent = paymentIntent
? normalizePaymentIntent(paymentIntent)
: null;
const normalizedIntent = paymentIntent ? normalizePaymentIntent(paymentIntent) : null
const response = {
type: paymentType,
@@ -594,41 +520,38 @@ const verifyStripePayment = onCall({ region: REGION }, async (request) => {
checkoutSession?.status ??
null,
currency: normalizedIntent?.currency ?? checkoutSession?.currency ?? null,
};
}
const paymentDocId =
paymentType === "checkout_session"
? checkoutSession?.id
: normalizedIntent?.id;
paymentType === 'checkout_session' ? checkoutSession?.id : normalizedIntent?.id
if (paymentDocId) {
const paymentDocRef = paymentsCollection.doc(paymentDocId);
const existing = await paymentDocRef.get();
const paymentDocRef = paymentsCollection.doc(paymentDocId)
const existing = await paymentDocRef.get()
if (existing.exists) {
await paymentDocRef.set(
{
status: response.status,
paymentStatus: checkoutSession?.payment_status,
amountTotal:
checkoutSession?.amount_total ?? normalizedIntent?.amount,
amountTotal: checkoutSession?.amount_total ?? normalizedIntent?.amount,
amountReceived: normalizedIntent?.amount_received,
currency: response.currency,
updatedAt: getServerTimestamp(),
},
{ merge: true },
);
{ merge: true }
)
}
}
return response;
return response
} catch (error) {
console.error("[verifyStripePayment] error", error);
console.error('[verifyStripePayment] error', error)
if (error instanceof HttpsError) {
throw error;
throw error
}
throw mapStripeErrorToHttps(error, "Vérification du paiement impossible.");
throw mapStripeErrorToHttps(error, 'Vérification du paiement impossible.')
}
});
})
module.exports = {
createCheckoutSession,
@@ -636,4 +559,4 @@ module.exports = {
createStripeCustomerPortalSession,
createStripeConnectLoginLink,
verifyStripePayment,
};
}
+50 -62
View File
@@ -1,29 +1,26 @@
const { HttpsError, onCall } = require("firebase-functions/https");
const { HttpsError, onCall } = require('firebase-functions/https')
const { getStripeClient, mapStripeErrorToHttps } = require("../../helpers/stripe");
const { REGION } = require("./config");
const { getStripeClient, mapStripeErrorToHttps } = require('../../helpers/stripe')
const { REGION } = require('./config')
const {
SUBSCRIPTION_PRICE_IDS,
SUBSCRIPTION_PRICE_METADATA,
COIN_PACK_PRODUCTS,
} = require("./constants");
const { parseCoinsPerMonth, formatCoinPack } = require("./shared");
} = require('./constants')
const { parseCoinsPerMonth, formatCoinPack } = require('./shared')
const formatPlan = (price, priceId) => {
if (!price || typeof price !== "object") {
return null;
if (!price || typeof price !== 'object') {
return null
}
const product =
typeof price.product === "object" && price.product !== null
? price.product
: {};
const product = typeof price.product === 'object' && price.product !== null ? price.product : {}
return {
id: price.id || priceId,
priceId: price.id || priceId,
active: price.active !== false,
currency: price.currency || "eur",
currency: price.currency || 'eur',
unitAmount: price.unit_amount,
unitAmountDecimal: price.unit_amount_decimal,
transformQuantity: price.transform_quantity || null,
@@ -36,16 +33,16 @@ const formatPlan = (price, priceId) => {
metadata: price.metadata || {},
product: {
id: product.id || null,
name: product.name || "",
description: product.description || "",
name: product.name || '',
description: product.description || '',
metadata: product.metadata || {},
},
};
};
}
}
const listSubscriptionPlans = onCall({ region: REGION }, async () => {
try {
const stripe = getStripeClient();
const stripe = getStripeClient()
const entries = await Promise.all(
Object.entries(SUBSCRIPTION_PRICE_IDS).map(async ([period, priceIds]) => {
@@ -53,88 +50,79 @@ const listSubscriptionPlans = onCall({ region: REGION }, async () => {
priceIds.map(async (priceId) => {
try {
const price = await stripe.prices.retrieve(priceId, {
expand: ["product"],
});
return formatPlan(price, priceId);
expand: ['product'],
})
return formatPlan(price, priceId)
} catch (error) {
console.error(
`[subscription-listSubscriptionPlans] Impossible de récupérer ${priceId}`,
error?.message || error,
);
return null;
error?.message || error
)
return null
}
}),
);
})
)
return [period, periodPlans.filter(Boolean)];
}),
);
return [period, periodPlans.filter(Boolean)]
})
)
return {
plans: Object.fromEntries(entries),
};
}
} catch (error) {
console.error("[subscription-listSubscriptionPlans] error", error);
throw mapStripeErrorToHttps(
error,
"Impossible de récupérer les abonnements Stripe.",
);
console.error('[subscription-listSubscriptionPlans] error', error)
throw mapStripeErrorToHttps(error, 'Impossible de récupérer les abonnements Stripe.')
}
});
})
const listCoinPacks = onCall({ region: REGION }, async () => {
try {
const stripe = getStripeClient();
const stripe = getStripeClient()
const packs = await Promise.all(
COIN_PACK_PRODUCTS.map(async (pack) => {
try {
const product = await stripe.products.retrieve(pack.productId, {
expand: ["default_price"],
});
expand: ['default_price'],
})
let resolvedPrice = null;
if (typeof product?.default_price === "string") {
resolvedPrice = await stripe.prices.retrieve(product.default_price);
} else if (
product?.default_price &&
typeof product.default_price === "object"
) {
resolvedPrice = product.default_price;
let resolvedPrice = null
if (typeof product?.default_price === 'string') {
resolvedPrice = await stripe.prices.retrieve(product.default_price)
} else if (product?.default_price && typeof product.default_price === 'object') {
resolvedPrice = product.default_price
}
const formatted = formatCoinPack({
product,
price: resolvedPrice,
});
})
return {
...formatted,
coinPackKey: pack.key || null,
};
}
} catch (error) {
console.error(
"[subscription-listCoinPacks] Unable to retrieve product",
'[subscription-listCoinPacks] Unable to retrieve product',
pack.productId,
error?.message || error,
);
return null;
error?.message || error
)
return null
}
}),
);
})
)
return {
packs: packs.filter(Boolean),
};
}
} catch (error) {
console.error("[subscription-listCoinPacks] error", error);
throw mapStripeErrorToHttps(
error,
"Impossible de récupérer les packs de pièces.",
);
console.error('[subscription-listCoinPacks] error', error)
throw mapStripeErrorToHttps(error, 'Impossible de récupérer les packs de pièces.')
}
});
})
module.exports = {
listSubscriptionPlans,
listCoinPacks,
};
}
+221 -262
View File
@@ -1,4 +1,4 @@
const { HttpsError, onCall } = require("firebase-functions/https");
const { HttpsError, onCall } = require('firebase-functions/https')
const {
getStripeClient,
@@ -7,312 +7,271 @@ const {
getReturnUrls,
formatCheckoutSessionResponse,
mapStripeErrorToHttps,
} = require("../../helpers/stripe");
const { REGION } = require("./config");
} = require('../../helpers/stripe')
const { REGION } = require('./config')
const {
ALL_SUBSCRIPTION_PRICE_IDS,
COIN_PACK_PRODUCT_IDS,
COIN_PACK_PRODUCT_MAP,
} = require("./constants");
const {
refsList,
formatCoinPack,
getSubscriptionMetaFromPrice,
} = require("./shared");
} = require('./constants')
const { refsList, formatCoinPack, getSubscriptionMetaFromPrice } = require('./shared')
const CHECKOUT_UI_MODES = new Set(["hosted", "embedded"]);
const CHECKOUT_UI_MODES = new Set(['hosted', 'embedded'])
const sanitizePriceId = (value) => {
if (typeof value !== "string") {
return "";
if (typeof value !== 'string') {
return ''
}
return value.trim();
};
return value.trim()
}
const resolveCheckoutUiMode = (request) => {
if (!request || !request.data || typeof request.data.uiMode === "undefined") {
return "hosted";
if (!request || !request.data || typeof request.data.uiMode === 'undefined') {
return 'hosted'
}
if (typeof request.data.uiMode !== "string") {
if (typeof request.data.uiMode !== 'string') {
throw new HttpsError(
"invalid-argument",
'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").',
);
'invalid-argument',
'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").'
)
}
const normalizedUiMode = request.data.uiMode.trim().toLowerCase();
const normalizedUiMode = request.data.uiMode.trim().toLowerCase()
if (!CHECKOUT_UI_MODES.has(normalizedUiMode)) {
throw new HttpsError(
"invalid-argument",
`uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`,
);
'invalid-argument',
`uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`
)
}
return normalizedUiMode;
};
return normalizedUiMode
}
const withCheckoutNavigationParams = (
baseParams,
{ uiMode, successUrl, cancelUrl },
) => {
if (uiMode === "embedded") {
const withCheckoutNavigationParams = (baseParams, { uiMode, successUrl, cancelUrl }) => {
if (uiMode === 'embedded') {
return {
...baseParams,
ui_mode: "embedded",
ui_mode: 'embedded',
return_url: undefined,
redirect_on_completion: "never",
};
redirect_on_completion: 'never',
}
}
return {
...baseParams,
success_url: successUrl,
cancel_url: cancelUrl,
};
};
}
}
const createSubscriptionCheckoutSession = onCall(
{ region: REGION },
async (request) => {
const createSubscriptionCheckoutSession = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError('unauthenticated', 'Connecte-toi pour souscrire un abonnement.')
}
const rawPriceId = request?.data?.priceId
const priceId = sanitizePriceId(rawPriceId)
if (!priceId) {
throw new HttpsError('invalid-argument', 'Un identifiant de prix Stripe est requis.')
}
if (!ALL_SUBSCRIPTION_PRICE_IDS.includes(priceId)) {
throw new HttpsError(
'invalid-argument',
`L'identifiant de prix ${priceId} n'est pas pris en charge.`
)
}
const stripe = getStripeClient()
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
const { lineItems, summary } = await buildCheckoutLineItems(
[
{
priceID: priceId,
quantity: 1,
isRenewable: true,
},
],
{ stripe }
)
const uiMode = resolveCheckoutUiMode(request)
const shouldProvideReturnUrls = uiMode !== 'embedded'
const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null }
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: true,
})
if (!customerId) {
throw new HttpsError(
'failed-precondition',
'Impossible de retrouver le client Stripe associé.'
)
}
const session = await stripe.checkout.sessions.create(
withCheckoutNavigationParams(
{
mode: 'subscription',
customer: customerId,
line_items: lineItems,
allow_promotion_codes: true,
metadata: {
firebaseUID: uid,
priceId,
purchaseType: 'SUBSCRIPTION',
subscriptionLevel: priceMeta.level || null,
subscriptionBillingPeriod: priceMeta.billingPeriod || null,
},
},
{
uiMode,
successUrl,
cancelUrl,
}
)
)
return formatCheckoutSessionResponse(session)
} catch (error) {
console.error('[subscription-createSubscriptionCheckoutSession] error', error)
if (error instanceof HttpsError) {
throw error
}
throw mapStripeErrorToHttps(error, "Impossible de créer la session d'abonnement Stripe.")
}
})
const createCoinPackCheckoutSession = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError('unauthenticated', 'Connecte-toi pour acheter un pack de pièces.')
}
const rawProductId = request?.data?.productId
const productId = typeof rawProductId === 'string' ? rawProductId.trim() : ''
if (!productId) {
throw new HttpsError('invalid-argument', 'Un identifiant de produit Stripe est requis.')
}
if (!COIN_PACK_PRODUCT_IDS.includes(productId)) {
throw new HttpsError(
'invalid-argument',
`Le produit ${productId} n'est pas un pack de pièces autorisé`
)
}
const stripe = getStripeClient()
const product = await stripe.products.retrieve(productId, {
expand: ['default_price'],
})
let resolvedPrice = null
if (typeof product?.default_price === 'string') {
resolvedPrice = await stripe.prices.retrieve(product.default_price)
} else if (product?.default_price && typeof product.default_price === 'object') {
resolvedPrice = product.default_price
}
let coinPack = null
try {
const uid = request?.auth?.uid;
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour souscrire un abonnement.",
);
}
const rawPriceId = request?.data?.priceId;
const priceId = sanitizePriceId(rawPriceId);
if (!priceId) {
throw new HttpsError(
"invalid-argument",
"Un identifiant de prix Stripe est requis.",
);
}
if (!ALL_SUBSCRIPTION_PRICE_IDS.includes(priceId)) {
throw new HttpsError(
"invalid-argument",
`L'identifiant de prix ${priceId} n'est pas pris en charge.`,
);
}
const stripe = getStripeClient();
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
const { lineItems, summary } = await buildCheckoutLineItems(
[
{
priceID: priceId,
quantity: 1,
isRenewable: true,
},
],
{ stripe },
);
const uiMode = resolveCheckoutUiMode(request);
const shouldProvideReturnUrls = uiMode !== "embedded";
const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null };
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: true,
});
if (!customerId) {
throw new HttpsError(
"failed-precondition",
"Impossible de retrouver le client Stripe associé.",
);
}
const session = await stripe.checkout.sessions.create(
withCheckoutNavigationParams(
{
mode: "subscription",
customer: customerId,
line_items: lineItems,
allow_promotion_codes: true,
metadata: {
firebaseUID: uid,
priceId,
purchaseType: "SUBSCRIPTION",
subscriptionLevel: priceMeta.level || null,
subscriptionBillingPeriod: priceMeta.billingPeriod || null,
},
},
{
uiMode,
successUrl,
cancelUrl,
},
),
);
return formatCheckoutSessionResponse(session);
coinPack = formatCoinPack({
product,
price: resolvedPrice,
})
} catch (error) {
console.error(
"[subscription-createSubscriptionCheckoutSession] error",
error,
);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible de créer la session d'abonnement Stripe.",
);
'[subscription-createCoinPackCheckoutSession] invalid coin pack metadata',
productId,
error?.message || error
)
throw new HttpsError(
'failed-precondition',
'Le pack Stripe est mal configuré (metadata.coins manquant).'
)
}
},
);
const createCoinPackCheckoutSession = onCall(
{ region: REGION },
async (request) => {
try {
const uid = request?.auth?.uid;
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour acheter un pack de pièces.",
);
}
if (!coinPack?.priceId) {
throw new HttpsError(
'failed-precondition',
'Impossible de déterminer le prix Stripe pour ce pack.'
)
}
const rawProductId = request?.data?.productId;
const productId =
typeof rawProductId === "string" ? rawProductId.trim() : "";
const uiMode = resolveCheckoutUiMode(request)
const shouldProvideReturnUrls = uiMode !== 'embedded'
const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null }
if (!productId) {
throw new HttpsError(
"invalid-argument",
"Un identifiant de produit Stripe est requis.",
);
}
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: true,
})
if (!COIN_PACK_PRODUCT_IDS.includes(productId)) {
throw new HttpsError(
"invalid-argument",
`Le produit ${productId} n'est pas un pack de pièces autorisé`,
);
}
if (!customerId) {
throw new HttpsError(
'failed-precondition',
'Impossible de retrouver le client Stripe associé.'
)
}
const stripe = getStripeClient();
const product = await stripe.products.retrieve(productId, {
expand: ["default_price"],
});
let resolvedPrice = null;
if (typeof product?.default_price === "string") {
resolvedPrice = await stripe.prices.retrieve(product.default_price);
} else if (
product?.default_price &&
typeof product.default_price === "object"
) {
resolvedPrice = product.default_price;
}
let coinPack = null;
try {
coinPack = formatCoinPack({
product,
price: resolvedPrice,
});
} catch (error) {
console.error(
"[subscription-createCoinPackCheckoutSession] invalid coin pack metadata",
productId,
error?.message || error,
);
throw new HttpsError(
"failed-precondition",
"Le pack Stripe est mal configuré (metadata.coins manquant).",
);
}
if (!coinPack?.priceId) {
throw new HttpsError(
"failed-precondition",
"Impossible de déterminer le prix Stripe pour ce pack.",
);
}
const uiMode = resolveCheckoutUiMode(request);
const shouldProvideReturnUrls = uiMode !== "embedded";
const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null };
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: true,
});
if (!customerId) {
throw new HttpsError(
"failed-precondition",
"Impossible de retrouver le client Stripe associé.",
);
}
const session = await stripe.checkout.sessions.create(
withCheckoutNavigationParams(
{
mode: "payment",
customer: customerId,
line_items: [
{
price: coinPack.priceId,
quantity: 1,
},
],
allow_promotion_codes: false,
metadata: {
firebaseUID: uid,
purchaseType: "COIN_PACK",
coinPackProductId: coinPack.productId,
coinPackPriceId: coinPack.priceId,
coinAmount: coinPack.coinAmount,
coinPackKey: COIN_PACK_PRODUCT_MAP[productId]?.key || null,
const session = await stripe.checkout.sessions.create(
withCheckoutNavigationParams(
{
mode: 'payment',
customer: customerId,
line_items: [
{
price: coinPack.priceId,
quantity: 1,
},
],
allow_promotion_codes: false,
metadata: {
firebaseUID: uid,
purchaseType: 'COIN_PACK',
coinPackProductId: coinPack.productId,
coinPackPriceId: coinPack.priceId,
coinAmount: coinPack.coinAmount,
coinPackKey: COIN_PACK_PRODUCT_MAP[productId]?.key || null,
},
{
uiMode,
successUrl,
cancelUrl,
},
),
);
},
{
uiMode,
successUrl,
cancelUrl,
}
)
)
return formatCheckoutSessionResponse(session);
} catch (error) {
console.error(
"[subscription-createCoinPackCheckoutSession] error",
error,
);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible de créer la session d'achat de pièces.",
);
return formatCheckoutSessionResponse(session)
} catch (error) {
console.error('[subscription-createCoinPackCheckoutSession] error', error)
if (error instanceof HttpsError) {
throw error
}
},
);
throw mapStripeErrorToHttps(error, "Impossible de créer la session d'achat de pièces.")
}
})
module.exports = {
createSubscriptionCheckoutSession,
createCoinPackCheckoutSession,
resolveCheckoutUiMode,
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
const REGION = process.env.FIREBASE_REGION || "europe-west1";
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
module.exports = {
REGION,
};
}
+33 -43
View File
@@ -1,86 +1,76 @@
const SUBSCRIPTION_PRICE_IDS = {
monthly: [
"price_1SPgitCzf2o5bDRdbnhLFx6f",
"price_1SPgjCCzf2o5bDRdr08Xzp8u",
"price_1SPgjaCzf2o5bDRdd9Xo2u26",
'price_1SPgitCzf2o5bDRdbnhLFx6f',
'price_1SPgjCCzf2o5bDRdr08Xzp8u',
'price_1SPgjaCzf2o5bDRdd9Xo2u26',
],
annual: [
"price_1SPgkDCzf2o5bDRdNGLVNeQ3",
"price_1SPgkXCzf2o5bDRdejBVxEBY",
"price_1SPgkqCzf2o5bDRdIcUwTDrm",
'price_1SPgkDCzf2o5bDRdNGLVNeQ3',
'price_1SPgkXCzf2o5bDRdejBVxEBY',
'price_1SPgkqCzf2o5bDRdIcUwTDrm',
],
};
}
const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat();
const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat()
const SUBSCRIPTION_LEVEL_ALLOWANCES = {
starter: 10,
pro: 40,
premium: 60,
};
}
const SUBSCRIPTION_PRICE_METADATA = {
price_1SPgitCzf2o5bDRdbnhLFx6f: {
level: "starter",
billingPeriod: "monthly",
level: 'starter',
billingPeriod: 'monthly',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
},
price_1SPgjCCzf2o5bDRdr08Xzp8u: {
level: "pro",
billingPeriod: "monthly",
level: 'pro',
billingPeriod: 'monthly',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
},
price_1SPgjaCzf2o5bDRdd9Xo2u26: {
level: "premium",
billingPeriod: "monthly",
level: 'premium',
billingPeriod: 'monthly',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
},
price_1SPgkDCzf2o5bDRdNGLVNeQ3: {
level: "starter",
billingPeriod: "annual",
level: 'starter',
billingPeriod: 'annual',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
},
price_1SPgkXCzf2o5bDRdejBVxEBY: {
level: "pro",
billingPeriod: "annual",
level: 'pro',
billingPeriod: 'annual',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
},
price_1SPgkqCzf2o5bDRdIcUwTDrm: {
level: "premium",
billingPeriod: "annual",
level: 'premium',
billingPeriod: 'annual',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
},
};
}
const COIN_PACK_PRODUCTS = [
{ productId: "prod_TMPPEXZ1wGk2cS", key: "starter" },
{ productId: "prod_TMPQ5SNdS47gY6", key: "pro" },
{ productId: "prod_TMPRpA0qQSHXj5", key: "premium" },
];
{ productId: 'prod_TMPPEXZ1wGk2cS', key: 'starter' },
{ productId: 'prod_TMPQ5SNdS47gY6', key: 'pro' },
{ productId: 'prod_TMPRpA0qQSHXj5', key: 'premium' },
]
const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId);
const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId)
const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce(
(acc, pack) => ({
...acc,
[pack.productId]: pack,
}),
{},
);
{}
)
const PREMIUM_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]);
const CANCELABLE_SUBSCRIPTION_STATUSES = new Set([
"trialing",
"active",
"past_due",
"unpaid",
]);
const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
"trialing",
"active",
"past_due",
"unpaid",
]);
const PREMIUM_SUBSCRIPTION_STATUSES = new Set(['active', 'trialing'])
const CANCELABLE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid'])
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid'])
module.exports = {
SUBSCRIPTION_PRICE_IDS,
@@ -93,4 +83,4 @@ module.exports = {
PREMIUM_SUBSCRIPTION_STATUSES,
CANCELABLE_SUBSCRIPTION_STATUSES,
ACTIVE_SUBSCRIPTION_STATUSES,
};
}
+6 -12
View File
@@ -1,14 +1,8 @@
const { listSubscriptionPlans, listCoinPacks } = require("./catalog");
const {
createSubscriptionCheckoutSession,
createCoinPackCheckoutSession,
} = require("./checkout");
const {
cancelActiveSubscription,
getActiveSubscription,
} = require("./management");
const { handleStripeWebhook } = require("./webhooks");
const { processAnnualSubscriptionAllowances } = require("./schedule");
const { listSubscriptionPlans, listCoinPacks } = require('./catalog')
const { createSubscriptionCheckoutSession, createCoinPackCheckoutSession } = require('./checkout')
const { cancelActiveSubscription, getActiveSubscription } = require('./management')
const { handleStripeWebhook } = require('./webhooks')
const { processAnnualSubscriptionAllowances } = require('./schedule')
module.exports = {
listSubscriptionPlans,
@@ -19,4 +13,4 @@ module.exports = {
createCoinPackCheckoutSession,
handleStripeWebhook,
processAnnualSubscriptionAllowances,
};
}
+66 -97
View File
@@ -1,91 +1,74 @@
const { HttpsError, onCall } = require("firebase-functions/https");
const { HttpsError, onCall } = require('firebase-functions/https')
const { getStripeClient, mapStripeErrorToHttps } = require("../../helpers/stripe");
const { REGION } = require("./config");
const {
CANCELABLE_SUBSCRIPTION_STATUSES,
ACTIVE_SUBSCRIPTION_STATUSES,
} = require("./constants");
const {
refsList,
formatSubscriptionForClient,
resolveUserContext,
} = require("./shared");
const { getStripeClient, mapStripeErrorToHttps } = require('../../helpers/stripe')
const { REGION } = require('./config')
const { CANCELABLE_SUBSCRIPTION_STATUSES, ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants')
const { refsList, formatSubscriptionForClient, resolveUserContext } = require('./shared')
const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour gérer ton abonnement.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour gérer ton abonnement.')
}
const stripe = getStripeClient();
const stripe = getStripeClient()
const userRef = refsList?.users?.doc(uid) || null;
const snapshot = userRef ? await userRef.get() : null;
const userData = snapshot?.exists ? snapshot.data() || {} : {};
const userRef = refsList?.users?.doc(uid) || null
const snapshot = userRef ? await userRef.get() : null
const userData = snapshot?.exists ? snapshot.data() || {} : {}
const inputSubscriptionId =
typeof request?.data?.subscriptionId === "string"
? request.data.subscriptionId.trim()
: "";
typeof request?.data?.subscriptionId === 'string' ? request.data.subscriptionId.trim() : ''
let subscriptionId =
inputSubscriptionId ||
userData?.stripeSubscription?.id ||
userData?.stripeSubscription?.subscriptionId ||
null;
null
const customerId = userData?.stripeCustomerId || null;
const customerId = userData?.stripeCustomerId || null
if (!subscriptionId && customerId) {
try {
const response = await stripe.subscriptions.list({
customer: customerId,
status: "all",
status: 'all',
limit: 5,
});
const { data: subscriptionList = [] } = response || {};
})
const { data: subscriptionList = [] } = response || {}
const activeSubscription = subscriptionList.find(
(candidate) =>
candidate?.status &&
CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status),
);
(candidate) => candidate?.status && CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status)
)
if (activeSubscription?.id) {
subscriptionId = activeSubscription.id;
subscriptionId = activeSubscription.id
}
} catch (error) {
console.warn(
"[subscription-cancelActiveSubscription] Unable to list subscriptions",
'[subscription-cancelActiveSubscription] Unable to list subscriptions',
customerId,
error?.message || error,
);
error?.message || error
)
}
}
if (!subscriptionId) {
throw new HttpsError(
"failed-precondition",
"Aucun abonnement actif à annuler.",
);
throw new HttpsError('failed-precondition', 'Aucun abonnement actif à annuler.')
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
if (!subscription) {
throw new HttpsError("not-found", "Abonnement introuvable côté Stripe.");
throw new HttpsError('not-found', 'Abonnement introuvable côté Stripe.')
}
if (subscription.status === "canceled") {
if (subscription.status === 'canceled') {
return {
subscriptionId: subscription.id,
status: subscription.status,
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
currentPeriodEnd: subscription.current_period_end || null,
alreadyCanceled: true,
};
}
}
if (subscription.cancel_at_period_end === true) {
@@ -95,15 +78,12 @@ const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
cancelAtPeriodEnd: true,
currentPeriodEnd: subscription.current_period_end || null,
alreadyCanceled: false,
};
}
}
const updatedSubscription = await stripe.subscriptions.update(
subscriptionId,
{
cancel_at_period_end: true,
},
);
const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
})
return {
subscriptionId: updatedSubscription.id,
@@ -111,30 +91,24 @@ const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end === true,
currentPeriodEnd: updatedSubscription.current_period_end || null,
alreadyCanceled: false,
};
} catch (error) {
console.error("[subscription-cancelActiveSubscription] error", error);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible d'annuler l'abonnement Stripe.",
);
} catch (error) {
console.error('[subscription-cancelActiveSubscription] error', error)
if (error instanceof HttpsError) {
throw error
}
throw mapStripeErrorToHttps(error, "Impossible d'annuler l'abonnement Stripe.")
}
});
})
const getActiveSubscription = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour récupérer ton abonnement.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour récupérer ton abonnement.')
}
const stripe = getStripeClient();
const stripe = getStripeClient()
const {
uid: resolvedUid,
@@ -143,71 +117,66 @@ const getActiveSubscription = onCall({ region: REGION }, async (request) => {
} = await resolveUserContext({
metadata: request?.data?.metadata || {},
customerId: null,
});
})
const lookupUid = resolvedUid || uid;
const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null;
const snapshot = lookupRef ? await lookupRef.get() : null;
const data = snapshot?.exists ? snapshot.data() || {} : userData || {};
const lookupUid = resolvedUid || uid
const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null
const snapshot = lookupRef ? await lookupRef.get() : null
const data = snapshot?.exists ? snapshot.data() || {} : userData || {}
const subscriptionId =
data?.stripeSubscription?.id ||
data?.stripeSubscription?.subscriptionId ||
null;
const customerId = data?.stripeCustomerId || null;
data?.stripeSubscription?.id || data?.stripeSubscription?.subscriptionId || null
const customerId = data?.stripeCustomerId || null
if (!subscriptionId && !customerId) {
return {
subscription: null,
customerId: null,
};
}
}
if (subscriptionId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ["items.data.price.product"],
});
expand: ['items.data.price.product'],
})
if (subscription) {
return {
subscription: formatSubscriptionForClient(subscription),
customerId: subscription.customer || customerId || null,
};
}
}
}
if (customerId) {
const response = await stripe.subscriptions.list({
customer: customerId,
status: "all",
status: 'all',
limit: 5,
expand: ["data.items.data.price.product"],
});
const [subscription] = response?.data || [];
expand: ['data.items.data.price.product'],
})
const [subscription] = response?.data || []
if (subscription && ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
return {
subscription: formatSubscriptionForClient(subscription),
customerId,
};
}
}
}
return {
subscription: null,
customerId,
};
} catch (error) {
console.error("[subscription-getActiveSubscription] error", error);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible de récupérer l'abonnement Stripe.",
);
} catch (error) {
console.error('[subscription-getActiveSubscription] error', error)
if (error instanceof HttpsError) {
throw error
}
throw mapStripeErrorToHttps(error, "Impossible de récupérer l'abonnement Stripe.")
}
});
})
module.exports = {
cancelActiveSubscription,
getActiveSubscription,
};
}
+74 -89
View File
@@ -1,106 +1,97 @@
const { onSchedule } = require("firebase-functions/v2/scheduler");
const { onSchedule } = require('firebase-functions/v2/scheduler')
const { ORDER_TYPES, createOrderDocument } = require("../helpers/orders");
const { batchFirestore } = require("../../helpers/firebase");
const { BATCH_TYPE } = require("../../config/types");
const {
admin,
refsList,
computeNextGrantTimestamp,
getServerTimestamp,
} = require("./shared");
const { ACTIVE_SUBSCRIPTION_STATUSES } = require("./constants");
const { ORDER_TYPES, createOrderDocument } = require('../helpers/orders')
const { batchFirestore } = require('../../helpers/firebase')
const { BATCH_TYPE } = require('../../config/types')
const { admin, refsList, computeNextGrantTimestamp, getServerTimestamp } = require('./shared')
const { ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants')
// Toggle to stop monthly grants for annual subscriptions while keeping logic handy.
const ENABLE_ANNUAL_GRANT_SCHEDULER = true;
const ENABLE_ANNUAL_GRANT_SCHEDULER = true
const processAnnualSubscriptionAllowances = onSchedule(
{
schedule: "30 3 * * *",
timeZone: "Europe/Paris",
schedule: '30 3 * * *',
timeZone: 'Europe/Paris',
},
async () => {
if (!ENABLE_ANNUAL_GRANT_SCHEDULER) {
console.log(
"[subscription-processAnnualSubscriptionAllowances] skipped (disabled)",
);
return;
console.log('[subscription-processAnnualSubscriptionAllowances] skipped (disabled)')
return
}
const nowTimestamp = admin.firestore.Timestamp.now();
const pageSize = 200;
let lastDoc = null;
let processedUsers = 0;
let grantsCreated = 0;
let docsToUpdate = [];
const nowTimestamp = admin.firestore.Timestamp.now()
const pageSize = 200
let lastDoc = null
let processedUsers = 0
let grantsCreated = 0
let docsToUpdate = []
const flushUpdates = async () => {
if (!docsToUpdate.length) {
return;
return
}
await batchFirestore({
docs: docsToUpdate,
type: BATCH_TYPE.UPDATE,
});
docsToUpdate = [];
};
})
docsToUpdate = []
}
try {
while (true) {
let query = refsList.users
.where("premiumBillingPeriod", "==", "annual")
.where("subscriptionGrantInterval", "==", "monthly")
.where("subscriptionNextGrantAt", "<=", nowTimestamp)
.orderBy("subscriptionNextGrantAt")
.limit(pageSize);
.where('premiumBillingPeriod', '==', 'annual')
.where('subscriptionGrantInterval', '==', 'monthly')
.where('subscriptionNextGrantAt', '<=', nowTimestamp)
.orderBy('subscriptionNextGrantAt')
.limit(pageSize)
if (lastDoc) {
query = query.startAfter(lastDoc);
query = query.startAfter(lastDoc)
}
const snapshot = await query.get();
const snapshot = await query.get()
if (snapshot.empty) {
break;
break
}
for (const doc of snapshot.docs) {
processedUsers += 1;
const data = doc.data() || {};
processedUsers += 1
const data = doc.data() || {}
const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0);
const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0)
if (!Number.isFinite(coinsPerMonth) || coinsPerMonth <= 0) {
continue;
continue
}
const status =
typeof data.stripeSubscriptionStatus === "string"
typeof data.stripeSubscriptionStatus === 'string'
? data.stripeSubscriptionStatus.toLowerCase()
: null;
: null
if (status && !ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
continue;
continue
}
const nextGrantAt = data.subscriptionNextGrantAt;
if (!nextGrantAt || typeof nextGrantAt.toDate !== "function") {
continue;
const nextGrantAt = data.subscriptionNextGrantAt
if (!nextGrantAt || typeof nextGrantAt.toDate !== 'function') {
continue
}
const nextGrantDate = nextGrantAt.toDate();
const nextGrantDate = nextGrantAt.toDate()
if (!nextGrantDate || nextGrantDate > new Date()) {
continue;
continue
}
const subscriptionInfo =
data.stripeSubscription &&
typeof data.stripeSubscription === "object"
data.stripeSubscription && typeof data.stripeSubscription === 'object'
? data.stripeSubscription
: {};
const subscriptionId =
subscriptionInfo.id || data.stripeSubscriptionId || null;
: {}
const subscriptionId = subscriptionInfo.id || data.stripeSubscriptionId || null
const orderId = subscriptionId
? `subscription_${subscriptionId}_sched_${nextGrantAt.seconds}`
: `subscription_${doc.id}_sched_${nextGrantAt.seconds}`;
: `subscription_${doc.id}_sched_${nextGrantAt.seconds}`
try {
const { orderId: processedOrderId } = await createOrderDocument({
@@ -108,25 +99,25 @@ const processAnnualSubscriptionAllowances = onSchedule(
type: ORDER_TYPES.SUBSCRIPTION,
amount: coinsPerMonth,
metadata: {
source: "STRIPE_SUBSCRIPTION",
schedule: "annual_scheduler",
source: 'STRIPE_SUBSCRIPTION',
schedule: 'annual_scheduler',
subscriptionId,
scheduledGrantAt: nextGrantDate.toISOString(),
},
orderId,
});
})
let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1);
const currentPeriodEnd = subscriptionInfo.currentPeriodEnd;
let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1)
const currentPeriodEnd = subscriptionInfo.currentPeriodEnd
if (
nextGrantTimestamp &&
currentPeriodEnd &&
typeof currentPeriodEnd.toDate === "function"
typeof currentPeriodEnd.toDate === 'function'
) {
const periodEndDate = currentPeriodEnd.toDate();
const nextGrantFutureDate = nextGrantTimestamp.toDate();
const periodEndDate = currentPeriodEnd.toDate()
const nextGrantFutureDate = nextGrantTimestamp.toDate()
if (periodEndDate && nextGrantFutureDate > periodEndDate) {
nextGrantTimestamp = null;
nextGrantTimestamp = null
}
}
@@ -136,53 +127,47 @@ const processAnnualSubscriptionAllowances = onSchedule(
subscriptionLastGrantAt: getServerTimestamp(),
subscriptionLastGrantAmount: coinsPerMonth,
subscriptionLastGrantOrderId: processedOrderId,
subscriptionLastGrantSource: "annual_scheduler",
subscriptionLastGrantSource: 'annual_scheduler',
subscriptionNextGrantAt: nextGrantTimestamp || null,
subscriptionGrantInterval: nextGrantTimestamp ? "monthly" : null,
subscriptionGrantInterval: nextGrantTimestamp ? 'monthly' : null,
},
});
grantsCreated += 1;
})
grantsCreated += 1
if (docsToUpdate.length >= 450) {
await flushUpdates();
await flushUpdates()
}
} catch (error) {
console.error(
"[subscription-processAnnualSubscriptionAllowances] Unable to create order",
'[subscription-processAnnualSubscriptionAllowances] Unable to create order',
{
userId: doc.id,
subscriptionId,
error: error?.message || error,
},
);
}
)
}
}
lastDoc = snapshot.docs[snapshot.docs.length - 1];
lastDoc = snapshot.docs[snapshot.docs.length - 1]
if (snapshot.size < pageSize) {
break;
break
}
}
await flushUpdates();
await flushUpdates()
console.log(
"[subscription-processAnnualSubscriptionAllowances] completed",
{
processedUsers,
grantsCreated,
},
);
console.log('[subscription-processAnnualSubscriptionAllowances] completed', {
processedUsers,
grantsCreated,
})
} catch (error) {
console.error(
"[subscription-processAnnualSubscriptionAllowances] error",
error,
);
throw error;
console.error('[subscription-processAnnualSubscriptionAllowances] error', error)
throw error
}
},
);
}
)
module.exports = {
processAnnualSubscriptionAllowances,
};
}
+148 -171
View File
@@ -1,242 +1,226 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const { refList } = require("../../index");
const { STRIPE_WEBHOOK_SECRET } = require("../../config/keys");
const { refList } = require('../../index')
const { STRIPE_WEBHOOK_SECRET } = require('../../config/keys')
const {
SUBSCRIPTION_LEVEL_ALLOWANCES,
SUBSCRIPTION_PRICE_METADATA,
COIN_PACK_PRODUCT_MAP,
} = require("./constants");
} = require('./constants')
const refsList = refList;
const paymentsCollection = admin.firestore().collection("payments");
let cachedStripeWebhookSecret = null;
const refsList = refList
const paymentsCollection = admin.firestore().collection('payments')
let cachedStripeWebhookSecret = null
const toFiniteNumber = (value) => {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === "string") {
const normalized = value.trim().replace(",", ".");
if (typeof value === 'string') {
const normalized = value.trim().replace(',', '.')
if (!normalized) {
return null;
return null
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
return null;
};
return null
}
const parseCoinsPerMonth = (metadata) => {
if (!metadata || typeof metadata !== "object") {
return null;
if (!metadata || typeof metadata !== 'object') {
return null
}
if (!Object.prototype.hasOwnProperty.call(metadata, "coinsPerMonth")) {
return null;
if (!Object.prototype.hasOwnProperty.call(metadata, 'coinsPerMonth')) {
return null
}
const candidateValue = toFiniteNumber(metadata.coinsPerMonth);
const candidateValue = toFiniteNumber(metadata.coinsPerMonth)
if (candidateValue === null || candidateValue <= 0) {
return null;
return null
}
return Math.round(candidateValue);
};
return Math.round(candidateValue)
}
const parseCoinAmount = (metadata) => {
if (!metadata || typeof metadata !== "object") {
return null;
if (!metadata || typeof metadata !== 'object') {
return null
}
if (!Object.prototype.hasOwnProperty.call(metadata, "coins")) {
return null;
if (!Object.prototype.hasOwnProperty.call(metadata, 'coins')) {
return null
}
const candidateValue = toFiniteNumber(metadata.coins);
const candidateValue = toFiniteNumber(metadata.coins)
if (candidateValue === null || candidateValue <= 0) {
return null;
return null
}
return Math.round(candidateValue);
};
return Math.round(candidateValue)
}
const toDateSafe = (value) => {
if (!value) {
return null;
return null
}
if (value instanceof Date) {
return value;
return value
}
if (typeof value?.toDate === "function") {
if (typeof value?.toDate === 'function') {
try {
return value.toDate();
return value.toDate()
} catch (_error) {
return null;
return null
}
}
if (typeof value === "number" && Number.isFinite(value)) {
if (typeof value === 'number' && Number.isFinite(value)) {
if (value > 1e12) {
return new Date(value);
return new Date(value)
}
return new Date(value * 1000);
return new Date(value * 1000)
}
return null;
};
return null
}
const addMonths = (date, months = 1) => {
if (!(date instanceof Date) || !Number.isFinite(months)) {
return null;
return null
}
const result = new Date(date.getTime());
const initialDay = result.getDate();
result.setMonth(result.getMonth() + months);
const result = new Date(date.getTime())
const initialDay = result.getDate()
result.setMonth(result.getMonth() + months)
if (result.getDate() !== initialDay) {
result.setDate(0);
result.setDate(0)
}
return result;
};
return result
}
const computeNextGrantTimestamp = (base, months = 1) => {
const baseDate = toDateSafe(base);
const baseDate = toDateSafe(base)
if (!baseDate) {
return null;
return null
}
const nextDate = addMonths(baseDate, months);
const nextDate = addMonths(baseDate, months)
if (!nextDate) {
return null;
return null
}
return admin.firestore.Timestamp.fromDate(nextDate);
};
return admin.firestore.Timestamp.fromDate(nextDate)
}
const getServerTimestamp = () => {
if (typeof FieldValue?.serverTimestamp === "function") {
return FieldValue.serverTimestamp();
if (typeof FieldValue?.serverTimestamp === 'function') {
return FieldValue.serverTimestamp()
}
const fallback = admin.firestore?.FieldValue;
if (typeof fallback?.serverTimestamp === "function") {
return fallback.serverTimestamp();
const fallback = admin.firestore?.FieldValue
if (typeof fallback?.serverTimestamp === 'function') {
return fallback.serverTimestamp()
}
throw new Error("Firestore FieldValue.serverTimestamp indisponible.");
};
throw new Error('Firestore FieldValue.serverTimestamp indisponible.')
}
const resolveStripeWebhookSecret = () => {
if (cachedStripeWebhookSecret) {
return cachedStripeWebhookSecret;
return cachedStripeWebhookSecret
}
const envSecret =
typeof process?.env?.STRIPE_WEBHOOK_SECRET === "string"
typeof process?.env?.STRIPE_WEBHOOK_SECRET === 'string'
? process.env.STRIPE_WEBHOOK_SECRET.trim()
: "";
const inlineSecret =
typeof STRIPE_WEBHOOK_SECRET === "string"
? STRIPE_WEBHOOK_SECRET.trim()
: "";
: ''
const inlineSecret = typeof STRIPE_WEBHOOK_SECRET === 'string' ? STRIPE_WEBHOOK_SECRET.trim() : ''
const secret = envSecret || inlineSecret;
const secret = envSecret || inlineSecret
if (!secret) {
throw new Error("STRIPE_WEBHOOK_SECRET not configured");
throw new Error('STRIPE_WEBHOOK_SECRET not configured')
}
cachedStripeWebhookSecret = secret;
return cachedStripeWebhookSecret;
};
cachedStripeWebhookSecret = secret
return cachedStripeWebhookSecret
}
const toFirestoreTimestamp = (unixSeconds) => {
if (typeof unixSeconds !== "number" || !Number.isFinite(unixSeconds)) {
return null;
if (typeof unixSeconds !== 'number' || !Number.isFinite(unixSeconds)) {
return null
}
try {
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000);
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000)
} catch (error) {
console.error("[subscription-toFirestoreTimestamp] Conversion error", unixSeconds, error);
return null;
console.error('[subscription-toFirestoreTimestamp] Conversion error', unixSeconds, error)
return null
}
};
}
const extractFirebaseUid = (metadata) => {
if (!metadata || typeof metadata !== "object") {
return null;
if (!metadata || typeof metadata !== 'object') {
return null
}
const candidates = [
metadata.firebaseUID,
metadata.firebaseUid,
metadata.uid,
metadata.userId,
];
const candidates = [metadata.firebaseUID, metadata.firebaseUid, metadata.uid, metadata.userId]
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
const candidate = candidates[index]
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim()
}
}
return null;
};
return null
}
const formatCoinPack = ({ product, price }) => {
if (!product || typeof product !== "object") {
return null;
if (!product || typeof product !== 'object') {
return null
}
const resolvedPrice =
price ||
(typeof product.default_price === "object" && product.default_price) ||
null;
price || (typeof product.default_price === 'object' && product.default_price) || null
const priceId =
(resolvedPrice && resolvedPrice.id) ||
(typeof product.default_price === "string" ? product.default_price : null);
(typeof product.default_price === 'string' ? product.default_price : null)
const coinAmount = parseCoinAmount(product?.metadata || {});
const coinAmount = parseCoinAmount(product?.metadata || {})
if (coinAmount === null) {
throw new Error(
`[formatCoinPack] Missing metadata.coins on product ${product.id}`,
);
throw new Error(`[formatCoinPack] Missing metadata.coins on product ${product.id}`)
}
return {
productId: product.id,
priceId,
name: product.name || "",
description: product.description || "",
name: product.name || '',
description: product.description || '',
coinAmount,
currency:
resolvedPrice?.currency ||
(typeof resolvedPrice?.currency === "string"
? resolvedPrice.currency.toLowerCase()
: "eur"),
(typeof resolvedPrice?.currency === 'string' ? resolvedPrice.currency.toLowerCase() : 'eur'),
unitAmount: resolvedPrice?.unit_amount ?? null,
metadata: product.metadata || {},
};
};
}
}
const getSubscriptionMetaFromPrice = (priceId) => {
if (typeof priceId !== "string") {
return null;
if (typeof priceId !== 'string') {
return null
}
return SUBSCRIPTION_PRICE_METADATA[priceId] || null;
};
return SUBSCRIPTION_PRICE_METADATA[priceId] || null
}
const buildEventSnapshot = (eventType, entityId) => {
const now = admin.firestore.Timestamp.now();
const now = admin.firestore.Timestamp.now()
return {
eventType: eventType || null,
entityId: entityId || null,
syncedAt: now,
};
};
}
}
const formatSubscriptionForClient = (subscription) => {
if (!subscription || typeof subscription !== "object") {
return null;
if (!subscription || typeof subscription !== 'object') {
return null
}
return {
id: subscription.id,
status: subscription.status,
customer: subscription.customer,
currentPeriodStart: toFirestoreTimestamp(
subscription.current_period_start,
),
currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
created: toFirestoreTimestamp(subscription.created),
@@ -248,12 +232,12 @@ const formatSubscriptionForClient = (subscription) => {
}))
: [],
metadata: subscription.metadata || {},
};
};
}
}
const buildSubscriptionPayload = (subscription) => {
if (!subscription || typeof subscription !== "object") {
return null;
if (!subscription || typeof subscription !== 'object') {
return null
}
const items = Array.isArray(subscription.items?.data)
@@ -264,20 +248,19 @@ const buildSubscriptionPayload = (subscription) => {
quantity: item.quantity || 0,
price: item.price || null,
}))
: [];
: []
const primaryItem = items[0] || null;
const productId = primaryItem?.productId || null;
const priceId = primaryItem?.priceId || null;
const primaryItem = items[0] || null
const productId = primaryItem?.productId || null
const priceId = primaryItem?.priceId || null
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
const resolvedLevel = metadataLevel || priceMeta?.level || null;
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null;
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
const metadataLevel = subscription.metadata?.subscriptionLevel || null
const resolvedLevel = metadataLevel || priceMeta?.level || null
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null
const customerId =
typeof subscription.customer === "string" ? subscription.customer : null;
const customerId = typeof subscription.customer === 'string' ? subscription.customer : null
return {
id: subscription.id,
@@ -289,34 +272,32 @@ const buildSubscriptionPayload = (subscription) => {
billingPeriod: resolvedPeriod,
status: subscription.status || null,
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
currentPeriodStart: toFirestoreTimestamp(
subscription.current_period_start,
),
currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
};
};
}
}
const resolveUserContext = async ({ metadata, customerId }) => {
const firebaseUid = extractFirebaseUid(metadata);
const firebaseUid = extractFirebaseUid(metadata)
if (firebaseUid) {
const userRef = refsList?.users?.doc(firebaseUid) || null;
const userRef = refsList?.users?.doc(firebaseUid) || null
if (userRef) {
try {
const snapshot = await userRef.get();
const snapshot = await userRef.get()
if (snapshot.exists) {
return {
uid: firebaseUid,
userRef,
userData: snapshot.data() || null,
};
}
}
} catch (error) {
console.warn(
"[subscription-resolveUserContext] Unable to read user",
'[subscription-resolveUserContext] Unable to read user',
firebaseUid,
error?.message || error,
);
error?.message || error
)
}
}
}
@@ -324,23 +305,23 @@ const resolveUserContext = async ({ metadata, customerId }) => {
if (customerId) {
try {
const snapshot = await refsList.users
.where("stripeCustomerId", "==", customerId)
.where('stripeCustomerId', '==', customerId)
.limit(1)
.get();
.get()
if (!snapshot.empty) {
const doc = snapshot.docs[0];
const doc = snapshot.docs[0]
return {
uid: doc.id,
userRef: doc.ref,
userData: doc.data() || null,
};
}
}
} catch (error) {
console.warn(
"[subscription-resolveUserContext] Unable to query by customer",
'[subscription-resolveUserContext] Unable to query by customer',
customerId,
error?.message || error,
);
error?.message || error
)
}
}
@@ -348,15 +329,15 @@ const resolveUserContext = async ({ metadata, customerId }) => {
uid: firebaseUid || null,
userRef: null,
userData: null,
};
};
}
}
const upsertPaymentDocument = async (docId, data = {}) => {
if (!docId) {
return null;
return null
}
const docRef = paymentsCollection.doc(docId);
const docRef = paymentsCollection.doc(docId)
try {
await docRef.set(
{
@@ -364,17 +345,13 @@ const upsertPaymentDocument = async (docId, data = {}) => {
updatedAt: getServerTimestamp(),
createdAt: getServerTimestamp(),
},
{ merge: true },
);
{ merge: true }
)
} catch (error) {
console.error(
"[subscription-upsertPaymentDocument] Failed to persist payment",
docId,
error,
);
console.error('[subscription-upsertPaymentDocument] Failed to persist payment', docId, error)
}
return docRef;
};
return docRef
}
module.exports = {
admin,
@@ -398,4 +375,4 @@ module.exports = {
resolveUserContext,
upsertPaymentDocument,
COIN_PACK_PRODUCT_MAP,
};
}
+221 -280
View File
@@ -1,9 +1,9 @@
const { onRequest } = require("firebase-functions/v2/https");
const { HttpsError } = require("firebase-functions/https");
const { onRequest } = require('firebase-functions/v2/https')
const { HttpsError } = require('firebase-functions/https')
const { ORDER_TYPES, createOrderDocument } = require("../helpers/orders");
const { getStripeClient } = require("../../helpers/stripe");
const { REGION } = require("./config");
const { ORDER_TYPES, createOrderDocument } = require('../helpers/orders')
const { getStripeClient } = require('../../helpers/stripe')
const { REGION } = require('./config')
const {
paymentsCollection,
resolveStripeWebhookSecret,
@@ -17,25 +17,21 @@ const {
buildSubscriptionPayload,
resolveUserContext,
upsertPaymentDocument,
} = require("./shared");
const { PREMIUM_SUBSCRIPTION_STATUSES } = require("./constants");
} = require('./shared')
const { PREMIUM_SUBSCRIPTION_STATUSES } = require('./constants')
const handleCheckoutSessionCompleted = async (
session,
event,
{ stripe } = {},
) => {
if (!session || typeof session !== "object") {
return;
const handleCheckoutSessionCompleted = async (session, event, { stripe } = {}) => {
if (!session || typeof session !== 'object') {
return
}
const firebaseUid = extractFirebaseUid(session.metadata);
const firebaseUid = extractFirebaseUid(session.metadata)
const paymentDocRef = await upsertPaymentDocument(session.id, {
userId: firebaseUid || null,
customerId: session.customer || null,
subscriptionId: session.subscription || null,
invoiceId: session.invoice || null,
status: session.status || "completed",
status: session.status || 'completed',
paymentStatus: session.payment_status || null,
mode: session.mode || null,
amountSubtotal: session.amount_subtotal ?? null,
@@ -44,109 +40,103 @@ const handleCheckoutSessionCompleted = async (
metadata: session.metadata || {},
completedAt: toFirestoreTimestamp(session.created),
expiresAt: toFirestoreTimestamp(session.expires_at),
paymentIntentId:
typeof session.payment_intent === "string"
? session.payment_intent
: null,
paymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : null,
lastEventType: event?.type || null,
lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(),
});
})
const { uid, userRef } = await resolveUserContext({
metadata: session.metadata,
customerId: session.customer,
});
})
if (userRef) {
const lastEvent = buildEventSnapshot(event?.type, session.id);
const lastEvent = buildEventSnapshot(event?.type, session.id)
if (firebaseUid) {
lastEvent.uid = firebaseUid;
lastEvent.uid = firebaseUid
}
const userUpdate = {
lastStripeWebhookEvent: lastEvent,
};
}
if (session.customer) {
userUpdate.stripeCustomerId = session.customer;
userUpdate.stripeCustomerId = session.customer
}
if (session.metadata?.subscriptionLevel) {
userUpdate.premiumLevel = session.metadata.subscriptionLevel;
userUpdate.premiumLevel = session.metadata.subscriptionLevel
}
if (session.metadata?.subscriptionBillingPeriod) {
userUpdate.premiumBillingPeriod =
session.metadata.subscriptionBillingPeriod;
userUpdate.premiumBillingPeriod = session.metadata.subscriptionBillingPeriod
}
await userRef.set(userUpdate, { merge: true });
await userRef.set(userUpdate, { merge: true })
}
if (
stripe &&
session.mode === "subscription" &&
typeof session.subscription === "string" &&
session.mode === 'subscription' &&
typeof session.subscription === 'string' &&
session.subscription
) {
try {
const subscription = await stripe.subscriptions.retrieve(
session.subscription,
{ expand: ["items.data.price.product"] },
);
const subscription = await stripe.subscriptions.retrieve(session.subscription, {
expand: ['items.data.price.product'],
})
if (subscription) {
await handleCustomerSubscriptionEvent(subscription, event, { stripe });
await handleCustomerSubscriptionEvent(subscription, event, { stripe })
}
} catch (error) {
console.error(
"[subscription-handleCheckoutSessionCompleted] Unable to sync subscription",
'[subscription-handleCheckoutSessionCompleted] Unable to sync subscription',
session.subscription,
error,
);
error
)
}
}
if (
session.mode === "payment" &&
(session.payment_status === "paid" ||
session.payment_status === "no_payment_required") &&
session.metadata?.purchaseType === "COIN_PACK" &&
session.mode === 'payment' &&
(session.payment_status === 'paid' || session.payment_status === 'no_payment_required') &&
session.metadata?.purchaseType === 'COIN_PACK' &&
userRef
) {
const coinAmountRaw = Number(session.metadata?.coinAmount || 0);
const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0;
const coinAmountRaw = Number(session.metadata?.coinAmount || 0)
const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0
if (coinAmount > 0 && paymentDocRef) {
let paymentSnapshot = null;
let paymentSnapshot = null
try {
paymentSnapshot = await paymentDocRef.get();
paymentSnapshot = await paymentDocRef.get()
} catch (error) {
console.warn(
"[subscription-handleCheckoutSessionCompleted] Unable to read payment doc",
'[subscription-handleCheckoutSessionCompleted] Unable to read payment doc',
session.id,
error?.message || error,
);
error?.message || error
)
}
const alreadyGranted = Boolean(
paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt,
);
paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt
)
if (!alreadyGranted) {
const targetUserId = uid || firebaseUid || userRef.id;
const targetUserId = uid || firebaseUid || userRef.id
await createOrderDocument({
userId: targetUserId,
type: ORDER_TYPES.COINS,
amount: coinAmount,
metadata: {
source: "STRIPE_CHECKOUT",
source: 'STRIPE_CHECKOUT',
paymentId: session.id || null,
coinPackKey: session.metadata?.coinPackKey || null,
},
orderId: `stripe_${session.id}`,
});
})
await paymentDocRef.set(
{
@@ -154,25 +144,21 @@ const handleCheckoutSessionCompleted = async (
coinPackGrantedAmount: coinAmount,
coinPackGrantedKey: session.metadata?.coinPackKey || null,
},
{ merge: true },
);
{ merge: true }
)
}
}
}
};
}
const handleCustomerSubscriptionEvent = async (
subscription,
event,
{ stripe } = {},
) => {
if (!subscription || typeof subscription !== "object") {
return;
const handleCustomerSubscriptionEvent = async (subscription, event, { stripe } = {}) => {
if (!subscription || typeof subscription !== 'object') {
return
}
const subscriptionPayload = buildSubscriptionPayload(subscription);
const subscriptionPayload = buildSubscriptionPayload(subscription)
if (!subscriptionPayload) {
return;
return
}
const {
@@ -182,36 +168,36 @@ const handleCustomerSubscriptionEvent = async (
} = await resolveUserContext({
metadata: subscription.metadata,
customerId: subscription.customer,
});
})
let userData = resolvedUserData || null;
let userData = resolvedUserData || null
if (!userData && userRef) {
try {
const snapshot = await userRef.get();
userData = snapshot.exists ? snapshot.data() || null : null;
const snapshot = await userRef.get()
userData = snapshot.exists ? snapshot.data() || null : null
} catch (error) {
console.warn(
"[subscription-handleCustomerSubscriptionEvent] Unable to read user",
'[subscription-handleCustomerSubscriptionEvent] Unable to read user',
subscription.customer,
error?.message || error,
);
error?.message || error
)
}
}
let resolvedCustomerId = subscriptionPayload?.customerId || null;
let resolvedCustomerId = subscriptionPayload?.customerId || null
if (!resolvedCustomerId && subscription.customer) {
resolvedCustomerId = subscription.customer;
resolvedCustomerId = subscription.customer
}
const fallbackUid = extractFirebaseUid(subscription.metadata);
const resolvedUid = uid || fallbackUid || null;
const fallbackUid = extractFirebaseUid(subscription.metadata)
const resolvedUid = uid || fallbackUid || null
await upsertPaymentDocument(subscription.id, {
userId: resolvedUid,
customerId: resolvedCustomerId,
subscriptionId: subscription.id || null,
status: subscription.status || null,
mode: "subscription",
mode: 'subscription',
priceId: subscriptionPayload?.priceId || null,
productId: subscriptionPayload?.productId || null,
cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null,
@@ -224,76 +210,66 @@ const handleCustomerSubscriptionEvent = async (
lastEventType: event?.type || null,
lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(),
});
})
if (!userRef) {
console.warn(
"[subscription-handleCustomerSubscriptionEvent] User not resolved",
{
subscriptionId: subscription?.id || null,
customerId: subscription?.customer || null,
metadataKeys: Object.keys(subscription?.metadata || {}),
eventType: event?.type || null,
},
);
return;
console.warn('[subscription-handleCustomerSubscriptionEvent] User not resolved', {
subscriptionId: subscription?.id || null,
customerId: subscription?.customer || null,
metadataKeys: Object.keys(subscription?.metadata || {}),
eventType: event?.type || null,
})
return
}
const lastEvent = buildEventSnapshot(event?.type, subscription.id);
const lastEvent = buildEventSnapshot(event?.type, subscription.id)
if (resolvedUid) {
lastEvent.uid = resolvedUid;
lastEvent.uid = resolvedUid
}
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId);
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
const metadataPeriod =
subscription.metadata?.subscriptionBillingPeriod || null;
const resolvedLevel = metadataLevel || priceMeta?.level || null;
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId)
const metadataLevel = subscription.metadata?.subscriptionLevel || null
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null
const resolvedLevel = metadataLevel || priceMeta?.level || null
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null
const isPremium = subscription.status
? PREMIUM_SUBSCRIPTION_STATUSES.has(subscription.status)
: false;
: false
const primaryItem = Array.isArray(subscriptionPayload?.items)
? subscriptionPayload.items[0]
: null;
: null
let coinsPerMonth = null;
let coinsPerMonth = null
const productMetadata =
primaryItem?.price &&
typeof primaryItem.price === "object" &&
typeof primaryItem.price === 'object' &&
primaryItem.price.product &&
typeof primaryItem.price.product === "object"
typeof primaryItem.price.product === 'object'
? primaryItem.price.product.metadata
: null;
: null
coinsPerMonth = parseCoinsPerMonth(productMetadata || {});
coinsPerMonth = parseCoinsPerMonth(productMetadata || {})
if (coinsPerMonth === null && stripe && primaryItem?.price?.id) {
try {
const priceWithProduct = await stripe.prices.retrieve(
primaryItem.price.id,
{ expand: ["product"] },
);
coinsPerMonth = parseCoinsPerMonth(
priceWithProduct?.product?.metadata || {},
);
const priceWithProduct = await stripe.prices.retrieve(primaryItem.price.id, {
expand: ['product'],
})
coinsPerMonth = parseCoinsPerMonth(priceWithProduct?.product?.metadata || {})
} catch (error) {
console.warn(
"[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata",
'[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata',
primaryItem.price.id,
error?.message || error,
);
error?.message || error
)
}
}
let subscriptionNextGrantAt = null;
let subscriptionNextGrantAt = null
if (coinsPerMonth !== null && subscriptionPayload?.currentPeriodEnd) {
subscriptionNextGrantAt = computeNextGrantTimestamp(
subscriptionPayload.currentPeriodEnd,
1,
);
subscriptionNextGrantAt = computeNextGrantTimestamp(subscriptionPayload.currentPeriodEnd, 1)
}
const userUpdate = {
@@ -313,53 +289,46 @@ const handleCustomerSubscriptionEvent = async (
premiumLevel: isPremium ? resolvedLevel : null,
premiumBillingPeriod: isPremium ? resolvedPeriod : null,
subscriptionNextGrantAt,
};
}
if (!isPremium) {
userUpdate.subscriptionNextGrantAt = null;
userUpdate.subscriptionNextGrantAt = null
}
await userRef.set(userUpdate, { merge: true });
};
await userRef.set(userUpdate, { merge: true })
}
const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
if (!invoice || typeof invoice !== "object") {
return;
if (!invoice || typeof invoice !== 'object') {
return
}
const firebaseUid = extractFirebaseUid(invoice.metadata);
const eventType = event?.type || null;
const paymentDocRef = paymentsCollection.doc(invoice.id);
const firebaseUid = extractFirebaseUid(invoice.metadata)
const eventType = event?.type || null
const paymentDocRef = paymentsCollection.doc(invoice.id)
let resolvedSubscriptionId =
typeof invoice.subscription === "string" && invoice.subscription
? invoice.subscription
: null;
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,
typeof line?.subscription === 'string' && line.subscription ? line.subscription : null
)
.find((value) => value)
: null;
: null
if (lineSubscriptionId) {
resolvedSubscriptionId = lineSubscriptionId;
console.log(
"[subscription-handleInvoiceEvent] Subscription resolved from invoice line",
{
invoiceId: invoice?.id || null,
subscriptionId: resolvedSubscriptionId,
},
);
resolvedSubscriptionId = lineSubscriptionId
console.log('[subscription-handleInvoiceEvent] Subscription resolved from invoice line', {
invoiceId: invoice?.id || null,
subscriptionId: resolvedSubscriptionId,
})
}
}
console.log("[subscription-handleInvoiceEvent] Received invoice webhook", {
console.log('[subscription-handleInvoiceEvent] Received invoice webhook', {
eventType,
invoiceId: invoice?.id || null,
subscriptionId: invoice?.subscription || null,
@@ -368,17 +337,14 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
status: invoice?.status || null,
billingReason: invoice?.billing_reason || null,
attemptCount: invoice?.attempt_count ?? null,
});
})
await upsertPaymentDocument(invoice.id, {
userId: firebaseUid || null,
customerId: invoice.customer || null,
subscriptionId: resolvedSubscriptionId,
status: invoice.status || null,
paymentStatus:
eventType === "invoice.payment_failed"
? "failed"
: invoice.status || null,
paymentStatus: eventType === 'invoice.payment_failed' ? 'failed' : invoice.status || null,
amountDue: invoice.amount_due ?? null,
amountPaid: invoice.amount_paid ?? null,
amountRemaining: invoice.amount_remaining ?? null,
@@ -394,8 +360,8 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
lastEventType: eventType,
lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(),
mode: "invoice",
});
mode: 'invoice',
})
const {
uid,
@@ -404,124 +370,109 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
} = await resolveUserContext({
metadata: invoice.metadata,
customerId: invoice.customer,
});
})
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;
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
}
let userData = resolvedUserData || null;
let userData = resolvedUserData || null
if (!userData) {
try {
const snapshot = await userRef.get();
userData = snapshot.exists ? snapshot.data() || null : null;
const snapshot = await userRef.get()
userData = snapshot.exists ? snapshot.data() || null : null
} catch (error) {
console.warn(
"[subscription-handleInvoiceEvent] Unable to read user",
'[subscription-handleInvoiceEvent] Unable to read user',
invoice.customer,
error?.message || error,
);
error?.message || error
)
}
}
const lastEvent = buildEventSnapshot(eventType, invoice.id);
const lastEvent = buildEventSnapshot(eventType, invoice.id)
if (firebaseUid) {
lastEvent.uid = firebaseUid;
lastEvent.uid = firebaseUid
}
const userUpdate = {
lastStripeWebhookEvent: lastEvent,
};
}
const billingReason = invoice.billing_reason || null;
const isInvoicePaid = invoice.status === "paid";
if (
billingReason === "subscription_create" ||
billingReason === "subscription_cycle"
) {
const billingReason = invoice.billing_reason || null
const isInvoicePaid = invoice.status === 'paid'
if (billingReason === 'subscription_create' || billingReason === 'subscription_cycle') {
if (isInvoicePaid && invoice.subscription) {
userUpdate.subscriptionLastInvoiceAt = getServerTimestamp();
userUpdate.subscriptionLastInvoiceAt = getServerTimestamp()
}
}
await userRef.set(userUpdate, { merge: true });
await userRef.set(userUpdate, { merge: true })
const subscriptionLine = Array.isArray(invoice?.lines?.data)
? invoice.lines.data.find(
(line) =>
line &&
typeof line === "object" &&
(line.type === "subscription" || line.price),
(line) => line && typeof line === 'object' && (line.type === 'subscription' || line.price)
)
: null;
: null
const priceId =
typeof subscriptionLine?.price?.id === "string"
typeof subscriptionLine?.price?.id === 'string'
? subscriptionLine.price.id
: typeof subscriptionLine?.price === "string"
: typeof subscriptionLine?.price === 'string'
? subscriptionLine.price
: null;
: null
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
const billingPeriod =
priceMeta.billingPeriod ||
(subscriptionLine?.price?.recurring?.interval === "year"
? "annual"
: subscriptionLine?.price?.recurring?.interval === "month"
? "monthly"
: null);
(subscriptionLine?.price?.recurring?.interval === 'year'
? 'annual'
: subscriptionLine?.price?.recurring?.interval === 'month'
? 'monthly'
: null)
const coinsPerMonth =
priceMeta.coinsPerMonth ??
parseCoinsPerMonth(subscriptionLine?.price?.product?.metadata || {}) ??
null;
null
const coinsToGrant =
billingPeriod === "annual" && typeof coinsPerMonth === "number"
? coinsPerMonth * 12
: null;
billingPeriod === 'annual' && typeof coinsPerMonth === 'number' ? coinsPerMonth * 12 : null
const targetSubscriptionId =
resolvedSubscriptionId ||
(typeof invoice.subscription === "string" ? invoice.subscription : null) ||
(typeof subscriptionLine?.subscription === "string"
? subscriptionLine.subscription
: null);
(typeof invoice.subscription === 'string' ? invoice.subscription : null) ||
(typeof subscriptionLine?.subscription === 'string' ? subscriptionLine.subscription : null)
const shouldGrantUpfront =
isInvoicePaid &&
coinsToGrant &&
(billingReason === "subscription_create" ||
billingReason === "subscription_cycle");
(billingReason === 'subscription_create' || billingReason === 'subscription_cycle')
if (shouldGrantUpfront && targetSubscriptionId) {
let grantSnapshot = null;
let grantSnapshot = null
try {
grantSnapshot = await paymentDocRef.get();
grantSnapshot = await paymentDocRef.get()
} catch (error) {
console.warn(
"[subscription-handleInvoiceEvent] Unable to read payment doc before grant",
'[subscription-handleInvoiceEvent] Unable to read payment doc before grant',
invoice?.id || null,
error?.message || error,
);
error?.message || error
)
}
const alreadyGranted =
grantSnapshot?.exists &&
Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt);
grantSnapshot?.exists && Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt)
if (!alreadyGranted) {
const targetUserId = uid || firebaseUid || userRef.id;
const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}`;
const targetUserId = uid || firebaseUid || userRef.id
const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}`
try {
const { orderId: processedOrderId } = await createOrderDocument({
@@ -529,139 +480,129 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
type: ORDER_TYPES.SUBSCRIPTION,
amount: coinsToGrant,
metadata: {
source: "STRIPE_INVOICE",
source: 'STRIPE_INVOICE',
billingPeriod,
coinsPerMonth,
invoiceId: invoice.id || null,
subscriptionId: targetSubscriptionId,
grantStrategy: "upfront",
grantStrategy: 'upfront',
},
orderId,
});
})
await paymentDocRef.set(
{
subscriptionCoinsGrantedAt: getServerTimestamp(),
subscriptionCoinsGrantAmount: coinsToGrant,
subscriptionCoinsGrantOrderId: processedOrderId,
subscriptionCoinsGrantSource: "invoice_upfront",
subscriptionCoinsGrantSource: 'invoice_upfront',
},
{ merge: true },
);
{ merge: true }
)
await userRef.set(
{
subscriptionLastGrantAt: getServerTimestamp(),
subscriptionLastGrantAmount: coinsToGrant,
subscriptionLastGrantOrderId: processedOrderId,
subscriptionLastGrantSource: "invoice_upfront",
subscriptionLastGrantSource: 'invoice_upfront',
subscriptionNextGrantAt: null,
subscriptionGrantInterval: null,
subscriptionGrantStrategy: "upfront",
subscriptionGrantStrategy: 'upfront',
subscriptionCoinsPerMonth: coinsPerMonth,
},
{ merge: true },
);
{ merge: true }
)
} catch (error) {
console.error(
"[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins",
'[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins',
{
invoiceId: invoice?.id || null,
subscriptionId: targetSubscriptionId,
error: error?.message || error,
},
);
}
)
}
}
}
};
}
const handleStripeWebhookEvent = async ({ event, stripe }) => {
if (!event || typeof event !== "object") {
return;
if (!event || typeof event !== 'object') {
return
}
const eventType = event.type;
const eventType = event.type
switch (eventType) {
case "checkout.session.completed":
case 'checkout.session.completed':
await handleCheckoutSessionCompleted(event.data?.object, event, {
stripe,
});
break;
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
})
break
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await handleCustomerSubscriptionEvent(event.data?.object, event, {
stripe,
});
break;
case "invoice.payment_succeeded":
case "invoice.payment_failed":
case "invoice.finalized":
await handleInvoiceEvent(event.data?.object, event, { stripe });
break;
})
break
case 'invoice.payment_succeeded':
case 'invoice.payment_failed':
case 'invoice.finalized':
await handleInvoiceEvent(event.data?.object, event, { stripe })
break
default:
console.log(
"[subscription-handleStripeWebhookEvent] Unhandled event type",
eventType,
);
console.log('[subscription-handleStripeWebhookEvent] Unhandled event type', eventType)
}
};
}
const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => {
if (req.method !== "POST") {
res.status(405).send("Method Not Allowed");
return;
if (req.method !== 'POST') {
res.status(405).send('Method Not Allowed')
return
}
const signature = req.headers["stripe-signature"];
const signature = req.headers['stripe-signature']
if (!signature) {
res.status(400).send("Missing Stripe signature");
return;
res.status(400).send('Missing Stripe signature')
return
}
let rawBody = req.rawBody;
let rawBody = req.rawBody
if (!rawBody && req.body) {
rawBody = Buffer.from(JSON.stringify(req.body));
rawBody = Buffer.from(JSON.stringify(req.body))
}
if (!rawBody) {
res.status(400).send("Missing request body");
return;
res.status(400).send('Missing request body')
return
}
let stripe = null;
let stripe = null
try {
stripe = getStripeClient();
stripe = getStripeClient()
} catch (error) {
console.error(
"[subscription-handleStripeWebhook] Stripe client error",
error,
);
res.status(500).send("Client Stripe indisponible");
return;
console.error('[subscription-handleStripeWebhook] Stripe client error', error)
res.status(500).send('Client Stripe indisponible')
return
}
try {
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
resolveStripeWebhookSecret(),
);
const event = stripe.webhooks.constructEvent(rawBody, signature, resolveStripeWebhookSecret())
await handleStripeWebhookEvent({ event, stripe });
await handleStripeWebhookEvent({ event, stripe })
res.status(200).send({ received: true });
res.status(200).send({ received: true })
} catch (error) {
console.error("[subscription-handleStripeWebhook] error", error);
console.error('[subscription-handleStripeWebhook] error', error)
if (error instanceof HttpsError) {
res.status(400).send(error.message);
return;
res.status(400).send(error.message)
return
}
res.status(500).send("Erreur lors du traitement du webhook");
res.status(500).send('Erreur lors du traitement du webhook')
}
});
})
module.exports = {
handleStripeWebhook,
};
}
+72 -83
View File
@@ -1,151 +1,140 @@
const { onObjectFinalized } = require("firebase-functions/v2/storage");
const logger = require("firebase-functions/logger");
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const ffmpeg = require("fluent-ffmpeg");
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const { refList } = require("../index");
const { onObjectFinalized } = require('firebase-functions/v2/storage')
const logger = require('firebase-functions/logger')
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const ffmpeg = require('fluent-ffmpeg')
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg')
const fs = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const crypto = require('node:crypto')
const { refList } = require('../index')
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
ffmpeg.setFfmpegPath(ffmpegInstaller.path)
exports.generateVideoThumbnail = onObjectFinalized(
{
region: "europe-west1",
region: 'europe-west1',
timeoutSeconds: 180,
memory: "1GiB",
memory: '1GiB',
cpu: 1,
},
async (event) => {
const file = event.data || {};
const bucketName = file.bucket;
const objectName = file.name || "";
const contentType = file.contentType || "";
const file = event.data || {}
const bucketName = file.bucket
const objectName = file.name || ''
const contentType = file.contentType || ''
// Basic guards + helpful logs for debugging why events might be ignored
if (!bucketName || !objectName) {
logger.info("[Thumbnail] Ignored: missing bucket or object name", {
logger.info('[Thumbnail] Ignored: missing bucket or object name', {
bucketName,
objectName,
contentType,
});
return;
})
return
}
// Avoid processing our own generated thumbnails
if (/_thumb9x16\.jpg$/i.test(objectName)) {
logger.info("[Thumbnail] Ignored: already a thumbnail", { objectName });
return;
logger.info('[Thumbnail] Ignored: already a thumbnail', { objectName })
return
}
// Accept if contentType says it's a video OR fallback to extension-based check
const isVideoContentType =
typeof contentType === "string" && contentType.startsWith("video/");
const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(
objectName.toLowerCase()
);
const isVideoContentType = typeof contentType === 'string' && contentType.startsWith('video/')
const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(objectName.toLowerCase())
if (!isVideoContentType && !isVideoLikeName) {
logger.info("[Thumbnail] Ignored: not a video", {
logger.info('[Thumbnail] Ignored: not a video', {
objectName,
contentType,
});
return;
})
return
}
logger.info("[Thumbnail] Event accepted", {
logger.info('[Thumbnail] Event accepted', {
bucketName,
objectName,
contentType,
});
})
const bucket = admin.storage().bucket(bucketName);
const playbackMatch = objectName.match(
/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i
);
const projectId = playbackMatch ? playbackMatch[1] : null;
const bucket = admin.storage().bucket(bucketName)
const playbackMatch = objectName.match(/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i)
const projectId = playbackMatch ? playbackMatch[1] : null
// Use unique folder under /tmp to avoid name collisions
const tmpDir = path.join(
os.tmpdir(),
`thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`
);
const baseName = path.basename(objectName);
const dirName = path.posix.dirname(objectName);
const localVideoPath = path.join(tmpDir, baseName);
const tmpDir = path.join(os.tmpdir(), `thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`)
const baseName = path.basename(objectName)
const dirName = path.posix.dirname(objectName)
const localVideoPath = path.join(tmpDir, baseName)
const thumbBase = baseName.replace(/\.[^.]+$/, "") + "_thumb9x16.jpg";
const localThumbPath = path.join(tmpDir, thumbBase);
const thumbBase = baseName.replace(/\.[^.]+$/, '') + '_thumb9x16.jpg'
const localThumbPath = path.join(tmpDir, thumbBase)
const remoteThumbPath =
dirName && dirName !== "."
? path.posix.join(dirName, thumbBase)
: thumbBase;
dirName && dirName !== '.' ? path.posix.join(dirName, thumbBase) : thumbBase
try {
await fs.mkdir(tmpDir, { recursive: true });
await fs.mkdir(tmpDir, { recursive: true })
// Télécharger la vidéo depuis le bucket
await bucket.file(objectName).download({ destination: localVideoPath });
await bucket.file(objectName).download({ destination: localVideoPath })
// Extraire 1 frame en 9:16 (1080x1920) de manière robuste
const vfCoverCrop =
"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920";
const vfCoverCrop = 'scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920'
const vfPad =
"scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2";
'scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2'
const extractFrame = (ssSeconds, vf) =>
new Promise((resolve, reject) => {
ffmpeg(localVideoPath)
.inputOptions([`-ss ${ssSeconds}`])
.frames(1)
.outputOptions(["-vf", vf, "-q:v", "2"])
.outputOptions(['-vf', vf, '-q:v', '2'])
.output(localThumbPath)
.on("end", resolve)
.on("error", reject)
.run();
});
.on('end', resolve)
.on('error', reject)
.run()
})
try {
// 1) Essai principal: 1s + cover/crop
await extractFrame(1, vfCoverCrop);
await extractFrame(1, vfCoverCrop)
} catch (e1) {
logger.warn("[Thumbnail] First attempt failed, retrying at 0s", {
logger.warn('[Thumbnail] First attempt failed, retrying at 0s', {
objectName,
error: e1?.message || String(e1),
});
})
try {
// 2) Deuxième essai: 0s + cover/crop (si vidéo très courte)
await extractFrame(0, vfCoverCrop);
await extractFrame(0, vfCoverCrop)
} catch (e2) {
logger.warn("[Thumbnail] Second attempt failed, fallback to pad", {
logger.warn('[Thumbnail] Second attempt failed, fallback to pad', {
objectName,
error: e2?.message || String(e2),
});
})
// 3) Fallback: 0s + pad (aucun crop, bandes latérales si besoin)
await extractFrame(0, vfPad);
await extractFrame(0, vfPad)
}
}
// Upload du thumbnail avec un token de téléchargement public Firebase
const downloadToken = crypto.randomUUID();
const downloadToken = crypto.randomUUID()
await bucket.upload(localThumbPath, {
destination: remoteThumbPath,
metadata: {
contentType: "image/jpeg",
cacheControl: "public, max-age=86400",
contentType: 'image/jpeg',
cacheControl: 'public, max-age=86400',
metadata: {
original: objectName,
aspect: "9:16",
t: "1s",
aspect: '9:16',
t: '1s',
firebaseStorageDownloadTokens: downloadToken,
},
},
});
})
const encodedPath = encodeURIComponent(remoteThumbPath);
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`;
const encodedPath = encodeURIComponent(remoteThumbPath)
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`
if (projectId) {
await refList.projects.doc(projectId).set(
@@ -154,23 +143,23 @@ exports.generateVideoThumbnail = onObjectFinalized(
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
)
}
logger.info("✅ [Thumbnail] Uploaded", {
logger.info('✅ [Thumbnail] Uploaded', {
objectName,
remoteThumbPath,
projectId,
thumbnailUrl,
});
})
} catch (error) {
logger.error("❌ [Thumbnail] Failed", {
logger.error('❌ [Thumbnail] Failed', {
objectName,
error: error?.message || String(error),
});
throw error;
})
throw error
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
await fs.rm(tmpDir, { recursive: true, force: true })
}
}
);
)
+115 -129
View File
@@ -1,35 +1,35 @@
// functions/mergeVideoAndAudio.js (ou dans index.js)
const { onCall, HttpsError } = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const axios = require("axios");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const ffmpeg = require("fluent-ffmpeg");
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
const { Buffer } = require("node:buffer");
const { FieldValue } = require("firebase-admin/firestore");
const { onCall, HttpsError } = require('firebase-functions/v2/https')
const admin = require('firebase-admin')
const logger = require('firebase-functions/logger')
const axios = require('axios')
const fs = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const crypto = require('node:crypto')
const ffmpeg = require('fluent-ffmpeg')
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg')
const { Buffer } = require('node:buffer')
const { FieldValue } = require('firebase-admin/firestore')
if (!admin.apps.length) admin.initializeApp();
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
if (!admin.apps.length) admin.initializeApp()
ffmpeg.setFfmpegPath(ffmpegInstaller.path)
const db = admin.firestore();
const PLAYBACK_CODEC_TAG = "h264-v1";
const db = admin.firestore()
const PLAYBACK_CODEC_TAG = 'h264-v1'
async function downloadToFile(url, destPath) {
if (!/^https?:\/\//i.test(url || "")) {
throw new HttpsError("invalid-argument", `URL non supportée: ${url}`);
if (!/^https?:\/\//i.test(url || '')) {
throw new HttpsError('invalid-argument', `URL non supportée: ${url}`)
}
const res = await axios.get(url, { responseType: "arraybuffer" });
await fs.writeFile(destPath, Buffer.from(res.data));
return res.headers?.["content-type"] || "";
const res = await axios.get(url, { responseType: 'arraybuffer' })
await fs.writeFile(destPath, Buffer.from(res.data))
return res.headers?.['content-type'] || ''
}
const SCALE_FILTER =
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease";
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease"
async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
return new Promise((resolve, reject) => {
@@ -37,92 +37,92 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
.input(videoPath) // 0:v
.input(audioPath) // 1:a
.outputOptions([
"-map",
"0:v:0", // garder la 1re piste vidéo de l'entrée 0
"-map",
"1:a:0", // prendre la 1re piste audio de l'entrée 1
'-map',
'0:v:0', // garder la 1re piste vidéo de l'entrée 0
'-map',
'1:a:0', // prendre la 1re piste audio de l'entrée 1
// Force une sortie H264 1080p max pour compatibilité totale iOS (les WebM VP8/9 posaient problème)
"-vf",
'-vf',
SCALE_FILTER,
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"22",
"-pix_fmt",
"yuv420p",
"-profile:v",
"high",
"-level:v",
"4.1",
"-c:a",
"aac",
"-b:a",
"192k",
"-movflags",
"+faststart",
"-shortest", // couper à la plus courte des 2 sources
"-tag:v",
"avc1",
'-c:v',
'libx264',
'-preset',
'veryfast',
'-crf',
'22',
'-pix_fmt',
'yuv420p',
'-profile:v',
'high',
'-level:v',
'4.1',
'-c:a',
'aac',
'-b:a',
'192k',
'-movflags',
'+faststart',
'-shortest', // couper à la plus courte des 2 sources
'-tag:v',
'avc1',
])
.on("error", reject)
.on("end", resolve)
.save(outPath);
});
.on('error', reject)
.on('end', resolve)
.save(outPath)
})
}
async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "merge-"));
const videoPath = path.join(tmpDir, "video.mp4");
const audioPath = path.join(tmpDir, "audio.mp3");
const outPath = path.join(tmpDir, "output.mp4");
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'merge-'))
const videoPath = path.join(tmpDir, 'video.mp4')
const audioPath = path.join(tmpDir, 'audio.mp3')
const outPath = path.join(tmpDir, 'output.mp4')
try {
logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl });
logger.info('[merge] téléchargement des sources', { videoUrl, audioUrl })
await downloadToFile(videoUrl, videoPath);
await downloadToFile(audioUrl, audioPath);
await downloadToFile(videoUrl, videoPath)
await downloadToFile(audioUrl, audioPath)
logger.info("[merge] transcodage/mux ffmpeg");
await muxAudioIntoVideo({ videoPath, audioPath, outPath });
logger.info('[merge] transcodage/mux ffmpeg')
await muxAudioIntoVideo({ videoPath, audioPath, outPath })
const bucket = admin.storage().bucket();
const downloadToken = crypto.randomUUID();
const bucket = admin.storage().bucket()
const downloadToken = crypto.randomUUID()
await bucket.upload(outPath, {
destination: storagePath,
metadata: {
contentType: "video/mp4",
cacheControl: "public,max-age=86400",
contentType: 'video/mp4',
cacheControl: 'public,max-age=86400',
metadata: { firebaseStorageDownloadTokens: downloadToken },
},
});
})
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
storagePath
)}?alt=media&token=${downloadToken}`;
)}?alt=media&token=${downloadToken}`
logger.info("[merge] upload terminé", { storagePath });
logger.info('[merge] upload terminé', { storagePath })
return {
success: true,
url: fileUrl,
contentType: "video/mp4",
contentType: 'video/mp4',
storagePath,
};
}
} catch (err) {
logger.error("[merge] échec", { error: err?.message || String(err) });
if (err instanceof HttpsError) throw err;
throw new HttpsError("internal", err?.message || "Fusion échouée");
logger.error('[merge] échec', { error: err?.message || String(err) })
if (err instanceof HttpsError) throw err
throw new HttpsError('internal', err?.message || 'Fusion échouée')
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
await fs.rm(tmpDir, { recursive: true, force: true })
}
}
async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
if (!projectId) return;
const docRef = db.collection("projects").doc(projectId);
if (!projectId) return
const docRef = db.collection('projects').doc(projectId)
await docRef.set(
{
playbackCompatibility: {
@@ -132,89 +132,75 @@ async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
},
},
{ merge: true }
);
)
}
exports.mergeVideoAndAudio = onCall(
{ timeoutSeconds: 540, memory: "1GiB" },
{ timeoutSeconds: 540, memory: '1GiB' },
async ({ data = {}, auth }) => {
const uid = auth?.uid;
if (!uid)
throw new HttpsError("unauthenticated", "Authentification requise");
const uid = auth?.uid
if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise')
const { videoUrl, audioUrl, storagePath, projectId } = data || {};
const { videoUrl, audioUrl, storagePath, projectId } = data || {}
if (!videoUrl || !audioUrl || !storagePath) {
throw new HttpsError(
"invalid-argument",
"Requis: { videoUrl, audioUrl, storagePath }"
);
throw new HttpsError('invalid-argument', 'Requis: { videoUrl, audioUrl, storagePath }')
}
const expectedPrefix = `users/${uid}/`;
const expectedPrefix = `users/${uid}/`
if (!storagePath.startsWith(expectedPrefix)) {
throw new HttpsError(
"permission-denied",
`storagePath doit commencer par ${expectedPrefix}`
);
throw new HttpsError('permission-denied', `storagePath doit commencer par ${expectedPrefix}`)
}
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath })
if (projectId) {
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
}
return result;
return result
}
);
)
exports.reencodePlayback = onCall(
{ timeoutSeconds: 540, memory: "1GiB" },
{ timeoutSeconds: 540, memory: '1GiB' },
async ({ data = {}, auth }) => {
const uid = auth?.uid;
if (!uid)
throw new HttpsError("unauthenticated", "Authentification requise");
const uid = auth?.uid
if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise')
const projectId = data?.projectId;
const projectId = data?.projectId
if (!projectId) {
throw new HttpsError(
"invalid-argument",
"Requis: { projectId } pour relancer le transcodage"
);
throw new HttpsError('invalid-argument', 'Requis: { projectId } pour relancer le transcodage')
}
logger.info("[reencodePlayback] request received", {
logger.info('[reencodePlayback] request received', {
projectId,
initiator: uid,
});
const projectRef = db.collection("projects").doc(projectId);
const projectSnap = await projectRef.get();
})
const projectRef = db.collection('projects').doc(projectId)
const projectSnap = await projectRef.get()
if (!projectSnap.exists) {
logger.warn("[reencodePlayback] project not found", {
logger.warn('[reencodePlayback] project not found', {
projectId,
initiator: uid,
});
throw new HttpsError("not-found", "Projet introuvable");
})
throw new HttpsError('not-found', 'Projet introuvable')
}
const project = projectSnap.data() || {};
const videoUrl = project.playbackUrl;
const audioUrl = project.songUrl;
const ownerId = project.userId;
const project = projectSnap.data() || {}
const videoUrl = project.playbackUrl
const audioUrl = project.songUrl
const ownerId = project.userId
if (!videoUrl || !audioUrl || !ownerId) {
logger.warn("[reencodePlayback] missing fields", {
logger.warn('[reencodePlayback] missing fields', {
projectId,
hasPlaybackUrl: !!videoUrl,
hasSongUrl: !!audioUrl,
ownerId,
});
throw new HttpsError(
"failed-precondition",
"playbackUrl, songUrl ou userId manquant"
);
})
throw new HttpsError('failed-precondition', 'playbackUrl, songUrl ou userId manquant')
}
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`;
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath })
await projectRef.set(
{
@@ -222,13 +208,13 @@ exports.reencodePlayback = onCall(
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
logger.info("[reencodePlayback] success", {
)
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
logger.info('[reencodePlayback] success', {
projectId,
initiator: uid,
storagePath,
});
return result;
})
return result
}
);
)
+64 -78
View File
@@ -1,137 +1,123 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const {
onDocumentDeleted,
onDocumentCreated,
} = require("firebase-functions/firestore");
const { refList } = require("../index");
const { ORDER_TYPES, createOrderDocument } = require("./helpers/orders");
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 admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const { onDocumentDeleted, onDocumentCreated } = require('firebase-functions/firestore')
const { refList } = require('../index')
const { ORDER_TYPES, createOrderDocument } = require('./helpers/orders')
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 <musicland@musicland.ai>";
const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
const resendClient = new Resend(RESEND_API_KEY)
const WELCOME_EMAIL_FROM = process.env.RESEND_FROM_EMAIL || 'MusicLand <musicland@musicland.ai>'
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" });
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 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('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 });
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() });
console.log(e)
return res.status(500).json({ success: false, error: e.message || e.toString() })
}
});
})
exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
exports.onUserCreated = onDocumentCreated('users/{userID}', async (event) => {
try {
const {
email = "",
firstName = "",
lastName = "",
} = event?.data?.data() || {};
const { email = '', firstName = '', lastName = '' } = event?.data?.data() || {}
const userId = event?.params?.userID;
const userId = event?.params?.userID
try {
await createOrderDocument({
userId,
type: ORDER_TYPES.GIFT,
amount: 10,
metadata: { reason: "WELCOME_BONUS" },
metadata: { reason: 'WELCOME_BONUS' },
orderId: `welcome_${userId}`,
});
})
} catch (coinError) {
console.warn(
"[users-onUserCreated] Unable to grant welcome coins",
'[users-onUserCreated] Unable to grant welcome coins',
event?.params?.userID,
coinError?.message || coinError,
);
coinError?.message || coinError
)
}
if (email) {
if (!resendClient) {
console.warn("Resend API key not configured; skipping welcome email.");
return;
console.warn('Resend API key not configured; skipping welcome email.')
return
}
try {
console.log(`Sending welcome email to ${email}`);
console.log(`Sending welcome email to ${email}`)
const { data, error } = await resendClient.emails.send({
from: WELCOME_EMAIL_FROM,
to: [email],
subject: WELCOME_EMAIL_SUBJECT,
html: welcomeTemplate({ firstName, lastName }),
});
})
if (error) {
console.log("Failed to send welcome email:", error);
return;
console.log('Failed to send welcome email:', error)
return
}
console.log("Welcome email sent:", data);
console.log('Welcome email sent:', data)
} catch (error) {
console.log("Failed to send welcome email:", error);
console.log('Failed to send welcome email:', error)
}
} else {
throw new Error("User created with empty email");
throw new Error('User created with empty email')
}
} catch (e) {
console.log(e);
console.log(e)
}
});
})
exports.onUserDelete = onDocumentDeleted("users/{userID}", async (event) => {
exports.onUserDelete = onDocumentDeleted('users/{userID}', async (event) => {
try {
const userID = event?.params?.userID;
await clearAllUserData(userID);
const userID = event?.params?.userID
await clearAllUserData(userID)
await deleteFolder(`users/${userID}/`);
await deleteFolder(`users/${userID}/`)
await admin.auth().deleteUser(userID);
console.log(`User ${userID} deleted successfully`);
await admin.auth().deleteUser(userID)
console.log(`User ${userID} deleted successfully`)
} catch (e) {
console.log(e);
console.log(e)
}
});
})
async function clearAllUserData(userID) {
const deleteAll = async (ref, key, operator = "==") => {
const snapshot = await ref.where(key, operator, userID).get();
snapshot.forEach((item) => item.ref.delete());
};
const deleteAll = async (ref, key, operator = '==') => {
const snapshot = await ref.where(key, operator, userID).get()
snapshot.forEach((item) => item.ref.delete())
}
const removeFromArray = async (ref, arrayName) => {
const snapshot = await ref.where(arrayName, "array-contains", userID).get();
const snapshot = await ref.where(arrayName, 'array-contains', userID).get()
snapshot.forEach((item) =>
item.ref.update({
[arrayName]: FieldValue.arrayRemove(userID),
}),
);
};
})
)
}
await deleteAll(refList.projects, "userId");
await deleteAll(refList.playlists, "createdBy");
await deleteAll(refList.projects, 'userId')
await deleteAll(refList.playlists, 'createdBy')
}
+120 -146
View File
@@ -1,34 +1,29 @@
const { onCall, HttpsError } = require("firebase-functions/v2/https");
const { defineSecret } = require("firebase-functions/params");
const logger = require("firebase-functions/logger");
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const axios = require("axios");
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { google } = require("googleapis");
const { onCall, HttpsError } = require('firebase-functions/v2/https')
const { defineSecret } = require('firebase-functions/params')
const logger = require('firebase-functions/logger')
const functions = require('firebase-functions')
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const axios = require('axios')
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const { google } = require('googleapis')
// ---- Secrets déclarés (gen2 + Secret Manager)
const S_YT_CLIENT_ID = defineSecret("YOUTUBE_CLIENT_ID");
const S_YT_CLIENT_SECRET = defineSecret("YOUTUBE_CLIENT_SECRET");
const S_YT_REFRESH_TOKEN = defineSecret("YOUTUBE_REFRESH_TOKEN");
const S_YT_REDIRECT_URI = defineSecret("YOUTUBE_REDIRECT_URI");
const S_YT_PRIVACY_STATUS = defineSecret("YOUTUBE_PRIVACY_STATUS");
const S_YT_CATEGORY_ID = defineSecret("YOUTUBE_CATEGORY_ID");
const S_YT_CLIENT_ID = defineSecret('YOUTUBE_CLIENT_ID')
const S_YT_CLIENT_SECRET = defineSecret('YOUTUBE_CLIENT_SECRET')
const S_YT_REFRESH_TOKEN = defineSecret('YOUTUBE_REFRESH_TOKEN')
const S_YT_REDIRECT_URI = defineSecret('YOUTUBE_REDIRECT_URI')
const S_YT_PRIVACY_STATUS = defineSecret('YOUTUBE_PRIVACY_STATUS')
const S_YT_CATEGORY_ID = defineSecret('YOUTUBE_CATEGORY_ID')
// Firestore
const firestore = admin.firestore();
const projectsRef = firestore.collection("projects");
const firestore = admin.firestore()
const projectsRef = firestore.collection('projects')
const YOUTUBE_IN_PROGRESS_STATUSES = [
"PUBLISHING",
"UPLOADING",
"PROCESSING",
"QUEUED",
];
const YOUTUBE_IN_PROGRESS_STATUSES = ['PUBLISHING', 'UPLOADING', 'PROCESSING', 'QUEUED']
// Lecture des secrets (recommandé en v2)
const getSecretsYoutubeConfig = () =>
@@ -40,44 +35,44 @@ const getSecretsYoutubeConfig = () =>
redirect_uri: S_YT_REDIRECT_URI.value(),
privacy_status: S_YT_PRIVACY_STATUS.value(),
category_id: S_YT_CATEGORY_ID.value(),
}).filter(([, value]) => value !== undefined && value !== "")
);
}).filter(([, value]) => value !== undefined && value !== '')
)
// Compat facultative v1 -> renverra {} en v2 (et on log un warn propre)
const getLegacyYoutubeConfig = () => {
if (typeof functions.config !== "function") {
return {};
if (typeof functions.config !== 'function') {
return {}
}
try {
return functions.config()?.youtube || {};
return functions.config()?.youtube || {}
} catch (error) {
if (
typeof error?.message === "string" &&
error.message.includes("functions.config() is no longer available")
typeof error?.message === 'string' &&
error.message.includes('functions.config() is no longer available')
) {
logger.warn(
"[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)"
);
return {};
'[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)'
)
return {}
}
throw error;
throw error
}
};
}
const ensureYoutubeConfig = () => {
// Fusionne (par prudence) lancienne config et les secrets actuels
const firebaseConfig = getLegacyYoutubeConfig();
const secretConfig = getSecretsYoutubeConfig();
const cfg = { ...firebaseConfig, ...secretConfig };
const firebaseConfig = getLegacyYoutubeConfig()
const secretConfig = getSecretsYoutubeConfig()
const cfg = { ...firebaseConfig, ...secretConfig }
const requiredKeys = ["client_id", "client_secret", "refresh_token"];
const missing = requiredKeys.filter((key) => !cfg[key]);
const requiredKeys = ['client_id', 'client_secret', 'refresh_token']
const missing = requiredKeys.filter((key) => !cfg[key])
if (missing.length) {
throw new HttpsError(
"failed-precondition",
`Configuration YouTube manquante: ${missing.join(", ")}`
);
'failed-precondition',
`Configuration YouTube manquante: ${missing.join(', ')}`
)
}
return {
@@ -87,63 +82,54 @@ const ensureYoutubeConfig = () => {
redirectUri: cfg.redirect_uri,
defaultPrivacyStatus: cfg.privacy_status,
defaultCategoryId: cfg.category_id,
};
};
}
}
const createYoutubeClient = ({
clientId,
clientSecret,
refreshToken,
redirectUri,
}) => {
const oauth2Client = new google.auth.OAuth2(
clientId,
clientSecret,
redirectUri
);
oauth2Client.setCredentials({ refresh_token: refreshToken });
const createYoutubeClient = ({ clientId, clientSecret, refreshToken, redirectUri }) => {
const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri)
oauth2Client.setCredentials({ refresh_token: refreshToken })
const youtube = google.youtube({
version: "v3",
version: 'v3',
auth: oauth2Client,
});
return { youtube, oauth2Client };
};
})
return { youtube, oauth2Client }
}
const downloadFile = async (url, destinationPath) => {
if (!/^https?:\/\//i.test(url || "")) {
throw new HttpsError("invalid-argument", `URL non valide: ${url}`);
if (!/^https?:\/\//i.test(url || '')) {
throw new HttpsError('invalid-argument', `URL non valide: ${url}`)
}
await fsp.mkdir(path.dirname(destinationPath), { recursive: true });
await fsp.mkdir(path.dirname(destinationPath), { recursive: true })
const response = await axios.get(url, { responseType: "stream" });
const response = await axios.get(url, { responseType: 'stream' })
await new Promise((resolve, reject) => {
const writer = fs.createWriteStream(destinationPath);
response.data.pipe(writer);
writer.on("finish", resolve);
writer.on("error", reject);
});
const writer = fs.createWriteStream(destinationPath)
response.data.pipe(writer)
writer.on('finish', resolve)
writer.on('error', reject)
})
return destinationPath;
};
return destinationPath
}
const buildVideoMetadata = (project, defaults) => {
const baseTitle = project?.title || "Création MusicLand";
const youtubeTitle = `${baseTitle} | MusicLand`;
const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`;
const baseTitle = project?.title || 'Création MusicLand'
const youtubeTitle = `${baseTitle} | MusicLand`
const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`
const tags = Array.isArray(project?.youtubeTags)
? project.youtubeTags.filter(Boolean).slice(0, 500)
: undefined;
: undefined
const snippet = {
title: youtubeTitle,
description,
categoryId: defaults.defaultCategoryId,
};
}
if (tags && tags.length) {
snippet.tags = tags;
snippet.tags = tags
}
return {
@@ -153,17 +139,17 @@ const buildVideoMetadata = (project, defaults) => {
embeddable: true,
selfDeclaredMadeForKids: false,
},
};
};
}
}
exports.publishPlaybackToYoutube = onCall(
{
timeoutSeconds: 540,
memory: "1GiB",
memory: '1GiB',
cors: [
"http://localhost:8081",
"https://musicland-one.vercel.app/",
"https://musicland-d33f9.firebaseapp.com",
'http://localhost:8081',
'https://musicland-one.vercel.app/',
'https://musicland-d33f9.firebaseapp.com',
],
// Secrets requis pour lexécution (v2)
secrets: [
@@ -176,98 +162,86 @@ exports.publishPlaybackToYoutube = onCall(
],
},
async ({ data = {}, auth }) => {
const uid = auth?.uid;
const uid = auth?.uid
if (!uid) {
throw new HttpsError("unauthenticated", "Authentification requise");
throw new HttpsError('unauthenticated', 'Authentification requise')
}
const projectId = data?.projectId;
if (!projectId || typeof projectId !== "string") {
throw new HttpsError("invalid-argument", "Paramètre projectId requis");
const projectId = data?.projectId
if (!projectId || typeof projectId !== 'string') {
throw new HttpsError('invalid-argument', 'Paramètre projectId requis')
}
const projectSnap = await projectsRef.doc(projectId).get();
const projectSnap = await projectsRef.doc(projectId).get()
if (!projectSnap.exists) {
throw new HttpsError("not-found", "Projet introuvable");
throw new HttpsError('not-found', 'Projet introuvable')
}
const project = projectSnap.data();
const project = projectSnap.data()
if (!project || project.userId !== uid) {
throw new HttpsError(
"permission-denied",
"Vous n'avez pas les droits sur ce projet"
);
throw new HttpsError('permission-denied', "Vous n'avez pas les droits sur ce projet")
}
if (!project.playbackUrl) {
throw new HttpsError(
"failed-precondition",
"Aucun playback disponible pour la publication"
);
throw new HttpsError('failed-precondition', 'Aucun playback disponible pour la publication')
}
if (
project.youtubeStatus &&
YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)
) {
if (project.youtubeStatus && YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)) {
throw new HttpsError(
"failed-precondition",
"Une publication est déjà en cours pour ce projet"
);
'failed-precondition',
'Une publication est déjà en cours pour ce projet'
)
}
let youtubeDefaults;
let youtubeClient;
let youtubeDefaults
let youtubeClient
try {
youtubeDefaults = ensureYoutubeConfig();
youtubeClient = createYoutubeClient(youtubeDefaults);
await youtubeClient.oauth2Client.getAccessToken();
youtubeDefaults = ensureYoutubeConfig()
youtubeClient = createYoutubeClient(youtubeDefaults)
await youtubeClient.oauth2Client.getAccessToken()
} catch (error) {
logger.error("[publishPlaybackToYoutube] configuration invalide", {
logger.error('[publishPlaybackToYoutube] configuration invalide', {
error: error?.message,
});
throw new HttpsError(
"failed-precondition",
"Configuration YouTube invalide ou incomplète"
);
})
throw new HttpsError('failed-precondition', 'Configuration YouTube invalide ou incomplète')
}
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "yt-upload-"));
const videoPath = path.join(tmpDir, "playback.mp4");
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'yt-upload-'))
const videoPath = path.join(tmpDir, 'playback.mp4')
try {
await projectsRef.doc(projectId).set(
{
youtubeStatus: "PUBLISHING",
youtubeStatus: 'PUBLISHING',
youtubePublished: false,
youtubeError: null,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
)
await downloadFile(project.playbackUrl, videoPath);
await downloadFile(project.playbackUrl, videoPath)
const metadata = buildVideoMetadata(project, youtubeDefaults);
const metadata = buildVideoMetadata(project, youtubeDefaults)
const uploadResponse = await youtubeClient.youtube.videos.insert({
part: ["snippet", "status"].join(","),
part: ['snippet', 'status'].join(','),
requestBody: metadata,
media: {
body: fs.createReadStream(videoPath),
},
});
})
const videoId = uploadResponse?.data?.id;
const videoId = uploadResponse?.data?.id
if (!videoId) {
throw new Error("ID de vidéo introuvable dans la réponse YouTube");
throw new Error('ID de vidéo introuvable dans la réponse YouTube')
}
const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`;
const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`
await projectsRef.doc(projectId).set(
{
youtubeStatus: "PUBLISHED",
youtubeStatus: 'PUBLISHED',
youtubePublished: true,
youtubeUrl: youtubeLink,
youtubeVideoId: videoId,
@@ -276,45 +250,45 @@ exports.publishPlaybackToYoutube = onCall(
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
)
logger.info("[publishPlaybackToYoutube] publication réussie", {
logger.info('[publishPlaybackToYoutube] publication réussie', {
projectId,
videoId,
});
})
return {
videoId,
youtubeUrl: youtubeLink,
};
}
} catch (error) {
logger.error("[publishPlaybackToYoutube] échec de publication", {
logger.error('[publishPlaybackToYoutube] échec de publication', {
projectId,
error: error?.message,
});
})
const errorMessage =
error instanceof HttpsError
? error.message
: error?.message || "Publication YouTube échouée";
: error?.message || 'Publication YouTube échouée'
await projectsRef.doc(projectId).set(
{
youtubeStatus: "FAILED",
youtubeStatus: 'FAILED',
youtubePublished: false,
youtubeError: errorMessage,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
)
if (error instanceof HttpsError) {
throw error;
throw error
}
throw new HttpsError("internal", errorMessage);
throw new HttpsError('internal', errorMessage)
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true });
await fsp.rm(tmpDir, { recursive: true, force: true })
}
}
);
)
+5 -5
View File
@@ -1,5 +1,5 @@
import { registerRootComponent } from "expo";
import App from "./App";
import { registerRootComponent } from 'expo'
import App from './App'
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
@@ -8,10 +8,10 @@ import App from "./App";
function HeadlessCheck({ isHeadless }) {
if (isHeadless) {
// App has been launched in the background by iOS, ignore
return null;
return null
}
return <App />;
return <App />
}
registerRootComponent(App, () => HeadlessCheck);
registerRootComponent(App, () => HeadlessCheck)
+19 -19
View File
@@ -1,37 +1,37 @@
import "@expo/metro-runtime";
import { registerRootComponent } from "expo";
import '@expo/metro-runtime'
import { registerRootComponent } from 'expo'
import App from "./App";
import App from './App'
// 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");
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");
const style = document.createElement('style')
style.setAttribute('data-inline-global', 'true')
style.innerHTML = `
html, body, #root { height: 100%; }
body { margin: 0; }
*:focus { outline: none; }
* { scrollbar-width: none; -ms-overflow-style: none; }
*::-webkit-scrollbar { display: none; }
`;
document.head.appendChild(style);
`
document.head.appendChild(style)
const proto = window.HTMLElement && window.HTMLElement.prototype;
if (proto && typeof proto.setNativeProps !== "function") {
const proto = window.HTMLElement && window.HTMLElement.prototype
if (proto && typeof proto.setNativeProps !== 'function') {
proto.setNativeProps = function (nativeProps = {}) {
try {
const { style: s, pointerEvents, ...rest } = nativeProps || {};
const { style: s, pointerEvents, ...rest } = nativeProps || {}
if (pointerEvents != null) {
this.style.pointerEvents = pointerEvents;
this.style.pointerEvents = pointerEvents
}
if (s && typeof s === "object") {
if (s && typeof s === 'object') {
for (const k in s) {
if (Object.prototype.hasOwnProperty.call(s, k)) {
try {
this.style[k] = s[k];
this.style[k] = s[k]
} catch {}
}
}
@@ -39,13 +39,13 @@ if (typeof document !== "undefined") {
for (const k in rest) {
if (Object.prototype.hasOwnProperty.call(rest, k)) {
try {
this.style[k] = rest[k];
this.style[k] = rest[k]
} catch {}
}
}
} catch {}
};
}
}
}
registerRootComponent(App);
registerRootComponent(App)
+8 -8
View File
@@ -1,18 +1,18 @@
const { getDefaultConfig } = require("@expo/metro-config");
const { getDefaultConfig } = require('@expo/metro-config')
const config = getDefaultConfig(__dirname);
const config = getDefaultConfig(__dirname)
// Vérifier que .js est bien présent
if (!config.resolver.sourceExts.includes("js")) {
config.resolver.sourceExts.push("js");
if (!config.resolver.sourceExts.includes('js')) {
config.resolver.sourceExts.push('js')
}
// Ajouter ce dont tu as besoin
config.resolver.sourceExts.push("cjs", "mjs");
config.resolver.sourceExts.push('cjs', 'mjs')
// Assurer que la plateforme web est bien prise en compte en priorité
config.resolver.platforms = Array.from(
new Set(["web", ...((config.resolver && config.resolver.platforms) || [])])
);
new Set(['web', ...((config.resolver && config.resolver.platforms) || [])])
)
module.exports = config;
module.exports = config
+2 -1
View File
@@ -5,6 +5,7 @@
"start": "expo start --dev-client",
"android": "expo run:android",
"ios": "expo run:ios -d",
"format": "prettier --write \"src/**/*.js\"",
"web": "expo start --web",
"xcode": "open ios/musicland.xcworkspace",
"clean": "npx expo prebuild --clean",
@@ -111,7 +112,7 @@
"imagemin-jpegtran": "~7.0.0",
"imagemin-optipng": "~8.0.0",
"imagemin-svgo": "~11.0.1",
"prettier": "~3.2.5",
"prettier": "^3.7.4",
"typescript": "~5.3.3"
},
"resolutions": {
+7 -7
View File
@@ -1,18 +1,18 @@
import moment from 'moment';
import moment from 'moment'
export const validateDate = (date = null) => {
if (!date) {
return null;
return null
}
if (moment(date).isValid()) {
return date;
return date
}
if (typeof date?.toDate === 'function') {
return date?.toDate();
return date?.toDate()
}
return null;
};
return null
}
export function getAge(birthDate) {
return moment().diff(birthDate.toDate(), 'years', false);
return moment().diff(birthDate.toDate(), 'years', false)
}
+12 -12
View File
@@ -2,7 +2,7 @@ import {
responsiveHeight as _responsiveHeight,
responsiveWidth as _responsiveWidth,
responsiveFontSize as _responsiveFontSize,
} from "react-native-responsive-dimensions";
} from 'react-native-responsive-dimensions'
import {
isWeb,
@@ -10,31 +10,31 @@ import {
isLargeDesktop,
isSmallDesktop,
isSuperSmallDesktop,
} from "../hooks/useLayoutType";
} from '../hooks/useLayoutType'
export const reductionCoeff = (bypass) =>
bypass ? 1 : isLargeDesktop ? 3 : isSmallDesktop ? 2.2 : isDesktop ? 2.8 : 1;
bypass ? 1 : isLargeDesktop ? 3 : isSmallDesktop ? 2.2 : isDesktop ? 2.8 : 1
export const responsiveHeight = (height, bypass = false) => {
return _responsiveHeight(height) / reductionCoeff(bypass);
};
return _responsiveHeight(height) / reductionCoeff(bypass)
}
export const responsiveWidth = (width, bypass = false) => {
return _responsiveWidth(width) / reductionCoeff(bypass);
};
return _responsiveWidth(width) / reductionCoeff(bypass)
}
export const responsiveFontSize = (fontSize, bypass = false) => {
if (isWeb) {
if (isSuperSmallDesktop) {
return fontSize * 6;
return fontSize * 6
} else if (isSmallDesktop) {
return fontSize * 7;
return fontSize * 7
} else {
return fontSize * 7.5;
return fontSize * 7.5
}
} else {
return _responsiveFontSize(fontSize) / reductionCoeff(bypass);
return _responsiveFontSize(fontSize) / reductionCoeff(bypass)
}
};
}
// test
+59 -63
View File
@@ -1,86 +1,82 @@
export function checkIfEmailIsValid({ email }) {
let regex = new RegExp(
"([!#-'*+/-9=?A-Z^-~-]+(.[!#-'*+/-9=?A-Z^-~-]+)*|\"([]!#-[^-~ \t]|(\\[\t -~]))+\")@([!#-'*+/-9=?A-Z^-~-]+(.[!#-'*+/-9=?A-Z^-~-]+)*|[[\t -Z^-~]*])"
);
return regex.test(email);
)
return regex.test(email)
}
export function checkIfPasswordIsStrongEnough({ password }) {
const reg =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{6,})/;
return reg.test(password);
const reg = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{6,})/
return reg.test(password)
}
export const formatPhoneNumber = ({ phoneNumber = null }) => {
if (!phoneNumber) {
return null;
return null
}
let newPhoneNumber = phoneNumber;
let newPhoneNumber = phoneNumber
newPhoneNumber = newPhoneNumber
.trim()
.replace(/\s+/g, "") // remove spaces
.replace(/\D/g, ""); // remove non digits
.replace(/\s+/g, '') // remove spaces
.replace(/\D/g, '') // remove non digits
if (newPhoneNumber.startsWith("330")) {
newPhoneNumber = newPhoneNumber.substring(2);
if (newPhoneNumber.startsWith('330')) {
newPhoneNumber = newPhoneNumber.substring(2)
}
if (newPhoneNumber.startsWith("06") || newPhoneNumber.startsWith("07")) {
newPhoneNumber = `+33${newPhoneNumber.slice(1)}`;
} else if (
newPhoneNumber.startsWith("336") ||
newPhoneNumber.startsWith("337")
) {
newPhoneNumber = `+${newPhoneNumber}`;
if (newPhoneNumber.startsWith('06') || newPhoneNumber.startsWith('07')) {
newPhoneNumber = `+33${newPhoneNumber.slice(1)}`
} else if (newPhoneNumber.startsWith('336') || newPhoneNumber.startsWith('337')) {
newPhoneNumber = `+${newPhoneNumber}`
} else {
newPhoneNumber = null;
newPhoneNumber = null
}
return newPhoneNumber;
};
return newPhoneNumber
}
export function handleFirebaseError(code = "") {
export function handleFirebaseError(code = '') {
switch (code) {
case "auth/user-not-found":
return "Ce compte n'existe pas.";
case "auth/user-disabled":
return "Ce compte est désactivé. Contacte le support si besoin.";
case "auth/invalid-verification-code":
return "Ton code de validation est incorrect.";
case "auth/provider-already-linked":
return "Ce compte est déjà lié à un utilisateur.";
case "auth/invalid-credential":
case "auth/invalid-login-credential":
case "auth/invalid-login-credentials":
return "Identifiants incorrects.";
case "auth/credential-already-in-use":
return "Ce compte existe déjà ou est déjà lié.";
case "auth/operation-not-allowed":
return "Le fournisseur d'identité n'est pas disponible.";
case "auth/invalid-email":
return "Adresse e-mail invalide.";
case "auth/wrong-password":
return "Mot de passe incorrect.";
case "auth/invalid-verification-id":
return "Impossible de t'authentifier, réessaie dans quelques secondes.";
case "auth/invalid-phone-number":
return "Numéro de téléphone incorrect.";
case "auth/too-many-requests":
return "Trop de tentatives. Réessaie dans quelques minutes.";
case "auth/email-already-in-use":
return "Un compte avec cette adresse mail existe déjà.";
case "auth/missing-password":
return "Renseigne ton mot de passe.";
case "auth/weak-password":
return "Ton mot de passe est trop faible.";
case "auth/network-request-failed":
return "Problème de connexion réseau. Vérifie ta connexion et réessaie.";
case "auth/invalid-action-code":
case "auth/expired-action-code":
case "auth/missing-oob-code":
return "Ce lien de réinitialisation n'est plus valide. Demande un nouveau mot de passe.";
case "auth/missing-email":
return "Renseigne ton adresse e-mail.";
case 'auth/user-not-found':
return "Ce compte n'existe pas."
case 'auth/user-disabled':
return 'Ce compte est désactivé. Contacte le support si besoin.'
case 'auth/invalid-verification-code':
return 'Ton code de validation est incorrect.'
case 'auth/provider-already-linked':
return 'Ce compte est déjà lié à un utilisateur.'
case 'auth/invalid-credential':
case 'auth/invalid-login-credential':
case 'auth/invalid-login-credentials':
return 'Identifiants incorrects.'
case 'auth/credential-already-in-use':
return 'Ce compte existe déjà ou est déjà lié.'
case 'auth/operation-not-allowed':
return "Le fournisseur d'identité n'est pas disponible."
case 'auth/invalid-email':
return 'Adresse e-mail invalide.'
case 'auth/wrong-password':
return 'Mot de passe incorrect.'
case 'auth/invalid-verification-id':
return "Impossible de t'authentifier, réessaie dans quelques secondes."
case 'auth/invalid-phone-number':
return 'Numéro de téléphone incorrect.'
case 'auth/too-many-requests':
return 'Trop de tentatives. Réessaie dans quelques minutes.'
case 'auth/email-already-in-use':
return 'Un compte avec cette adresse mail existe déjà.'
case 'auth/missing-password':
return 'Renseigne ton mot de passe.'
case 'auth/weak-password':
return 'Ton mot de passe est trop faible.'
case 'auth/network-request-failed':
return 'Problème de connexion réseau. Vérifie ta connexion et réessaie.'
case 'auth/invalid-action-code':
case 'auth/expired-action-code':
case 'auth/missing-oob-code':
return "Ce lien de réinitialisation n'est plus valide. Demande un nouveau mot de passe."
case 'auth/missing-email':
return 'Renseigne ton adresse e-mail.'
default:
return "Une erreur est survenue. Réessaie dans quelques instants.";
return 'Une erreur est survenue. Réessaie dans quelques instants.'
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import Svg, { Circle, Path } from "react-native-svg";
import * as React from 'react'
import Svg, { Circle, Path } from 'react-native-svg'
function EyeSVG(props) {
return (
@@ -19,7 +19,7 @@ function EyeSVG(props) {
<Path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<Circle cx={12} cy={12} r={3} />
</Svg>
);
)
}
export default EyeSVG;
export default EyeSVG
+4 -4
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import Svg, { Path } from "react-native-svg";
import * as React from 'react'
import Svg, { Path } from 'react-native-svg'
function EyeSlashSVG(props) {
return (
@@ -21,7 +21,7 @@ function EyeSlashSVG(props) {
<Path d="M12 9a3 3 0 013 3" />
<Path d="M9.88 9.88A3 3 0 0012 15a3 3 0 002.12-.88" />
</Svg>
);
)
}
export default EyeSlashSVG;
export default EyeSlashSVG
+10 -10
View File
@@ -1,11 +1,11 @@
import * as React from "react";
import { View } from "react-native";
import Svg, { ClipPath, Defs, G, Path, Rect } from "react-native-svg";
import * as React from 'react'
import { View } from 'react-native'
import Svg, { ClipPath, Defs, G, Path, Rect } from 'react-native-svg'
const RestartSpinnerIcon = ({
size = 30,
color = "#F94697",
background = "transparent",
color = '#F94697',
background = 'transparent',
style,
}) => {
return (
@@ -15,8 +15,8 @@ const RestartSpinnerIcon = ({
width: size + 8,
height: size + 8,
borderRadius: size,
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
backgroundColor: background,
},
style,
@@ -55,7 +55,7 @@ const RestartSpinnerIcon = ({
</Defs>
</Svg>
</View>
);
};
)
}
export default RestartSpinnerIcon;
export default RestartSpinnerIcon
+125 -127
View File
@@ -1,110 +1,110 @@
import addTab from "./UI/tabs/add.png";
import albums from "./UI/tabs/albums.png";
import chat from "./UI/tabs/chat.png";
import design from "./UI/tabs/design.png";
import home from "./UI/tabs/home.png";
import mic from "./UI/tabs/mic.png";
import person from "./UI/tabs/person.png";
import quotes from "./UI/tabs/quotes.png";
import ribbon from "./UI/tabs/ribbon.png";
import settings from "./UI/tabs/settings.png";
import tasks from "./UI/tabs/tasks.png";
import addTab from './UI/tabs/add.png'
import albums from './UI/tabs/albums.png'
import chat from './UI/tabs/chat.png'
import design from './UI/tabs/design.png'
import home from './UI/tabs/home.png'
import mic from './UI/tabs/mic.png'
import person from './UI/tabs/person.png'
import quotes from './UI/tabs/quotes.png'
import ribbon from './UI/tabs/ribbon.png'
import settings from './UI/tabs/settings.png'
import tasks from './UI/tabs/tasks.png'
import bell from "./UI/bell.png";
import sort from "./UI/sort.png";
import bell from './UI/bell.png'
import sort from './UI/sort.png'
import arrowRight from "./UI/arrowRight.png";
import chevronDown from "./UI/chevronDown.png";
import threeDots from "./UI/threeDots.png";
import arrowRight from './UI/arrowRight.png'
import chevronDown from './UI/chevronDown.png'
import threeDots from './UI/threeDots.png'
import thumbDown from "./UI/thumbDown.png";
import thumbUp from "./UI/thumbUp.png";
import thumbDown from './UI/thumbDown.png'
import thumbUp from './UI/thumbUp.png'
import add from "./UI/add.png";
import addFile from "./UI/addFile.png";
import check from "./UI/check.png";
import checkCircle from "./UI/checkCircle.png";
import deliveryTime from "./UI/deliveryTime.png";
import edit from "./UI/edit.png";
import eye from "./UI/eye.png";
import lock from "./UI/lock.png";
import message from "./UI/message.png";
import search from "./UI/search.png";
import send from "./UI/send.png";
import shoppingBag from "./UI/shoppingBag.png";
import tools from "./UI/tools.png";
import trash from "./UI/trash.png";
import undo from "./UI/undo.png";
import add from './UI/add.png'
import addFile from './UI/addFile.png'
import check from './UI/check.png'
import checkCircle from './UI/checkCircle.png'
import deliveryTime from './UI/deliveryTime.png'
import edit from './UI/edit.png'
import eye from './UI/eye.png'
import lock from './UI/lock.png'
import message from './UI/message.png'
import search from './UI/search.png'
import send from './UI/send.png'
import shoppingBag from './UI/shoppingBag.png'
import tools from './UI/tools.png'
import trash from './UI/trash.png'
import undo from './UI/undo.png'
import focus from "./UI/focus.png";
import picture from "./UI/picture.png";
import screenshot from "./UI/screenshot.png";
import focus from './UI/focus.png'
import picture from './UI/picture.png'
import screenshot from './UI/screenshot.png'
import android from "./UI/android.png";
import ios from "./UI/ios.png";
import web from "./UI/web.png";
import android from './UI/android.png'
import ios from './UI/ios.png'
import web from './UI/web.png'
import law from "./UI/law.png";
import support from "./UI/support.png";
import user from "./UI/user.png";
import law from './UI/law.png'
import support from './UI/support.png'
import user from './UI/user.png'
import attachment from "./UI/attachment.png";
import attachment from './UI/attachment.png'
import cloud from "./icons/cloud.png";
import dashboard from "./icons/dashboard.png";
import file from "./icons/file.png";
import cloud from './icons/cloud.png'
import dashboard from './icons/dashboard.png'
import file from './icons/file.png'
import algolia from "./icons/algolia.png";
import calendar from "./icons/calendar.png";
import chatBubble from "./icons/chatBubble.png";
import close from "./icons/close.png";
import coin from "./icons/coin.png";
import disk from "./icons/disk.png";
import figma from "./icons/figma.png";
import forward from "./icons/forward.png";
import gitlab from "./icons/gitlab.png";
import heart from "./icons/heart.png";
import heartOutline from "./icons/heartOutline.png";
import hitParadeLogo from "./icons/hitParadeLogo.png";
import more from "./icons/more.png";
import musicLandAccueil from "./icons/musicLandAccueil.png";
import musicLandLogo from "./icons/musicLandLogo.png";
import musicLandProduction from "./icons/musicLandProduction.png";
import musicLandStudio from "./icons/musicLandStudio.png";
import musicLandVideo from "./icons/musicLandVideo.png";
import musicLandWriting from "./icons/musicLandWriting.png";
import pause from "./icons/pause.png";
import play from "./icons/play.png";
import share from "./icons/share.png";
import stars from "./icons/stars.png";
import algolia from './icons/algolia.png'
import calendar from './icons/calendar.png'
import chatBubble from './icons/chatBubble.png'
import close from './icons/close.png'
import coin from './icons/coin.png'
import disk from './icons/disk.png'
import figma from './icons/figma.png'
import forward from './icons/forward.png'
import gitlab from './icons/gitlab.png'
import heart from './icons/heart.png'
import heartOutline from './icons/heartOutline.png'
import hitParadeLogo from './icons/hitParadeLogo.png'
import more from './icons/more.png'
import musicLandAccueil from './icons/musicLandAccueil.png'
import musicLandLogo from './icons/musicLandLogo.png'
import musicLandProduction from './icons/musicLandProduction.png'
import musicLandStudio from './icons/musicLandStudio.png'
import musicLandVideo from './icons/musicLandVideo.png'
import musicLandWriting from './icons/musicLandWriting.png'
import pause from './icons/pause.png'
import play from './icons/play.png'
import share from './icons/share.png'
import stars from './icons/stars.png'
import hitParadeBG from "./UI/hitParadeBG.png";
import homeBG from "./UI/homeBG.png";
import libraryBG from "./UI/libraryBG.png";
import libraryBG2 from "./UI/libraryBG2.png";
import playbackBG from "./UI/playbackBG.png";
import playbackBG2 from "./UI/playbackBG2.png";
import productionBG from "./UI/productionBG.png";
import productionBG2 from "./UI/productionBG2.png";
import profileBG from "./UI/profileBG.png";
import studioBG from "./UI/studioBG.png";
import studioBG2 from "./UI/studioBG2.png";
import writingBG from "./UI/writingBG.png";
import hitParadeBG from './UI/hitParadeBG.png'
import homeBG from './UI/homeBG.png'
import libraryBG from './UI/libraryBG.png'
import libraryBG2 from './UI/libraryBG2.png'
import playbackBG from './UI/playbackBG.png'
import playbackBG2 from './UI/playbackBG2.png'
import productionBG from './UI/productionBG.png'
import productionBG2 from './UI/productionBG2.png'
import profileBG from './UI/profileBG.png'
import studioBG from './UI/studioBG.png'
import studioBG2 from './UI/studioBG2.png'
import writingBG from './UI/writingBG.png'
import bena from "./UI/bena.png";
import john from "./UI/john.png";
import malik from "./UI/malik.png";
import nathalie from "./UI/nathalie.png";
import theo from "./UI/theo.png";
import bena from './UI/bena.png'
import john from './UI/john.png'
import malik from './UI/malik.png'
import nathalie from './UI/nathalie.png'
import theo from './UI/theo.png'
import { Platform } from "react-native";
import goodVibe from "./UI/goodVibe.png";
import placeholder from "./UI/placeholder.jpg";
import placeholder2 from "./UI/placeholder2.jpg";
import placeholder3 from "./UI/placeholder3.png";
import placeholder4 from "./UI/placeholder4.jpg";
import profile from "./UI/profile.jpg";
import musiclandClub from "./UI/musiclandClub.png";
import { Platform } from 'react-native'
import goodVibe from './UI/goodVibe.png'
import placeholder from './UI/placeholder.jpg'
import placeholder2 from './UI/placeholder2.jpg'
import placeholder3 from './UI/placeholder3.png'
import placeholder4 from './UI/placeholder4.jpg'
import profile from './UI/profile.jpg'
import musiclandClub from './UI/musiclandClub.png'
export const tabs = {
home,
tasks,
@@ -117,12 +117,12 @@ export const tabs = {
ribbon,
mic,
person,
};
}
export const icons = {
bell,
sort,
dragDots: require("./icons/dragDots.png"),
dragDots: require('./icons/dragDots.png'),
chevronDown,
arrowRight,
@@ -186,13 +186,13 @@ export const icons = {
hitParadeLogo,
calendar,
coin,
club: require("./icons/club.png"),
clubIcon: require("./icons/clubIcon.png"),
};
club: require('./icons/club.png'),
clubIcon: require('./icons/clubIcon.png'),
}
export const background = {
writingBG,
writingBgWeb: require("./UI/writingBgWeb.png"),
writingBgWeb: require('./UI/writingBgWeb.png'),
studioBG,
studioBG2,
productionBG,
@@ -200,21 +200,21 @@ export const background = {
playbackBG,
playbackBG2,
libraryBG,
libraryBgWeb: require("./UI/libraryBgWeb.png"),
libraryBgWeb: require('./UI/libraryBgWeb.png'),
libraryBG2,
libraryBG2Web: require("./UI/libraryBG2Web.png"),
libraryBG2Web: require('./UI/libraryBG2Web.png'),
profileBG,
profileBgWeb: require("./UI/profileBgWeb.png"),
profileBgWeb: require('./UI/profileBgWeb.png'),
hitParadeBG,
hitParadeBG2: require("./UI/hitparadeBG2.jpg"),
playbackWeb: require("./UI/playbackWeb.png"),
playbackMobile: require("./UI/playbackMobile.png"),
hitParadeBG2: require('./UI/hitparadeBG2.jpg'),
playbackWeb: require('./UI/playbackWeb.png'),
playbackMobile: require('./UI/playbackMobile.png'),
homeBG,
homeBGWeb: require("./UI/homeBGWeb.png"),
loginBgWeb: require("./UI/loginBgWeb.png"),
profileWebBG: require("./UI/profileWebBG.jpg"),
bgTrans: require("./UI/bgTrans.png"),
};
homeBGWeb: require('./UI/homeBGWeb.png'),
loginBgWeb: require('./UI/loginBgWeb.png'),
profileWebBG: require('./UI/profileWebBG.jpg'),
bgTrans: require('./UI/bgTrans.png'),
}
export const ai = {
nathalie,
@@ -222,15 +222,13 @@ export const ai = {
malik,
bena,
john,
};
}
export const videos = {
test:
Platform.OS === "web"
? require("./video/testVideoWeb.mp4")
: require("./video/testVideo.mp4"),
club: require("./video/club.mp4"),
};
Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'),
club: require('./video/club.mp4'),
}
export const img = {
placeholder,
@@ -240,17 +238,17 @@ export const img = {
profile,
goodVibe,
musiclandClub,
};
}
export const cardsImg = {
writing: require("./icons/writing.png"),
studio: require("./icons/studio.png"),
video: require("./icons/video.png"),
production: require("./icons/production.png"),
};
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"),
};
starter: require('./icons/starterBadge.png'),
pro: require('./icons/proBadge.png'),
premium: require('./icons/premiumBadge.png'),
}
+5 -6
View File
@@ -1,9 +1,8 @@
import {Dimensions} from 'react-native';
import {resWidth} from '../styles';
import { Dimensions } from 'react-native'
import { resWidth } from '../styles'
const {width: DIMENSION_WIDTH, height: DIMENSION_HEIGHT} =
Dimensions.get('screen');
const { width: DIMENSION_WIDTH, height: DIMENSION_HEIGHT } = Dimensions.get('screen')
const BOTTOM_BAR_HEIGHT = resWidth(80);
const BOTTOM_BAR_HEIGHT = resWidth(80)
export {DIMENSION_WIDTH, DIMENSION_HEIGHT, BOTTOM_BAR_HEIGHT};
export { DIMENSION_WIDTH, DIMENSION_HEIGHT, BOTTOM_BAR_HEIGHT }
+1 -1
View File
@@ -1 +1 @@
export * from './constants';
export * from './constants'
+7 -7
View File
@@ -1,10 +1,10 @@
import { View } from "react-native";
import { View } from 'react-native'
import { LoaderIndicator } from "../providers/LoadingProvider";
import { LoaderIndicator } from '../providers/LoadingProvider'
export default ({
message,
defaultMessage = "Chargement...",
defaultMessage = 'Chargement...',
containerStyle = {},
indicatorProps = {},
messageStyle = {},
@@ -13,8 +13,8 @@ export default ({
<View
style={[
{
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
},
containerStyle,
]}
@@ -26,5 +26,5 @@ export default ({
indicatorProps={indicatorProps}
/>
</View>
);
};
)
}
+63 -76
View File
@@ -1,56 +1,48 @@
import { PortalProvider } from "@gorhom/portal";
import React, { useState } from "react";
import { Alert, Platform, StyleSheet, Text, View } from "react-native";
import { PortalProvider } from '@gorhom/portal'
import React, { useState } from 'react'
import { Alert, Platform, StyleSheet, Text, View } from 'react-native'
import { BlurView } from "expo-blur";
import { Fonts, Palette, gutters } from "../styles";
import GradientButton from "./GradientButton";
import { LinearGradient } from "./LinearGradient/LinearGradient";
import Overlay from "./Overlay";
import { BlurView } from 'expo-blur'
import { Fonts, Palette, gutters } from '../styles'
import GradientButton from './GradientButton'
import { LinearGradient } from './LinearGradient/LinearGradient'
import Overlay from './Overlay'
const WebAlertModal = ({ title, description, options }) => {
const [visible, setVisible] = useState(true);
const [visible, setVisible] = useState(true)
const confirmOption = options?.find(({ style }) => style !== "cancel");
const cancelOption = options?.find(({ style }) => style === "cancel");
const hasSecondaryAction = Boolean(cancelOption);
const buttonContainerStyle = hasSecondaryAction
? styles.actionButton
: styles.singleActionButton;
const confirmOption = options?.find(({ style }) => style !== 'cancel')
const cancelOption = options?.find(({ style }) => style === 'cancel')
const hasSecondaryAction = Boolean(cancelOption)
const buttonContainerStyle = hasSecondaryAction ? styles.actionButton : styles.singleActionButton
const onConfirm = () => {
setVisible(false);
confirmOption?.onPress();
};
setVisible(false)
confirmOption?.onPress()
}
const onCancel = () => {
setVisible(false);
cancelOption?.onPress();
};
setVisible(false)
cancelOption?.onPress()
}
const renderDescription = () => {
if (
typeof description === "string" ||
typeof description === "number"
) {
return <Text style={styles.description}>{description}</Text>;
if (typeof description === 'string' || typeof description === 'number') {
return <Text style={styles.description}>{description}</Text>
}
if (!description) {
return null;
return null
}
return <View style={styles.customDescription}>{description}</View>;
};
return <View style={styles.customDescription}>{description}</View>
}
return (
<PortalProvider>
<Overlay
isVisible={visible}
contentContainerStyle={styles.overlayContent}
>
<Overlay isVisible={visible} contentContainerStyle={styles.overlayContent}>
<View style={styles.modalWrapper}>
<LinearGradient
colors={["rgba(255,255,255,0.18)", "rgba(255,255,255,0.04)"]}
colors={['rgba(255,255,255,0.18)', 'rgba(255,255,255,0.04)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.modalBorder}
@@ -59,24 +51,19 @@ const WebAlertModal = ({ title, description, options }) => {
<Text style={styles.title}>{title}</Text>
{renderDescription()}
<View style={styles.divider} />
<View
style={hasSecondaryAction ? styles.actionsRow : styles.actions}
>
<View style={hasSecondaryAction ? styles.actionsRow : styles.actions}>
{cancelOption && (
<GradientButton
title={cancelOption.text}
onPress={onCancel}
colors={[
"rgba(255,255,255,0.16)",
"rgba(255,255,255,0.08)",
]}
colors={['rgba(255,255,255,0.16)', 'rgba(255,255,255,0.08)']}
textStyle={styles.secondaryButtonText}
gradientStyle={styles.secondaryButtonGradient}
containerStyle={buttonContainerStyle}
/>
)}
<GradientButton
title={confirmOption?.text || "OK"}
title={confirmOption?.text || 'OK'}
onPress={onConfirm}
containerStyle={buttonContainerStyle}
/>
@@ -86,16 +73,16 @@ const WebAlertModal = ({ title, description, options }) => {
</View>
</Overlay>
</PortalProvider>
);
};
)
}
const alertPolyfill = (title, description, options, extra) => {
const rootDiv = document.createElement("div");
document.body.appendChild(rootDiv);
const rootDiv = document.createElement('div')
document.body.appendChild(rootDiv)
const closeModal = () => {
document.body.removeChild(rootDiv);
};
document.body.removeChild(rootDiv)
}
const WebAlertComponent = () => (
<WebAlertModal
@@ -104,38 +91,38 @@ const alertPolyfill = (title, description, options, extra) => {
options={options}
onDismiss={closeModal}
/>
);
)
// Render the React component into the div
require("react-dom").render(<WebAlertComponent />, rootDiv);
};
require('react-dom').render(<WebAlertComponent />, rootDiv)
}
const customAlert = (title, description, options, extra) => {
// Utilisation de la propriété userInterfaceStyle pour le mettre en mode sombre
Alert.alert(title, description, options, {
...extra,
userInterfaceStyle: "dark",
});
};
userInterfaceStyle: 'dark',
})
}
const alert = Platform.OS === "web" ? alertPolyfill : customAlert;
const alert = Platform.OS === 'web' ? alertPolyfill : customAlert
export const showPremiumRequiredAlert = () =>
alert(
"Accès premium requis",
"Vous devez payer pour créer une nouvelle musique."
'Accès premium requis',
'Vous devez payer pour créer une nouvelle musique.'
// [{ text: "OK", style: "cancel" }]
);
)
export default alert;
export default alert
const styles = StyleSheet.create({
overlayContent: {
justifyContent: "center",
alignItems: "center",
justifyContent: 'center',
alignItems: 'center',
},
modalWrapper: {
width: "90%",
width: '90%',
maxWidth: 460,
paddingHorizontal: 12,
},
@@ -147,49 +134,49 @@ const styles = StyleSheet.create({
borderRadius: 23,
paddingHorizontal: gutters * 1.8,
paddingVertical: gutters,
backgroundColor: "rgba(15, 12, 20, 0.9)",
overflow: "hidden",
backgroundColor: 'rgba(15, 12, 20, 0.9)',
overflow: 'hidden',
gap: gutters * 0.75,
},
title: Fonts({
type: "mainTitle",
type: 'mainTitle',
fontSize: 3,
style: { textAlign: "center" },
style: { textAlign: 'center' },
}),
description: Fonts({
type: "default",
type: 'default',
color: Palette.gray,
fontSize: 2,
style: { lineHeight: 22, textAlign: "center" },
style: { lineHeight: 22, textAlign: 'center' },
}),
divider: {
height: 1,
backgroundColor: "rgba(255,255,255,0.08)",
backgroundColor: 'rgba(255,255,255,0.08)',
marginVertical: 0,
},
customDescription: {
width: "100%",
width: '100%',
},
actionsRow: {
flexDirection: "row",
flexDirection: 'row',
gap: gutters,
width: "100%",
width: '100%',
},
actions: {
width: "100%",
width: '100%',
gap: gutters,
},
actionButton: {
flex: 1,
},
singleActionButton: {
width: "100%",
width: '100%',
},
secondaryButtonGradient: {
borderWidth: 1,
borderColor: "rgba(255,255,255,0.2)",
borderColor: 'rgba(255,255,255,0.2)',
},
secondaryButtonText: {
color: Palette.gray,
},
});
})
@@ -1,11 +1,11 @@
import React, { useMemo } from "react";
import { Animated, StyleSheet, useWindowDimensions, View } from "react-native";
import { Palette } from "../../styles";
import React, { useMemo } from 'react'
import { Animated, StyleSheet, useWindowDimensions, View } from 'react-native'
import { Palette } from '../../styles'
const ORIENTATION = {
HORIZONTAL: "horizontal",
VERTICAL: "vertical",
};
HORIZONTAL: 'horizontal',
VERTICAL: 'vertical',
}
const AnimatedPaginationDot = ({
data = [],
@@ -16,44 +16,32 @@ const AnimatedPaginationDot = ({
orientation = ORIENTATION.HORIZONTAL,
expandingDotSize = 20,
inactiveDotOpacity = 0.4,
inactiveDotColor = "rgba(255,255,255,0.4)",
inactiveDotColor = 'rgba(255,255,255,0.4)',
activeDotColor = Palette.white,
baseDotSize = 10,
}) => {
const animatedValue = useMemo(
() => scrollValue || new Animated.Value(0),
[scrollValue]
);
const animatedValue = useMemo(() => scrollValue || new Animated.Value(0), [scrollValue])
const { width, height } = useWindowDimensions();
const { width, height } = useWindowDimensions()
const distance = useMemo(() => {
if (typeof itemDimension === "number" && itemDimension > 0) {
return itemDimension;
if (typeof itemDimension === 'number' && itemDimension > 0) {
return itemDimension
}
if (orientation === ORIENTATION.VERTICAL) {
return Math.max(height, 1);
return Math.max(height, 1)
}
return Math.max(width, 1);
}, [height, itemDimension, orientation, width]);
return Math.max(width, 1)
}, [height, itemDimension, orientation, width])
const resolvedDotStyle = useMemo(
() => StyleSheet.flatten(dotStyle) || {},
[dotStyle]
);
const defaultSize = Math.max(baseDotSize, 1);
const resolvedDotStyle = useMemo(() => StyleSheet.flatten(dotStyle) || {}, [dotStyle])
const defaultSize = Math.max(baseDotSize, 1)
const baseWidth =
typeof resolvedDotStyle?.width === "number"
? resolvedDotStyle.width
: defaultSize;
typeof resolvedDotStyle?.width === 'number' ? resolvedDotStyle.width : defaultSize
const baseHeight =
typeof resolvedDotStyle?.height === "number"
? resolvedDotStyle.height
: defaultSize;
typeof resolvedDotStyle?.height === 'number' ? resolvedDotStyle.height : defaultSize
const containerOrientationStyle =
orientation === ORIENTATION.VERTICAL
? styles.containerVertical
: styles.containerHorizontal;
orientation === ORIENTATION.VERTICAL ? styles.containerVertical : styles.containerHorizontal
return (
<View
@@ -61,28 +49,24 @@ const AnimatedPaginationDot = ({
style={[styles.containerBase, containerOrientationStyle, containerStyle]}
>
{data.map((_, index) => {
const inputRange = [
(index - 1) * distance,
index * distance,
(index + 1) * distance,
];
const inputRange = [(index - 1) * distance, index * distance, (index + 1) * distance]
const staticSizeStyle = {
width: baseWidth,
height: baseHeight,
};
}
const color = animatedValue.interpolate({
inputRange,
outputRange: [inactiveDotColor, activeDotColor, inactiveDotColor],
extrapolate: "clamp",
});
extrapolate: 'clamp',
})
const opacity = animatedValue.interpolate({
inputRange,
outputRange: [inactiveDotOpacity, 1, inactiveDotOpacity],
extrapolate: "clamp",
});
extrapolate: 'clamp',
})
const primarySize = animatedValue.interpolate({
inputRange,
@@ -91,13 +75,11 @@ const AnimatedPaginationDot = ({
expandingDotSize,
orientation === ORIENTATION.VERTICAL ? baseHeight : baseWidth,
],
extrapolate: "clamp",
});
extrapolate: 'clamp',
})
const animatedSizeStyle =
orientation === ORIENTATION.VERTICAL
? { height: primarySize }
: { width: primarySize };
orientation === ORIENTATION.VERTICAL ? { height: primarySize } : { width: primarySize }
return (
<Animated.View
@@ -110,30 +92,30 @@ const AnimatedPaginationDot = ({
{ backgroundColor: color, opacity },
]}
/>
);
)
})}
</View>
);
};
)
}
AnimatedPaginationDot.orientation = ORIENTATION;
AnimatedPaginationDot.orientation = ORIENTATION
export default AnimatedPaginationDot;
export default AnimatedPaginationDot
const styles = StyleSheet.create({
containerBase: {
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
},
containerHorizontal: {
flexDirection: "row",
flexDirection: 'row',
},
containerVertical: {
flexDirection: "column",
flexDirection: 'column',
},
dotBase: {
borderRadius: 999,
marginHorizontal: 4,
marginVertical: 4,
},
});
})
+28 -34
View File
@@ -1,31 +1,25 @@
import { Portal } from "@gorhom/portal";
import { BlurView } from "expo-blur";
import { useCallback } from "react";
import { Platform, Pressable, StyleSheet, View } from "react-native";
import ActionSheet, { SheetManager } from "react-native-actions-sheet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { isWeb } from "../hooks/useLayoutType";
import { gutters, Palette } from "../styles";
import { Portal } from '@gorhom/portal'
import { BlurView } from 'expo-blur'
import { useCallback } from 'react'
import { Platform, Pressable, StyleSheet, View } from 'react-native'
import ActionSheet, { SheetManager } from 'react-native-actions-sheet'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { isWeb } from '../hooks/useLayoutType'
import { gutters, Palette } from '../styles'
const AppActionSheet = ({
id,
children,
webModal = false,
onClose = () => {},
...sheetProps
}) => {
const insets = useSafeAreaInsets();
const AppActionSheet = ({ id, children, webModal = false, onClose = () => {}, ...sheetProps }) => {
const insets = useSafeAreaInsets()
const handleRequestClose = useCallback(() => {
if (id) {
Promise.resolve(SheetManager.hide(id))
.catch(() => {})
.finally(() => {
onClose?.();
});
return;
onClose?.()
})
return
}
onClose?.();
}, [id, onClose]);
onClose?.()
}, [id, onClose])
if (isWeb && webModal) {
return (
@@ -43,7 +37,7 @@ const AppActionSheet = ({
</View>
</View>
</Portal>
);
)
}
return (
@@ -62,7 +56,7 @@ const AppActionSheet = ({
{...sheetProps}
>
<BlurView
intensity={Platform.OS === "ios" || isWeb ? 20 : 10}
intensity={Platform.OS === 'ios' || isWeb ? 20 : 10}
style={{
paddingTop: 36,
paddingHorizontal: 14,
@@ -70,7 +64,7 @@ const AppActionSheet = ({
backgroundColor: Palette.glass,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
overflow: "hidden",
overflow: 'hidden',
}}
// experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
@@ -79,33 +73,33 @@ const AppActionSheet = ({
{children}
</BlurView>
</ActionSheet>
);
};
)
}
export default AppActionSheet;
export default AppActionSheet
const styles = StyleSheet.create({
webOverlay: {
...StyleSheet.absoluteFillObject,
position: "fixed",
position: 'fixed',
zIndex: 100,
},
webBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.55)",
backgroundColor: 'rgba(0, 0, 0, 0.55)',
},
webModalWrapper: {
flex: 1,
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
webModalCard: {
width: 480,
maxWidth: "90%",
maxWidth: '90%',
borderRadius: 28,
overflow: "hidden",
overflow: 'hidden',
backgroundColor: Palette.glass,
padding: 32,
},
});
})
+8 -8
View File
@@ -1,8 +1,8 @@
import { View, Text, Pressable } from "react-native";
import React from "react";
import { Palette, Style } from "../styles";
import { size } from "../styles/Style";
import { FONT_FAMILY } from "../styles/Fonts";
import { View, Text, Pressable } from 'react-native'
import React from 'react'
import { Palette, Style } from '../styles'
import { size } from '../styles/Style'
import { FONT_FAMILY } from '../styles/Fonts'
const AppCheckbox = ({ onPress, selected, label }) => {
return (
@@ -42,7 +42,7 @@ const AppCheckbox = ({ onPress, selected, label }) => {
{label}
</Text>
</Pressable>
);
};
)
}
export default AppCheckbox;
export default AppCheckbox
+74 -82
View File
@@ -1,93 +1,85 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import React, { useCallback, useEffect, useState } from "react";
import {
Image,
Linking,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import AsyncStorage from '@react-native-async-storage/async-storage'
import React, { useCallback, useEffect, useState } from 'react'
import { Image, Linking, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
import { icons } from "../assets";
import { appleAppStoreUrl, googlePlayStoreUrl } from "../data";
import useLayoutType from "../hooks/useLayoutType";
import { Palette, gutters } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { icons } from '../assets'
import { appleAppStoreUrl, googlePlayStoreUrl } from '../data'
import useLayoutType from '../hooks/useLayoutType'
import { Palette, gutters } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts'
const STORAGE_KEY = "appDownloadBanner:dismissed";
const STORAGE_KEY = 'appDownloadBanner:dismissed'
const AppDownloadBanner = () => {
const { isMobileWeb } = useLayoutType();
const [isVisible, setIsVisible] = useState(false);
const [hasHydrated, setHasHydrated] = useState(false);
const { isMobileWeb } = useLayoutType()
const [isVisible, setIsVisible] = useState(false)
const [hasHydrated, setHasHydrated] = useState(false)
useEffect(() => {
let mounted = true;
let mounted = true
if (!isMobileWeb) {
setIsVisible(false);
setHasHydrated(false);
return undefined;
setIsVisible(false)
setHasHydrated(false)
return undefined
}
AsyncStorage.getItem(STORAGE_KEY)
.then((value) => {
if (!mounted) return;
setIsVisible(value !== "hidden");
setHasHydrated(true);
if (!mounted) return
setIsVisible(value !== 'hidden')
setHasHydrated(true)
})
.catch(() => {
if (!mounted) return;
setIsVisible(true);
setHasHydrated(true);
});
if (!mounted) return
setIsVisible(true)
setHasHydrated(true)
})
return () => {
mounted = false;
};
}, [isMobileWeb]);
mounted = false
}
}, [isMobileWeb])
const handleDismiss = useCallback(() => {
setIsVisible(false);
AsyncStorage.setItem(STORAGE_KEY, "hidden").catch(() => {});
}, []);
setIsVisible(false)
AsyncStorage.setItem(STORAGE_KEY, 'hidden').catch(() => {})
}, [])
const openLink = useCallback((url) => {
if (typeof url !== "string") return;
const target = url.trim();
if (!target) return;
if (typeof url !== 'string') return
const target = url.trim()
if (!target) return
if (Platform.OS === "web") {
if (Platform.OS === 'web') {
try {
window.open(target, "_blank", "noopener,noreferrer");
return;
window.open(target, '_blank', 'noopener,noreferrer')
return
} catch (error) {
console.warn("AppDownloadBanner: failed to open link in new tab", error);
console.warn('AppDownloadBanner: failed to open link in new tab', error)
}
}
Linking.openURL(target).catch((error) => {
console.warn("AppDownloadBanner: failed to open store link", error);
});
}, []);
console.warn('AppDownloadBanner: failed to open store link', error)
})
}, [])
const handleOpenStore = useCallback(
(store) => {
if (store === "ios") {
openLink(appleAppStoreUrl);
return;
if (store === 'ios') {
openLink(appleAppStoreUrl)
return
}
if (store === "android") {
openLink(googlePlayStoreUrl);
if (store === 'android') {
openLink(googlePlayStoreUrl)
}
},
[openLink],
);
[openLink]
)
if (!isMobileWeb || !isVisible || !hasHydrated) {
return null;
return null
}
return (
@@ -101,8 +93,8 @@ const AppDownloadBanner = () => {
<View style={styles.titleContent}>
<Text style={styles.title}>Télécharge l'app MusicLand</Text>
<Text style={styles.subtitle}>
Pour une expérience mobile plus fluide, utilise l'application
native et retrouve toutes les fonctionnalités.
Pour une expérience mobile plus fluide, utilise l'application native et retrouve
toutes les fonctionnalités.
</Text>
</View>
</View>
@@ -119,38 +111,38 @@ const AppDownloadBanner = () => {
<View style={styles.actions}>
<TouchableOpacity
style={[styles.cta, styles.appStoreCta]}
onPress={() => handleOpenStore("ios")}
onPress={() => handleOpenStore('ios')}
>
<Text style={styles.ctaText}>App Store</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.cta, styles.playStoreCta]}
onPress={() => handleOpenStore("android")}
onPress={() => handleOpenStore('android')}
>
<Text style={styles.ctaText}>Google Play</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
};
)
}
const styles = StyleSheet.create({
container: {
position: "fixed",
position: 'fixed',
bottom: gutters,
left: gutters,
right: gutters,
alignItems: "center",
alignItems: 'center',
zIndex: 80,
},
banner: {
width: "100%",
width: '100%',
maxWidth: 520,
borderRadius: 18,
paddingVertical: 14,
paddingHorizontal: 16,
backgroundColor: "rgba(12, 10, 16, 0.95)",
backgroundColor: 'rgba(12, 10, 16, 0.95)',
borderWidth: 1,
borderColor: Palette.ultraLightWhite,
shadowColor: Palette.black,
@@ -160,29 +152,29 @@ const styles = StyleSheet.create({
elevation: 10,
},
headerRow: {
flexDirection: "row",
alignItems: "flex-start",
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 12,
},
titleRow: {
flex: 1,
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
},
logoWrapper: {
width: 50,
height: 50,
borderRadius: 12,
backgroundColor: "rgba(255, 255, 255, 0.06)",
alignItems: "center",
justifyContent: "center",
backgroundColor: 'rgba(255, 255, 255, 0.06)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: Palette.ultraLightWhite,
},
logo: {
width: "80%",
height: "80%",
resizeMode: "contain",
width: '80%',
height: '80%',
resizeMode: 'contain',
},
titleContent: {
flex: 1,
@@ -210,16 +202,16 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterSemiBold,
},
actions: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
cta: {
flex: 1,
height: 46,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: Palette.ultraLightWhite,
},
@@ -237,6 +229,6 @@ const styles = StyleSheet.create({
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
});
})
export default AppDownloadBanner;
export default AppDownloadBanner
+20 -20
View File
@@ -1,31 +1,31 @@
import { Text, View } from "react-native";
import { Image } from "expo-image";
import { responsiveWidth } from "../actions/responsiveSizes.js";
import { formatImageURL, getInitials } from "../helpers";
import { Fonts, Palette, Style } from "../styles";
import Badge from "./Badge.js";
import { Text, View } from 'react-native'
import { Image } from 'expo-image'
import { responsiveWidth } from '../actions/responsiveSizes.js'
import { formatImageURL, getInitials } from '../helpers'
import { Fonts, Palette, Style } from '../styles'
import Badge from './Badge.js'
export default ({
name = "",
name = '',
url = null,
size = responsiveWidth(7),
containerStyle = {},
forceRawImage = false,
badge = {},
}) => {
const uniqColorById = (uniqId = "test") => {
const uniqColorById = (uniqId = 'test') => {
// Calculer un nombre unique à partir de l'ID de l'employé
let uniqueNumber = 0;
let uniqueNumber = 0
for (let i = 0; i < uniqId?.length; i++) {
uniqueNumber += uniqId.charCodeAt(i);
uniqueNumber += uniqId.charCodeAt(i)
}
// génère des couleurs pastels claires
const color = `hsl(${uniqueNumber % 360}, 100%, 90%)`;
const color = `hsl(${uniqueNumber % 360}, 100%, 90%)`
return color;
};
return color
}
return (
<View
@@ -39,14 +39,14 @@ export default ({
<View
style={{
...Style.containerRound,
width: "100%",
height: "100%",
backgroundColor: url ? "transparent" : uniqColorById(name),
width: '100%',
height: '100%',
backgroundColor: url ? 'transparent' : uniqColorById(name),
}}
>
{url ? (
<Image
cachePolicy={"memory"}
cachePolicy={'memory'}
source={{
uri: forceRawImage ? url : formatImageURL({ url, size: 200 }),
}}
@@ -59,7 +59,7 @@ export default ({
) : (
<Text
style={Fonts({
type: "section",
type: 'section',
color: Palette.darkPurple,
style: { fontSize: size / 3 },
})}
@@ -70,5 +70,5 @@ export default ({
</View>
<Badge {...badge} />
</View>
);
};
)
}
@@ -0,0 +1,16 @@
import { Image, View } from 'react-native'
import { subBadges } from '../../assets'
export default function SubscriptionAvatar({ user }) {
const subscriptionLevel = user.premiumLevel
switch (subscriptionLevel) {
case 'starter':
return <Image source={subBadges.starter} />
case 'pro':
return <Image source={subBadges.pro} />
case 'premium':
return <Image source={subBadges.premium} />
default:
return null
}
}
+10 -15
View File
@@ -1,24 +1,19 @@
import { Motion } from "@legendapp/motion";
import { Motion } from '@legendapp/motion'
import { Fonts, Palette, Style } from "../styles";
import { Fonts, Palette, Style } from '../styles'
const Badge = ({
count = 0,
size = 20,
customContent = null,
backgroundColor = null,
} = {}) => {
const Badge = ({ count = 0, size = 20, customContent = null, backgroundColor = null } = {}) => {
if (!count && !customContent) {
return null;
return null
}
return (
<Motion.View
animate={{ scale: 1 }}
initial={{ scale: 0 }}
transition={{ type: "tween", duration: 0.5 }}
transition={{ type: 'tween', duration: 0.5 }}
style={{
position: "absolute",
position: 'absolute',
top: -size / 4,
right: -size / 4,
backgroundColor: backgroundColor || Palette.red,
@@ -34,7 +29,7 @@ const Badge = ({
<Motion.Text
animate={{ scale: 1 }}
initial={{ scale: 0 }}
transition={{ type: "tween", duration: 0.5 }}
transition={{ type: 'tween', duration: 0.5 }}
style={{
...Fonts({ color: Palette.white }),
}}
@@ -43,7 +38,7 @@ const Badge = ({
</Motion.Text>
)}
</Motion.View>
);
};
)
}
export default Badge;
export default Badge
+19 -26
View File
@@ -1,40 +1,33 @@
import { Image, Pressable, Text, View } from "react-native";
import React from "reactn";
import { responsiveWidth } from "../actions/responsiveSizes.js";
import { Image, Pressable, Text, View } from 'react-native'
import React from 'reactn'
import { responsiveWidth } from '../actions/responsiveSizes.js'
import { icons } from "../assets";
import { Fonts, Style, gutters } from "../styles";
import { icons } from '../assets'
import { Fonts, Style, gutters } from '../styles'
import { Routes } from "../navigation";
import { navigate } from "../navigation/NavigationService";
import { Routes } from '../navigation'
import { navigate } from '../navigation/NavigationService'
import useLayoutType, { sidebarWidth } from "../hooks/useLayoutType.js";
import useNotifications from "../hooks/useNotifications";
import useLayoutType, { sidebarWidth } from '../hooks/useLayoutType.js'
import useNotifications from '../hooks/useNotifications'
export default ({ title = "", containerStyle = {}, bell = false }) => {
const { isDesktop } = useLayoutType();
const notificationsContext = useNotifications();
const unreadCount = notificationsContext?.unreadCount || 0;
const hasUnread = unreadCount > 0;
export default ({ title = '', containerStyle = {}, bell = false }) => {
const { isDesktop } = useLayoutType()
const notificationsContext = useNotifications()
const unreadCount = notificationsContext?.unreadCount || 0
const hasUnread = unreadCount > 0
return (
<View
style={[
Style.containerSpaceBetween,
{ marginBottom: 20, ...containerStyle },
]}
>
<View style={[Style.containerSpaceBetween, { marginBottom: 20, ...containerStyle }]}>
<View style={Style.containerRow}>
<Text
numberOfLines={1}
style={{
...Fonts({
type: "mainTitle",
type: 'mainTitle',
style: {
marginRight: 10,
maxWidth: isDesktop
? sidebarWidth - 4 * gutters
: responsiveWidth(70),
maxWidth: isDesktop ? sidebarWidth - 4 * gutters : responsiveWidth(70),
},
}),
}}
@@ -65,5 +58,5 @@ export default ({ title = "", containerStyle = {}, bell = false }) => {
</Pressable>
</View>
</View>
);
};
)
}
+15 -22
View File
@@ -1,23 +1,16 @@
import {
View,
Text,
Pressable,
StyleSheet,
Image,
Platform,
} from "react-native";
import React from "react";
import { BlurView } from "expo-blur";
import { Palette, Style } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { icons } from "../assets";
import { size } from "../styles/Style";
import { View, Text, Pressable, StyleSheet, Image, Platform } from 'react-native'
import React from 'react'
import { BlurView } from 'expo-blur'
import { Palette, Style } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts'
import { icons } from '../assets'
import { size } from '../styles/Style'
const BlurItemButton = ({ onPress, title = "" }) => {
const BlurItemButton = ({ onPress, title = '' }) => {
return (
<Pressable onPress={onPress}>
<BlurView
intensity={Platform.OS === "ios" ? 20 : 10}
intensity={Platform.OS === 'ios' ? 20 : 10}
style={styles.buttonContainer}
// experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
@@ -28,16 +21,16 @@ const BlurItemButton = ({ onPress, title = "" }) => {
source={icons.chevronDown}
style={{
...size({ size: 15 }),
transform: [{ rotate: "-90deg" }],
transform: [{ rotate: '-90deg' }],
}}
resizeMode="contain"
/>
</BlurView>
</Pressable>
);
};
)
}
export default BlurItemButton;
export default BlurItemButton
const styles = StyleSheet.create({
buttonContainer: {
@@ -45,7 +38,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
height: 56,
borderRadius: 14,
overflow: "hidden",
overflow: 'hidden',
backgroundColor: Palette.glass,
},
buttonText: {
@@ -53,4 +46,4 @@ const styles = StyleSheet.create({
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
});
})
@@ -1,13 +1,8 @@
import { StyleSheet, View } from "react-native";
import { StyleSheet, View } from 'react-native'
import { GradientBorderView } from "../GradientBorderView";
import { GradientBorderView } from '../GradientBorderView'
const BorderGradient = ({
children,
contentContainerStyle,
gradientProps,
...props
}) => {
const BorderGradient = ({ children, contentContainerStyle, gradientProps, ...props }) => {
return (
<GradientBorderView
gradientProps={{
@@ -26,13 +21,13 @@ const BorderGradient = ({
{children}
</View>
</GradientBorderView>
);
};
)
}
export default BorderGradient;
export default BorderGradient
const styles = StyleSheet.create({
innerContainer: {
flex: 1,
},
});
})
@@ -1,5 +1,5 @@
import { StyleSheet, View } from "react-native";
import { useState } from "react";
import { StyleSheet, View } from 'react-native'
import { useState } from 'react'
const BorderGradient = ({ children, gradientProps, ...props }) => {
const defaultGradientProps = {
@@ -15,10 +15,9 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
colors: [],
useAngle: false,
angle: 0,
};
}
const { locations, end, start, useAngle, angle, onLayout, colors } =
gradientProps;
const { locations, end, start, useAngle, angle, onLayout, colors } = gradientProps
const {
style,
@@ -32,29 +31,29 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
borderLeftWidth,
borderRightWidth,
borderBottomWidth,
} = props;
} = props
const propStart = start ?? defaultGradientProps?.start;
const propEnd = end ?? defaultGradientProps?.end;
const propStart = start ?? defaultGradientProps?.start
const propEnd = end ?? defaultGradientProps?.end
const [state, setState] = useState({
width: 1,
height: 1,
});
})
const measure = (event) => {
setState({
width: event.nativeEvent.layout.width,
height: event.nativeEvent.layout.height,
});
})
if (onLayout) {
onLayout(event);
onLayout(event)
}
};
}
const getAngle = () => {
if (useAngle) {
return angle + "deg";
return angle + 'deg'
}
// Math.atan2 handles Infinity
@@ -63,26 +62,26 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
state.width * (propEnd.y - propStart.y),
state.height * (propEnd.x - propStart.x)
) +
Math.PI / 2;
return _angle + "rad";
};
Math.PI / 2
return _angle + 'rad'
}
const getColors = () =>
colors
.map((color, index) => {
const location = locations?.[index] ?? defaultGradientProps.locations;
let locationStyle = "";
const location = locations?.[index] ?? defaultGradientProps.locations
let locationStyle = ''
if (location) {
locationStyle = " " + location * 100 + "%";
locationStyle = ' ' + location * 100 + '%'
}
return color + locationStyle;
return color + locationStyle
})
.join(",");
.join(',')
return (
<View
style={{
position: "relative",
position: 'relative',
}}
>
<View
@@ -100,35 +99,34 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
borderLeftWidth,
borderRightWidth,
borderBottomWidth,
borderStyle: "solid",
borderColor: "transparent",
borderStyle: 'solid',
borderColor: 'transparent',
// borderImage: `linear-gradient(${getAngle()},${getColors()}) 1`,
background: `linear-gradient(${getAngle()},${getColors()}) border-box`,
WebkitMask:
"linear-gradient(#fff 0 0) padding-box,linear-gradient(#fff 0 0)",
WebkitMaskComposite: "xor",
maskComposite: "exclude",
overflow: "hidden",
WebkitMask: 'linear-gradient(#fff 0 0) padding-box,linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
overflow: 'hidden',
},
]}
></View>
<View style={styles.innerContainer}>{children}</View>
</View>
);
};
)
}
export default BorderGradient;
export default BorderGradient
const styles = StyleSheet.create({
innerContainer: {
flex: 1,
position: "absolute",
position: 'absolute',
top: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
right: 0,
left: 0,
overflow: "hidden",
overflow: 'hidden',
},
});
})
+27 -30
View File
@@ -1,40 +1,39 @@
import { BlurView } from "expo-blur";
import React from "react";
import { Image, Pressable, Text, View } from "react-native";
import { Palette, Style } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { size as sizeStyle } from "../styles/Style";
import BorderGradient from "./BorderGradient/BorderGradient";
import { BlurView } from 'expo-blur'
import React from 'react'
import { Image, Pressable, Text, View } from 'react-native'
import { Palette, Style } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts'
import { size as sizeStyle } from '../styles/Style'
import BorderGradient from './BorderGradient/BorderGradient'
const HEIGHT_BY_SIZE = {
small: 40,
medium: 50,
large: 58,
};
}
const FONT_SIZE_BY_SIZE = {
small: 13,
medium: 15,
large: 17,
};
}
const BorderGradientButton = ({
title = "Jai déjà mes paroles",
title = 'Jai déjà mes paroles',
onPress,
icon,
titleStyle,
containerStyle = {},
tint = "dark",
tint = 'dark',
disabled = false,
maxWidth = null,
size = "medium",
size = 'medium',
height = null,
}) => {
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
const buttonHeight =
typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize];
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
const iconSize = resolvedSize === "small" ? 14 : 16;
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : 'medium'
const buttonHeight = typeof height === 'number' ? height : HEIGHT_BY_SIZE[resolvedSize]
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]
const iconSize = resolvedSize === 'small' ? 14 : 16
return (
<Pressable
@@ -51,7 +50,7 @@ const BorderGradientButton = ({
>
<BorderGradient
gradientProps={{
colors: ["#F94697", "#7023F7"],
colors: ['#F94697', '#7023F7'],
start: { x: 0, y: 0 },
end: { x: 1, y: 0 },
locations: [0, 1],
@@ -66,27 +65,25 @@ const BorderGradientButton = ({
<View
style={{
borderRadius: 14,
overflow: "hidden",
backgroundColor: "#73737324",
width: "100%",
height: "100%",
overflow: 'hidden',
backgroundColor: '#73737324',
width: '100%',
height: '100%',
}}
>
<BlurView
intensity={40}
tint={tint}
style={{
width: "100%",
height: "100%",
width: '100%',
height: '100%',
...Style.containerCenter,
...Style.containerRow,
borderRadius: 14,
gap: 11,
}}
>
{icon && (
<Image source={icon} style={sizeStyle({ size: iconSize })} />
)}
{icon && <Image source={icon} style={sizeStyle({ size: iconSize })} />}
<Text
style={{
fontSize,
@@ -101,7 +98,7 @@ const BorderGradientButton = ({
</View>
</BorderGradient>
</Pressable>
);
};
)
}
export default BorderGradientButton;
export default BorderGradientButton
+21 -21
View File
@@ -1,18 +1,18 @@
import { BlurView } from "expo-blur";
import React from "react";
import { Image, Pressable, Text, View } from "react-native";
import { Palette, Style } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { size } from "../styles/Style";
import RotationBorder from "./RotationBorder/RotationBorder";
import { BlurView } from 'expo-blur'
import React from 'react'
import { Image, Pressable, Text, View } from 'react-native'
import { Palette, Style } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts'
import { size } from '../styles/Style'
import RotationBorder from './RotationBorder/RotationBorder'
const BorderGradientButton = ({
title = "Jai déjà mes paroles",
title = 'Jai déjà mes paroles',
onPress,
icon,
titleStyle,
containerStyle = {},
tint = "dark",
tint = 'dark',
disabled = false,
maxWidth = null,
}) => {
@@ -20,35 +20,35 @@ const BorderGradientButton = ({
...(maxWidth ? { maxWidth } : {}),
...containerStyle,
opacity: disabled ? 0.6 : 1,
};
}
return (
<Pressable onPress={onPress} disabled={disabled} style={pressableStyle}>
<RotationBorder
borderWidth={2}
borderRadius={14}
colors={["#F94697", "#7023F7"]}
colors={['#F94697', '#7023F7']}
style={{
height: 50,
width: "100%",
width: '100%',
zIndex: 1,
}}
>
<View
style={{
borderRadius: 14,
overflow: "hidden",
backgroundColor: "#000000b8",
width: "100%",
height: "100%",
overflow: 'hidden',
backgroundColor: '#000000b8',
width: '100%',
height: '100%',
}}
>
<BlurView
intensity={40}
tint={tint}
style={{
width: "100%",
height: "100%",
width: '100%',
height: '100%',
...Style.containerCenter,
...Style.containerRow,
borderRadius: 14,
@@ -70,7 +70,7 @@ const BorderGradientButton = ({
</View>
</RotationBorder>
</Pressable>
);
};
)
}
export default BorderGradientButton;
export default BorderGradientButton
+2 -2
View File
@@ -1,3 +1,3 @@
import BottomSheet from "@gorhom/bottom-sheet";
import BottomSheet from '@gorhom/bottom-sheet'
export default BottomSheet;
export default BottomSheet
+29 -35
View File
@@ -1,41 +1,37 @@
import React, { useImperativeHandle, useState, useRef } from "react";
import { View, TouchableOpacity, ScrollView } from "react-native";
import { Portal } from "@gorhom/portal";
import { Motion } from "@legendapp/motion";
import React, { useImperativeHandle, useState, useRef } from 'react'
import { View, TouchableOpacity, ScrollView } from 'react-native'
import { Portal } from '@gorhom/portal'
import { Motion } from '@legendapp/motion'
import { Palette } from "../../styles";
import {
isDesktop,
isLargeDesktop,
sidebarWidth,
} from "../../hooks/useLayoutType";
import { Palette } from '../../styles'
import { isDesktop, isLargeDesktop, sidebarWidth } from '../../hooks/useLayoutType'
export const SheetScrollView = ScrollView;
export const SheetBackdrop = View;
export const SheetScrollView = ScrollView
export const SheetBackdrop = View
const BottomSheet = React.forwardRef((props, ref) => {
const [showSheet, setShowSheet] = useState(false);
const [showSheet, setShowSheet] = useState(false)
const bottomSheetRef = useRef();
const bottomSheetRef = useRef()
useImperativeHandle(ref, () => ({
snapToIndex: () => {
setShowSheet(true);
setShowSheet(true)
},
expand: () => {
setShowSheet(true);
setShowSheet(true)
},
collapse: () => closeBottomSheet(),
close: () => closeBottomSheet(),
}));
}))
const closeBottomSheet = () => {
setShowSheet(false);
props.onChange(-1);
};
setShowSheet(false)
props.onChange(-1)
}
if (!showSheet) {
return null;
return null
}
return (
@@ -44,51 +40,49 @@ const BottomSheet = React.forwardRef((props, ref) => {
initial={{ right: -500, opacity: 0 }}
animate={{ right: 0, opacity: 1 }}
style={{
position: "fixed",
position: 'fixed',
right: 0,
top: 0,
left: 0,
bottom: 0,
flex: 1,
zIndex: 1000000,
justifyContent: "center",
alignItems: "center",
justifyContent: 'center',
alignItems: 'center',
}}
>
<TouchableOpacity
onPress={closeBottomSheet}
ref={bottomSheetRef}
style={{
position: "absolute",
position: 'absolute',
right: 0,
top: 0,
left: 0,
bottom: 0,
flex: 1,
backgroundColor: "rgba(0,0,0,0.4)",
backgroundColor: 'rgba(0,0,0,0.4)',
}}
/>
<View
style={{
position: "absolute",
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
height: "100%",
width: isDesktop
? sidebarWidth * (isLargeDesktop ? 2 : 1.5)
: "100%",
height: '100%',
width: isDesktop ? sidebarWidth * (isLargeDesktop ? 2 : 1.5) : '100%',
backgroundColor: Palette.lightPurple,
overflow: "scroll",
overflow: 'scroll',
}}
>
{props.children}
</View>
</Motion.View>
</Portal>
);
});
)
})
// TODO une croix pour fermer sur mobile web
export default BottomSheet;
export default BottomSheet
+22 -22
View File
@@ -1,42 +1,42 @@
import { AnimatePresence, Motion } from "@legendapp/motion";
import { useKeyboard } from "@react-native-community/hooks";
import { useCallback, useEffect, useState } from "react";
import { Keyboard, StyleSheet } from "react-native";
import { AnimatePresence, Motion } from '@legendapp/motion'
import { useKeyboard } from '@react-native-community/hooks'
import { useCallback, useEffect, useState } from 'react'
import { Keyboard, StyleSheet } from 'react-native'
import BottomSheet from "./BottomSheet";
import BottomSheet from './BottomSheet'
import useLayoutType from "../hooks/useLayoutType";
import { Palette } from "../styles";
import useLayoutType from '../hooks/useLayoutType'
import { Palette } from '../styles'
export default ({
children,
bottomSheetRef,
snapPoints = ["25%", "50%"],
snapPoints = ['25%', '50%'],
handleStyle = {},
...rest
}) => {
const [currentSnapPointIndex, setCurrentSnapPointIndex] = useState(0);
const [currentSnapPointIndex, setCurrentSnapPointIndex] = useState(0)
const { keyboardShown = false } = useKeyboard();
const { isWeb } = useLayoutType();
const { keyboardShown = false } = useKeyboard()
const { isWeb } = useLayoutType()
const handleSheetChanges = useCallback((index) => {
setCurrentSnapPointIndex(index);
setCurrentSnapPointIndex(index)
if (index <= 0) {
Keyboard.dismiss();
Keyboard.dismiss()
}
}, []);
}, [])
useEffect(() => {
if (bottomSheetRef.current && !isWeb && currentSnapPointIndex > 0) {
if (keyboardShown) {
bottomSheetRef.current.expand();
bottomSheetRef.current.expand()
} else {
bottomSheetRef.current.snapToIndex(1);
bottomSheetRef.current.snapToIndex(1)
}
}
}, [keyboardShown]);
}, [keyboardShown])
return (
<>
@@ -47,7 +47,7 @@ export default ({
onPress={() => bottomSheetRef?.current?.close()}
>
<Motion.View
key={"A"}
key={'A'}
style={{
flex: 1,
backgroundColor: Palette.black,
@@ -57,10 +57,10 @@ export default ({
exit={{ opacity: 0 }}
transition={{
default: {
type: "spring",
type: 'spring',
},
opacity: {
type: "timing",
type: 'timing',
},
}}
></Motion.View>
@@ -83,5 +83,5 @@ export default ({
{children}
</BottomSheet>
</>
);
};
)
}
+33 -36
View File
@@ -1,17 +1,17 @@
import { Motion } from "@legendapp/motion";
import * as Haptics from "expo-haptics";
import { Text, View } from "react-native";
import { Motion } from '@legendapp/motion'
import * as Haptics from 'expo-haptics'
import { Text, View } from 'react-native'
import { isDesktop, isMobile, isNative } from "../hooks/useLayoutType";
import { Fonts, Palette } from "../styles";
import Style, { gutters, mainBorderRadius } from "../styles/Style";
import { isDesktop, isMobile, isNative } from '../hooks/useLayoutType'
import { Fonts, Palette } from '../styles'
import Style, { gutters, mainBorderRadius } from '../styles/Style'
export default ({
type = "primary",
theme = "default", // "default" | "radioactiv"
type = 'primary',
theme = 'default', // "default" | "radioactiv"
text,
onPress = () => console.log("null"),
onPress = () => console.log('null'),
isAbsoluteBottom = false,
alternateAction = {},
@@ -23,39 +23,39 @@ export default ({
isMainDesktopPanel = false,
}) => {
const buttonWidth = alternateAction?.text ? "49%" : "100%";
const buttonWidth = alternateAction?.text ? '49%' : '100%'
return (
<View
style={{
flexDirection: "row",
justifyContent: alternateAction?.text ? "space-between" : "center",
flexDirection: 'row',
justifyContent: alternateAction?.text ? 'space-between' : 'center',
...(isAbsoluteBottom
? {
position: "absolute",
position: 'absolute',
bottom: gutters * (isMobile ? 2 : 1),
alignItems: "flex-end",
alignItems: 'flex-end',
...(isDesktop && !isMainDesktopPanel
? {
maxWidth: 600,
minWidth: 400,
alignSelf: "center",
alignSelf: 'center',
}
: {
right: gutters,
left: gutters,
alignSelf: "center",
alignSelf: 'center',
}),
}
: {
width: "100%",
width: '100%',
}),
...contentContainerStyle,
}}
>
{alternateAction?.text && alternateAction?.onPress ? (
<BaseButton
type={"secondary"}
type={'secondary'}
theme={alternateAction?.theme || theme}
text={alternateAction.text}
onPress={alternateAction.onPress}
@@ -83,45 +83,42 @@ export default ({
}}
/>
</View>
);
};
)
}
export const BaseButton = ({
type = "primary",
theme = "default",
type = 'primary',
theme = 'default',
text,
onPress = () => console.log("null"),
onPress = () => console.log('null'),
containerStyle = {},
textStyle = {},
hasShadow = false,
}) => {
const primaryColor =
theme === "radioactiv" ? Palette.radioactivGreen : Palette.primary;
const primaryColor = theme === 'radioactiv' ? Palette.radioactivGreen : Palette.primary
const primaryTransparentColor =
theme === "radioactiv"
? Palette.transparentRadioactivGreen
: Palette.transparentPrimary;
theme === 'radioactiv' ? Palette.transparentRadioactivGreen : Palette.transparentPrimary
const textColor = type === "secondary" ? primaryColor : Palette.darkPurple;
const textColor = type === 'secondary' ? primaryColor : Palette.darkPurple
return (
<Motion.Pressable
whileTap={{ scale: 0.8 }}
onPress={() => {
if (isNative) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}
onPress();
onPress()
}}
style={{
width: "100%",
width: '100%',
height: 50,
marginTop: gutters / 2,
...Style.containerRow,
...Style.containerCenter,
backgroundColor: primaryColor,
borderRadius: mainBorderRadius,
...(type === "secondary"
...(type === 'secondary'
? {
backgroundColor: primaryTransparentColor,
}
@@ -134,7 +131,7 @@ export const BaseButton = ({
<Text
style={{
...Fonts({
type: "default",
type: 'default',
color: textColor,
}),
...textStyle,
@@ -143,5 +140,5 @@ export const BaseButton = ({
{text}
</Text>
</Motion.Pressable>
);
};
)
}
+36 -36
View File
@@ -1,32 +1,32 @@
import { useState, useEffect } from "reactn";
import { Pressable, TextInput, View, Image, Platform } from "react-native";
import { BlurView } from "expo-blur";
import { useState, useEffect } from 'reactn'
import { Pressable, TextInput, View, Image, Platform } from 'react-native'
import { BlurView } from 'expo-blur'
import { Fonts, gutters, Palette } from "../styles";
import Style from "../styles/Style";
import { Fonts, gutters, Palette } from '../styles'
import Style from '../styles/Style'
import { chatsRef } from "../config/firebase";
import { chatsRef } from '../config/firebase'
import { icons } from "../assets";
import { icons } from '../assets'
import { isWeb } from "../hooks/useLayoutType.js";
import { isWeb } from '../hooks/useLayoutType.js'
import DocumentDropZone from "./DocumentDropZone.js";
import FilesPreview from "./FilesPreview.js";
import DocumentDropZone from './DocumentDropZone.js'
import FilesPreview from './FilesPreview.js'
const ChatInput = ({
message,
setMessage,
onSendMessage,
containerStyle = {},
placeholder = "",
placeholder = '',
chatID = null,
}) => {
const [files, setFiles] = useState([]);
const [files, setFiles] = useState([])
useEffect(() => {
setFiles([]);
}, [chatID]);
setFiles([])
}, [chatID])
const handleMessageObject = () => {
if (message.length > 0 || files.length > 0) {
@@ -34,37 +34,37 @@ const ChatInput = ({
customPayload: {
files,
},
});
setMessage("");
setFiles([]);
})
setMessage('')
setFiles([])
}
};
}
const handleKeyPress = (e) => {
if (e?.nativeEvent?.key?.toLowerCase() === "enter") {
handleMessageObject();
if (e?.nativeEvent?.key?.toLowerCase() === 'enter') {
handleMessageObject()
}
};
}
return (
<View
style={{
...Style.containerItem,
backgroundColor: Palette.transparentDarkPurple,
justifyContent: "center",
justifyContent: 'center',
borderWidth: 1,
borderColor: Palette.ultraLightWhite,
overflow: "hidden",
width: "100%",
height: "auto",
overflow: 'hidden',
width: '100%',
height: 'auto',
padding: 0,
...containerStyle,
}}
>
<BlurView
intensity={Platform.OS !== "ios" ? 10 : 30}
intensity={Platform.OS !== 'ios' ? 10 : 30}
tint="dark"
style={{ flex: 1, justifyContent: "center" }}
style={{ flex: 1, justifyContent: 'center' }}
// experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
// }
@@ -101,13 +101,13 @@ const ChatInput = ({
value={message}
onChangeText={setMessage}
style={{
width: "85%",
...Fonts({ type: "default", style: {} }),
width: '85%',
...Fonts({ type: 'default', style: {} }),
}}
keyboardAppearance="dark"
{...(!isWeb
? {
returnKeyType: "send",
returnKeyType: 'send',
onSubmitEditing: onSendMessage,
}
: {
@@ -118,13 +118,13 @@ const ChatInput = ({
<Pressable
onPress={handleMessageObject}
style={{
position: "absolute",
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 50,
justifyContent: "center",
alignItems: "center",
justifyContent: 'center',
alignItems: 'center',
}}
>
<Image
@@ -138,7 +138,7 @@ const ChatInput = ({
</View>
</BlurView>
</View>
);
};
)
}
export default ChatInput;
export default ChatInput
+70 -94
View File
@@ -1,78 +1,66 @@
import { useState, useRef, useEffect, useGlobal, getGlobal } from "reactn";
import {
Pressable,
TextInput,
View,
Image,
Text,
Keyboard,
FlatList,
} from "react-native";
import { responsiveHeight } from "../actions/responsiveSizes.js";
import { useDataFromRef } from "react-native-minuit/src/hooks";
import { useKeyboard } from "@react-native-community/hooks";
import moment from "moment";
import { useState, useRef, useEffect, useGlobal, getGlobal } from 'reactn'
import { Pressable, TextInput, View, Image, Text, Keyboard, FlatList } from 'react-native'
import { responsiveHeight } from '../actions/responsiveSizes.js'
import { useDataFromRef } from 'react-native-minuit/src/hooks'
import { useKeyboard } from '@react-native-community/hooks'
import moment from 'moment'
import { Fonts, gutters, Palette } from "../styles";
import Style, { bubbleStyle } from "../styles/Style";
import { Fonts, gutters, Palette } from '../styles'
import Style, { bubbleStyle } from '../styles/Style'
import firebase, { chatsRef } from "../config/firebase";
import firebase, { chatsRef } from '../config/firebase'
import { formatNameForConfidentiality } from "../helpers/index.js";
import useLayoutType from "../hooks/useLayoutType.js";
import { formatNameForConfidentiality } from '../helpers/index.js'
import useLayoutType from '../hooks/useLayoutType.js'
import TypingLoader from "./TypingLoader";
import Avatar from "./Avatar";
import RenderChatFile from "./RenderChatFile.js";
import HyperlinkContainer from "./HyperlinkContainer.js";
import TypingLoader from './TypingLoader'
import Avatar from './Avatar'
import RenderChatFile from './RenderChatFile.js'
import HyperlinkContainer from './HyperlinkContainer.js'
export default ({
chatID = null,
layout = "default", // default | taskSideBar
layout = 'default', // default | taskSideBar
containerStyle = {},
messageListContainerStyle = {},
}) => {
const [currentUID] = useGlobal("currentUID");
const [currentProjectData] = useGlobal("currentProjectData");
const [currentUID] = useGlobal('currentUID')
const [currentProjectData] = useGlobal('currentProjectData')
const [isTyping] = useState(false);
const [isTyping] = useState(false)
const flatListRef = useRef();
const flatListRef = useRef()
const { isNative } = useLayoutType();
const { keyboardShown = false } = useKeyboard();
const { isNative } = useLayoutType()
const { keyboardShown = false } = useKeyboard()
const { data: messageList } = useDataFromRef({
ref: chatID
? chatsRef
.doc(chatID)
.collection("messages")
.orderBy("createdAt", "desc")
.limit(50)
? chatsRef.doc(chatID).collection('messages').orderBy('createdAt', 'desc').limit(50)
: null,
simpleRef: false,
listener: true,
condition: chatID,
refreshArray: [chatID],
documentID: "messageID",
});
documentID: 'messageID',
})
let conversation = [
isTyping ? { senderID: "minuit.ai", userTyping: true } : null,
isTyping ? { senderID: 'minuit.ai', userTyping: true } : null,
...(messageList || []),
].filter((item) => item);
].filter((item) => item)
if (layout === "default") {
conversation = conversation.reverse();
if (layout === 'default') {
conversation = conversation.reverse()
}
useEffect(() => {
if (flatListRef?.current && isNative && keyboardShown) {
flatListRef?.current?.scrollToEnd?.({ animated: true });
flatListRef?.current?.scrollToEnd?.({ animated: true })
}
}, [keyboardShown, isNative]);
}, [keyboardShown, isNative])
return (
<View
@@ -92,33 +80,30 @@ export default ({
}}
showsVerticalScrollIndicator={false}
keyExtractor={(item, index) =>
item?.messageID
? `${item?.messageID?.toString()}-${index}`
: `no-messageID-${index}`
item?.messageID ? `${item?.messageID?.toString()}-${index}` : `no-messageID-${index}`
}
renderItem={({
item: {
createdAt = null,
senderID,
senderName = "",
senderName = '',
senderProfilePicture = null,
text = "",
text = '',
userTyping = false,
files = [],
},
index,
}) => {
const isCurrentUser = senderID === currentUID;
const isChatbot = senderID === "minuit.ai";
const isCurrentUser = senderID === currentUID
const isChatbot = senderID === 'minuit.ai'
const senderData =
currentProjectData?.teamMembers?.[senderID] || {};
const senderData = currentProjectData?.teamMembers?.[senderID] || {}
const isDayChange =
moment(createdAt?.toDate()).format("DD/MM/YYYY") !==
moment(
conversation[index - 1]?.createdAt?.toDate() || new Date()
).format("DD/MM/YYYY") || !conversation[index - 1];
moment(createdAt?.toDate()).format('DD/MM/YYYY') !==
moment(conversation[index - 1]?.createdAt?.toDate() || new Date()).format(
'DD/MM/YYYY'
) || !conversation[index - 1]
return (
<>
@@ -132,21 +117,19 @@ export default ({
<Text
style={{
...Fonts({
type: "default",
type: 'default',
color: Palette.white,
style: { textAlign: "center", opacity: 0.5 },
style: { textAlign: 'center', opacity: 0.5 },
}),
}}
>
{moment(createdAt?.toDate()).format(
"[Le] DD/MM/YYYY [à] HH:mm"
)}
{moment(createdAt?.toDate()).format('[Le] DD/MM/YYYY [à] HH:mm')}
</Text>
<View
style={{
...Style.separatorHorizontal,
width: "100%",
width: '100%',
}}
/>
</View>
@@ -156,9 +139,9 @@ export default ({
style={[
Style.containerRow,
{
flexDirection: isCurrentUser ? "row-reverse" : "row",
alignItems: "flex-end",
width: "100%",
flexDirection: isCurrentUser ? 'row-reverse' : 'row',
alignItems: 'flex-end',
width: '100%',
marginBottom: gutters / 2,
},
]}
@@ -175,21 +158,15 @@ export default ({
}
: {
marginRight: gutters / 2,
backgroundColor: "transparent",
backgroundColor: 'transparent',
borderColor: Palette.primary,
borderWidth: 1,
}),
}}
>
<Avatar
name={
isChatbot ? "m" : senderData?.name || senderName || ""
}
url={
senderData?.profilePictureURL ||
senderProfilePicture ||
null
}
name={isChatbot ? 'm' : senderData?.name || senderName || ''}
url={senderData?.profilePictureURL || senderProfilePicture || null}
size={35}
/>
</View>
@@ -197,7 +174,7 @@ export default ({
<View
style={{
alignItems: isCurrentUser ? "flex-end" : "flex-start",
alignItems: isCurrentUser ? 'flex-end' : 'flex-start',
}}
>
{files?.map((props, index) => (
@@ -209,8 +186,8 @@ export default ({
index !== files?.length - 1
? gutters / 2
: text?.length > 0 || userTyping
? gutters / 2
: 0,
? gutters / 2
: 0,
}}
/>
)) || null}
@@ -232,11 +209,11 @@ export default ({
<HyperlinkContainer>
<Text
style={Fonts({
type: "default",
type: 'default',
style: {
color: Palette.white,
textAlign: isCurrentUser ? "right" : "left",
width: "100%",
textAlign: isCurrentUser ? 'right' : 'left',
width: '100%',
},
})}
>
@@ -249,30 +226,29 @@ export default ({
</View>
</View>
</>
);
)
}}
/>
</View>
</View>
);
};
)
}
export const onSendMessage = async ({
chatID = null,
projectID = null,
message = "",
message = '',
setMessage = () => {},
setIsTyping = () => {},
customPayload = {},
}) => {
try {
const currentUID = getGlobal()?.currentUID || null;
const { name = "", profilePictureURL = null } =
getGlobal()?.currentUserData || {};
const currentUID = getGlobal()?.currentUID || null
const { name = '', profilePictureURL = null } = getGlobal()?.currentUserData || {}
if (message.length > 0 || customPayload?.files?.length > 0) {
Keyboard.dismiss();
setMessage("");
Keyboard.dismiss()
setMessage('')
const messageData = {
projectID,
@@ -282,13 +258,13 @@ export const onSendMessage = async ({
senderProfilePicture: profilePictureURL || null,
text: message,
...customPayload,
};
}
await chatsRef.doc(chatID).collection("messages").add(messageData);
await chatsRef.doc(chatID).collection('messages').add(messageData)
}
} catch (error) {
console.log(error);
console.log(error)
} finally {
setIsTyping(false);
setIsTyping(false)
}
};
}
+7 -7
View File
@@ -1,6 +1,6 @@
import React from "react";
import { Image } from "react-native";
import { icons } from "../assets";
import React from 'react'
import { Image } from 'react-native'
import { icons } from '../assets'
const CoinIcon = ({ size = 22, style }) => {
return (
@@ -10,12 +10,12 @@ const CoinIcon = ({ size = 22, style }) => {
{
width: size,
height: size,
resizeMode: "contain",
resizeMode: 'contain',
},
style,
]}
/>
);
};
)
}
export default CoinIcon;
export default CoinIcon
+14 -14
View File
@@ -1,14 +1,14 @@
import { BlurView } from "expo-blur";
import React from "react";
import { Pressable, Text } from "react-native";
import { Routes } from "../navigation";
import { navigate } from "../navigation/NavigationService";
import { Palette } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { BlurView } from 'expo-blur'
import React from 'react'
import { Pressable, Text } from 'react-native'
import { Routes } from '../navigation'
import { navigate } from '../navigation/NavigationService'
import { Palette } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts'
export default function ConnectBtn({ style }) {
const handlePress = () => {
navigate(Routes.Login);
};
navigate(Routes.Login)
}
return (
<Pressable
@@ -22,9 +22,9 @@ export default function ConnectBtn({ style }) {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 15,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text
@@ -35,9 +35,9 @@ export default function ConnectBtn({ style }) {
marginRight: 5,
}}
>
{"Se connecter"}
{'Se connecter'}
</Text>
</BlurView>
</Pressable>
);
)
}
+40 -56
View File
@@ -1,42 +1,42 @@
import React from "react";
import { StyleSheet, Text, View } from "react-native";
import CoinIcon from "./CoinIcon";
import { FONT_FAMILY } from "../styles/Fonts";
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import CoinIcon from './CoinIcon'
import { FONT_FAMILY } from '../styles/Fonts'
const defaultFormatOptions = {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
};
}
const formatAmount = (value, options = defaultFormatOptions) => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null
}
try {
return new Intl.NumberFormat("fr-FR", {
return new Intl.NumberFormat('fr-FR', {
...defaultFormatOptions,
...options,
}).format(value);
}).format(value)
} catch (_error) {
return `${value}`;
return `${value}`
}
};
}
const extractNumericValue = (value) => {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === "string") {
const parsed = Number(value);
if (typeof value === 'string') {
const parsed = Number(value)
if (Number.isFinite(parsed)) {
return parsed;
return parsed
}
}
return null;
};
return null
}
const CreditAmount = ({
value,
@@ -44,78 +44,62 @@ const CreditAmount = ({
textStyle,
iconSize = 18,
iconStyle,
iconPosition = "right",
iconPosition = 'right',
gap = 6,
showPlus = false,
formatterOptions,
accessibilityLabel,
}) => {
const numericValue = React.useMemo(
() => extractNumericValue(value),
[value],
);
const numericValue = React.useMemo(() => extractNumericValue(value), [value])
const resolvedValue =
numericValue !== null
? numericValue
: value ?? 0;
const resolvedValue = numericValue !== null ? numericValue : (value ?? 0)
const formattedValue =
numericValue !== null
? formatAmount(resolvedValue, formatterOptions) ?? `${resolvedValue}`
: typeof resolvedValue === "string"
? resolvedValue
: `${resolvedValue}`;
? (formatAmount(resolvedValue, formatterOptions) ?? `${resolvedValue}`)
: typeof resolvedValue === 'string'
? resolvedValue
: `${resolvedValue}`
const prefix =
numericValue !== null && showPlus && numericValue > 0 ? "+" : "";
const prefix = numericValue !== null && showPlus && numericValue > 0 ? '+' : ''
const a11yLabel =
accessibilityLabel || `${prefix}${formattedValue} pièces`;
const a11yLabel = accessibilityLabel || `${prefix}${formattedValue} pièces`
const containerStyles = Array.isArray(style)
? [styles.container, { gap }, ...style]
: [styles.container, { gap }, style];
: [styles.container, { gap }, style]
const textStyles = Array.isArray(textStyle)
? [styles.value, ...textStyle]
: [styles.value, textStyle];
: [styles.value, textStyle]
const iconStyles = Array.isArray(iconStyle)
? [styles.icon, ...iconStyle]
: [styles.icon, iconStyle];
: [styles.icon, iconStyle]
return (
<View
style={containerStyles}
accessibilityRole="text"
accessibilityLabel={a11yLabel}
>
{iconPosition === "left" ? (
<CoinIcon size={iconSize} style={iconStyles} />
) : null}
<View style={containerStyles} accessibilityRole="text" accessibilityLabel={a11yLabel}>
{iconPosition === 'left' ? <CoinIcon size={iconSize} style={iconStyles} /> : null}
<Text style={textStyles}>{`${prefix}${formattedValue}`}</Text>
{iconPosition === "right" ? (
<CoinIcon size={iconSize} style={iconStyles} />
) : null}
{iconPosition === 'right' ? <CoinIcon size={iconSize} style={iconStyles} /> : null}
</View>
);
};
)
}
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
},
value: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 16,
color: "#fff",
color: '#fff',
},
icon: {
width: 18,
height: 18,
},
});
})
export default CreditAmount;
export default CreditAmount
+6 -7
View File
@@ -1,8 +1,7 @@
import Dialog from "react-native-dialog";
export const Container = Dialog.Container;
export const Button = Dialog.Button;
export const Title = Dialog.Title;
export const Input = Dialog.Input;
export const Description = Dialog.Description;
import Dialog from 'react-native-dialog'
export const Container = Dialog.Container
export const Button = Dialog.Button
export const Title = Dialog.Title
export const Input = Dialog.Input
export const Description = Dialog.Description
+24 -29
View File
@@ -1,29 +1,29 @@
import React from "react";
import { Text, View } from "react-native";
import React from 'react'
import { Text, View } from 'react-native'
import { Input as MinuitInput } from "../Input";
import { BaseButton as MinuitButton } from "../Button";
import Overlay from "../Overlay";
import { Input as MinuitInput } from '../Input'
import { BaseButton as MinuitButton } from '../Button'
import Overlay from '../Overlay'
import { Fonts, Palette, gutters } from "../../styles";
import Style from "../../styles/Style";
import { Fonts, Palette, gutters } from '../../styles'
import Style from '../../styles/Style'
export const Container = ({ visible, setVisible = () => null, children }) => {
return (
<Overlay isVisible={visible}>
<View style={{ ...Style.containerModal }}>{children}</View>
</Overlay>
);
};
)
}
export const Title = ({ children }) => {
return (
<Text
style={{
...Fonts({
type: "title",
type: 'title',
style: {
textAlign: "center",
textAlign: 'center',
marginBottom: gutters,
},
}),
@@ -31,17 +31,17 @@ export const Title = ({ children }) => {
>
{children}
</Text>
);
};
)
}
export const Description = ({ children }) => {
return (
<Text
style={{
...Fonts({
type: "default",
type: 'default',
style: {
textAlign: "center",
textAlign: 'center',
marginBottom: gutters / 2,
},
}),
@@ -49,29 +49,24 @@ export const Description = ({ children }) => {
>
{children}
</Text>
);
};
)
}
export const Button = ({
label,
onPress,
type = "primary",
containerStyle = {},
}) => {
export const Button = ({ label, onPress, type = 'primary', containerStyle = {} }) => {
return (
<MinuitButton
containerStyle={{
width: "100%",
alignSelf: "center",
width: '100%',
alignSelf: 'center',
...containerStyle,
}}
text={label}
onPress={onPress}
type={type}
/>
);
};
)
}
export const Input = (props) => {
return <MinuitInput {...props} setValue={props.onChangeText} />;
};
return <MinuitInput {...props} setValue={props.onChangeText} />
}
+152 -154
View File
@@ -1,27 +1,27 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import * as DocumentPicker from "expo-document-picker";
import * as ImagePicker from "expo-image-picker";
import moment from "moment";
import { Image, Pressable, Text, View } from "react-native";
import Compressor from "react-native-compressor";
import useMinuit from "react-native-minuit/src/hooks/useMinuit";
import React, { useCallback, useEffect, useRef, useState } from "reactn";
import { useActionSheet } from '@expo/react-native-action-sheet'
import * as DocumentPicker from 'expo-document-picker'
import * as ImagePicker from 'expo-image-picker'
import moment from 'moment'
import { Image, Pressable, Text, View } from 'react-native'
import Compressor from 'react-native-compressor'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
import React, { useCallback, useEffect, useRef, useState } from 'reactn'
import { arrayUnion } from "../config/firebase";
import { arrayUnion } from '../config/firebase'
import { icons } from "../assets";
import { Fonts, Palette } from "../styles";
import Style, { gutterConstant, mainBorderRadius } from "../styles/Style";
import { icons } from '../assets'
import { Fonts, Palette } from '../styles'
import Style, { gutterConstant, mainBorderRadius } from '../styles/Style'
import { uploadFileToFirebase } from "../helpers/uploadToFirebase";
import useLayoutType from "../hooks/useLayoutType";
import { uploadFileToFirebase } from '../helpers/uploadToFirebase'
import useLayoutType from '../hooks/useLayoutType'
const NATIVE_OPTIONS = [
"Importer depuis la galerie",
"Ajouter un fichier",
"Prendre une photo ou une vidéo",
"Annuler",
];
'Importer depuis la galerie',
'Ajouter un fichier',
'Prendre une photo ou une vidéo',
'Annuler',
]
const DocumentDropZone = ({
containerStyle = {},
@@ -35,162 +35,162 @@ const DocumentDropZone = ({
customElement = null,
shouldReturnObject = false,
}) => {
const dropRef = useRef(null);
const dropRef = useRef(null)
const [isDragging, setIsDragging] = useState(false);
const [isDragging, setIsDragging] = useState(false)
const { setIsLoading, setTooltip } = useMinuit();
const { isDesktop, isWeb, isNative } = useLayoutType();
const { showActionSheetWithOptions } = useActionSheet();
const { setIsLoading, setTooltip } = useMinuit()
const { isDesktop, isWeb, isNative } = useLayoutType()
const { showActionSheetWithOptions } = useActionSheet()
useEffect(() => {
if (isWeb && dropRef.current) {
const el = dropRef.current;
const el = dropRef.current
const handleDragIn = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragOut = (e) => {
e.preventDefault();
e.stopPropagation();
e.preventDefault()
e.stopPropagation()
if (!dropRef.current.contains(e.relatedTarget)) {
console.log("drag left");
setIsDragging(false);
console.log('drag left')
setIsDragging(false)
}
};
}
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
let files = [...e.dataTransfer.files];
let files = [...e.dataTransfer.files]
if (files.length > 0) {
for (const file of files) {
handleWebFile({ file });
handleWebFile({ file })
}
}
};
}
el.addEventListener("dragenter", handleDragIn);
el.addEventListener("dragleave", handleDragOut);
el.addEventListener("dragover", (e) => e.preventDefault());
el.addEventListener("drop", handleDrop);
el.addEventListener('dragenter', handleDragIn)
el.addEventListener('dragleave', handleDragOut)
el.addEventListener('dragover', (e) => e.preventDefault())
el.addEventListener('drop', handleDrop)
return () => {
el.removeEventListener("dragenter", handleDragIn);
el.removeEventListener("dragleave", handleDragOut);
el.removeEventListener("dragover", (e) => e.preventDefault());
el.removeEventListener("drop", handleDrop);
};
el.removeEventListener('dragenter', handleDragIn)
el.removeEventListener('dragleave', handleDragOut)
el.removeEventListener('dragover', (e) => e.preventDefault())
el.removeEventListener('drop', handleDrop)
}
}
}, [dropRef?.current]);
}, [dropRef?.current])
const handleWebFile = ({ file }) => {
try {
const { type = "" } = file;
const reader = new FileReader();
const { type = '' } = file
const reader = new FileReader()
reader.onloadend = () => {
const base64 = reader.result.split(",")[1]; // Vous pourriez avoir besoin de cette valeur base64 pour un upload direct
const uri = reader.result; // URI en base64 du fichier
const base64 = reader.result.split(',')[1] // Vous pourriez avoir besoin de cette valeur base64 pour un upload direct
const uri = reader.result // URI en base64 du fichier
console.log("file", file);
console.log('file', file)
onUploadDocument({ files: [{ name: file.name, uri, type }] });
};
onUploadDocument({ files: [{ name: file.name, uri, type }] })
}
reader.onerror = (err) => {
console.error("FileReader error", err);
};
console.error('FileReader error', err)
}
reader.readAsDataURL(file); // Lire le fichier et déclencher reader.onloadend lorsque c'est fait
reader.readAsDataURL(file) // Lire le fichier et déclencher reader.onloadend lorsque c'est fait
} catch (error) {
console.log(error);
setTooltip({ text: error.message, type: "error" });
console.log(error)
setTooltip({ text: error.message, type: 'error' })
}
};
}
const onAddFile = async ({} = {}) => {
try {
if (isNative) {
const cancelButtonIndex = NATIVE_OPTIONS.length - 1;
const cancelButtonIndex = NATIVE_OPTIONS.length - 1
showActionSheetWithOptions(
{
options: NATIVE_OPTIONS,
cancelButtonIndex,
userInterfaceStyle: "dark",
userInterfaceStyle: 'dark',
...Style.actionSheet,
},
async (selectedIndex) => {
if (selectedIndex !== cancelButtonIndex) {
switch (selectedIndex) {
case 0:
onChooseLibrary();
break;
onChooseLibrary()
break
case 1:
onChooseDocumentPicker();
break;
onChooseDocumentPicker()
break
case 2:
onTakePicture();
break;
onTakePicture()
break
default:
break;
break
}
}
}
);
)
} else {
onChooseDocumentPicker();
onChooseDocumentPicker()
}
} catch (error) {
console.log(error);
setTooltip({ text: error.message, type: "error" });
console.log(error)
setTooltip({ text: error.message, type: 'error' })
} finally {
setIsLoading(false);
setIsLoading(false)
}
};
}
const onChooseLibrary = async ({} = {}) => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.All,
allowsEditing: false,
quality: 3,
});
})
if (result?.assets?.[0]?.uri) {
const { uri, fileName = "" } = result?.assets?.[0];
const { uri, fileName = '' } = result?.assets?.[0]
onUploadDocument({
files: [
{
name: getAssetName({ fileName, uri }),
uri,
type: "IMAGE",
type: 'IMAGE',
},
],
});
})
} else {
throw new Error("Aucune image sélectionnée");
throw new Error('Aucune image sélectionnée')
}
};
}
const onChooseDocumentPicker = async ({} = {}) => {
const result = await DocumentPicker.getDocumentAsync({
type: "*/*",
type: '*/*',
copyToCacheDirectory: false,
});
})
if (result?.assets?.length > 0) {
const { name = "", uri = null } = result?.assets[0] || {};
const { name = '', uri = null } = result?.assets[0] || {}
if (!uri) {
throw new Error("Erreur lors de l'ajout du document");
throw new Error("Erreur lors de l'ajout du document")
}
onUploadDocument({
@@ -200,117 +200,117 @@ const DocumentDropZone = ({
uri,
},
],
});
})
} else {
console.log(result);
throw new Error("Erreur lors de l'ajout du document");
console.log(result)
throw new Error("Erreur lors de l'ajout du document")
}
};
}
const onTakePicture = useCallback(async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
const { status } = await ImagePicker.requestCameraPermissionsAsync()
if (status !== "granted") {
throw new Error("Permissions caméra non accordées");
if (status !== 'granted') {
throw new Error('Permissions caméra non accordées')
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.All,
allowsEditing: true,
quality: 1,
});
})
if (result?.assets?.[0]?.uri) {
const { uri, fileName = "" } = result?.assets?.[0];
const { uri, fileName = '' } = result?.assets?.[0]
onUploadDocument({
files: [
{
name: getAssetName({ fileName, uri }),
uri,
type: "IMAGE",
type: 'IMAGE',
},
],
});
})
} else {
throw new Error("Aucune image sélectionnée");
throw new Error('Aucune image sélectionnée')
}
}, []);
}, [])
const getDefaultFileName = () => {
const randomID = Math.random().toString(36).substring(7);
return `${moment().format(`DD_MM_YYYY_HH_mm_ss`)}_${randomID}`;
};
const randomID = Math.random().toString(36).substring(7)
return `${moment().format(`DD_MM_YYYY_HH_mm_ss`)}_${randomID}`
}
const getAssetName = ({ fileName = "", uri = "" }) => {
let name = fileName;
const getAssetName = ({ fileName = '', uri = '' }) => {
let name = fileName
if (!name && uri?.startsWith("file://")) {
const splittedArray = uri?.split("/") || [];
name = splittedArray?.[splittedArray?.length - 1] || "";
if (!name && uri?.startsWith('file://')) {
const splittedArray = uri?.split('/') || []
name = splittedArray?.[splittedArray?.length - 1] || ''
}
if (!name?.length) {
let extension = uri?.split(";")?.[0]?.split("/")?.[1] || "";
let extension = uri?.split(';')?.[0]?.split('/')?.[1] || ''
const defaultFileName = getDefaultFileName();
const defaultFileName = getDefaultFileName()
if (extension?.length) {
name = `${defaultFileName}.${extension}`;
name = `${defaultFileName}.${extension}`
} else {
name = `${defaultFileName}`;
name = `${defaultFileName}`
}
}
return name;
};
return name
}
const onUploadDocument = async ({ files = [] } = {}) => {
try {
setIsLoading(true);
setIsLoading(true)
const filesToUpdate = [];
const filesToUpdate = []
await Promise.all(
files.map(async (file) => {
const { name = "", uri = null } = file;
const { name = '', uri = null } = file
console.log(file);
console.log(file)
if (!uri) {
throw new Error("Erreur lors de l'ajout du document");
throw new Error("Erreur lors de l'ajout du document")
}
const fileName = name || getDefaultFileName();
let type = "FILE";
const fileName = name || getDefaultFileName()
let type = 'FILE'
if (name?.toLowerCase()?.match(/\.(jpeg|jpg|gif|png)$/) != null) {
type = "IMAGE";
type = 'IMAGE'
}
if (name?.toLowerCase()?.match(/\.(mp4|mov|avi|mkv)$/) != null) {
type = "VIDEO";
type = 'VIDEO'
}
let compressedURI = uri;
let thumbnailURI = null;
let compressedURI = uri
let thumbnailURI = null
if (isNative) {
if (type === "IMAGE") {
if (type === 'IMAGE') {
compressedURI = await Compressor.Image.compress(uri, {
compressionMethod: "manual",
compressionMethod: 'manual',
maxWidth: 1000,
quality: 0.8,
});
} else if (type === "VIDEO") {
compressedURI = await Compressor.Video.compress(uri);
})
} else if (type === 'VIDEO') {
compressedURI = await Compressor.Video.compress(uri)
}
}
const { resultURI = null } = await uploadFileToFirebase({
uri: compressedURI,
path: `documents/${documentID}/files/${fileName}`,
});
})
if (resultURI) {
if (shouldReturnObject) {
@@ -319,39 +319,39 @@ const DocumentDropZone = ({
uri: resultURI,
type,
thumbnailURI,
});
})
} else {
filesToUpdate.push(resultURI);
filesToUpdate.push(resultURI)
}
} else {
console.log("resultURI is null");
console.log('resultURI is null')
}
})
);
)
if (filesToUpdate.length > 0) {
if (documentExists) {
await collectionRef.doc(documentID).update({
files: arrayUnion(...filesToUpdate),
});
})
}
if (setFiles) {
setFiles((prev) => [...prev, ...filesToUpdate]);
setFiles((prev) => [...prev, ...filesToUpdate])
}
}
setTooltip({ text: "Document(s) ajouté(s) avec succès" });
setTooltip({ text: 'Document(s) ajouté(s) avec succès' })
} catch (error) {
console.log(error);
setTooltip({ text: error.message, type: "error" });
console.log(error)
setTooltip({ text: error.message, type: 'error' })
} finally {
setIsLoading(false);
setIsLoading(false)
}
};
}
if (!documentID) {
return null;
return null
}
return (
@@ -361,11 +361,9 @@ const DocumentDropZone = ({
<View
style={{
...Style.containerCenter,
backgroundColor: isDragging
? Palette.transparentGreen
: Palette.transparentPrimary,
backgroundColor: isDragging ? Palette.transparentGreen : Palette.transparentPrimary,
borderRadius: mainBorderRadius,
borderStyle: "dashed",
borderStyle: 'dashed',
borderWidth: 2,
borderColor: isDragging ? Palette.green : Palette.primary,
...containerStyle,
@@ -380,20 +378,20 @@ const DocumentDropZone = ({
/>
<Text
style={{
...Fonts({ type: "default" }),
textAlign: "center",
...Fonts({ type: 'default' }),
textAlign: 'center',
color: Palette.white,
}}
>
{isWeb && isDesktop
? `Glissez-déposez ici\nles fichiers, images et vidéos\nà ajouter.`
: "Ajouter une image,\nun fichier ou une vidéo."}
: 'Ajouter une image,\nun fichier ou une vidéo.'}
</Text>
</View>
)}
</Pressable>
</View>
);
};
)
}
export default DocumentDropZone;
export default DocumentDropZone
+16 -22
View File
@@ -1,40 +1,34 @@
import React, { useState, useEffect } from "react";
import { View } from "react-native";
import { Image } from "expo-image";
import React, { useState, useEffect } from 'react'
import { View } from 'react-native'
import { Image } from 'expo-image'
const DynamicImage = ({ uri, children, ...props }) => {
const [loading, setLoading] = useState(true);
const [numRetries, setNumRetries] = useState(0);
const [loading, setLoading] = useState(true)
const [numRetries, setNumRetries] = useState(0)
useEffect(() => {
if (numRetries < 15) {
const timer = setTimeout(() => {
setLoading(true);
}, 2500);
return () => clearTimeout(timer);
setLoading(true)
}, 2500)
return () => clearTimeout(timer)
}
}, [numRetries]);
}, [numRetries])
const handleError = () => {
setLoading(false);
setNumRetries(numRetries + 1);
};
setLoading(false)
setNumRetries(numRetries + 1)
}
return (
<View style={{ flex: 1 }}>
{loading && numRetries < 15 && uri ? (
<Image
source={uri}
contentFit="cover"
transition={500}
{...props}
onError={handleError}
/>
<Image source={uri} contentFit="cover" transition={500} {...props} onError={handleError} />
) : (
<View {...props}>{children}</View>
)}
</View>
);
};
)
}
export default DynamicImage;
export default DynamicImage
+22 -28
View File
@@ -1,60 +1,54 @@
import React, { useGlobal } from "reactn";
import { View, Text } from "react-native";
import React, { useGlobal } from 'reactn'
import { View, Text } from 'react-native'
import useLayoutType from "../hooks/useLayoutType";
import useLayoutType from '../hooks/useLayoutType'
import Button from "./Button";
import Button from './Button'
import { Style, Fonts } from "../styles";
import { responsiveScreenHeight } from "react-native-responsive-dimensions";
import { Style, Fonts } from '../styles'
import { responsiveScreenHeight } from 'react-native-responsive-dimensions'
const EmptyFlashListPlaceholder = ({
loading = false,
text = "-",
buttonData = {},
}) => {
const [, setShowSearch] = useGlobal("showSearch");
const EmptyFlashListPlaceholder = ({ loading = false, text = '-', buttonData = {} }) => {
const [, setShowSearch] = useGlobal('showSearch')
const { isMobile } = useLayoutType;
const { isMobile } = useLayoutType
if (loading) {
return (
<Text style={Fonts({ type: "default", style: { textAlign: "center" } })}>
<Text style={Fonts({ type: 'default', style: { textAlign: 'center' } })}>
Chargement en cours...
</Text>
);
)
}
return (
<View
style={{
...Style.containerCenter,
alignSelf: "center",
width: isMobile ? "100%" : "50%",
alignSelf: 'center',
width: isMobile ? '100%' : '50%',
height: responsiveScreenHeight(50),
}}
>
<Text style={Fonts({ type: "default", style: { textAlign: "center" } })}>
{text}
</Text>
<Text style={Fonts({ type: 'default', style: { textAlign: 'center' } })}>{text}</Text>
{buttonData?.text?.length > 0 && (
<Button
text={buttonData?.text || "Ajouter une tâche"}
text={buttonData?.text || 'Ajouter une tâche'}
type="secondary"
onPress={() => {
setShowSearch(false);
setShowSearch(false)
buttonData?.onPress?.() || (() => console.log("null"));
buttonData?.onPress?.() || (() => console.log('null'))
}}
containerStyle={{
alignSelf: "center",
width: "100%",
alignSelf: 'center',
width: '100%',
}}
/>
)}
</View>
);
};
)
}
export default EmptyFlashListPlaceholder;
export default EmptyFlashListPlaceholder
+158 -188
View File
@@ -1,54 +1,41 @@
import { BlurView } from "expo-blur";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Animated,
FlatList,
Platform,
StyleSheet,
View,
useWindowDimensions,
} from "react-native";
import { responsiveHeight } from "../../actions/responsiveSizes";
import { ai, cardsImg } from "../../assets";
import { gutters } from "../../styles";
import { getCreationStageStates } from "../../utils/projectStages";
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
import PersonaCard from "../cards/PersonaCard/PersonaCard";
import { BlurView } from 'expo-blur'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Animated, FlatList, Platform, StyleSheet, View, useWindowDimensions } from 'react-native'
import { responsiveHeight } from '../../actions/responsiveSizes'
import { ai, cardsImg } from '../../assets'
import { gutters } from '../../styles'
import { getCreationStageStates } from '../../utils/projectStages'
import AnimatedPaginationDot from '../AnimatedPaginationDot/AnimatedPaginationDot'
import PersonaCard from '../cards/PersonaCard/PersonaCard'
const STAGE_CARD_CONTENT = [
{
key: "songwriter",
title: "Céline",
description: "Lets write lyrics together !",
key: 'songwriter',
title: 'Céline',
description: 'Lets write lyrics together !',
image: ai.leftIcon,
},
{
key: "beatmaker",
title: "Theo",
key: 'beatmaker',
title: 'Theo',
description: "Come back, when you'll have lyrics!",
image: ai.rightIcon,
},
{
key: "director",
title: "Theo",
key: 'director',
title: 'Theo',
description: "Theo t'accompagne pour créer ton playback.",
image: ai.rightIcon,
},
{
key: "publisher",
title: "Publication",
description: "Ta vidéo est prête ? Direction YouTube !",
key: 'publisher',
title: 'Publication',
description: 'Ta vidéo est prête ? Direction YouTube !',
image: cardsImg.production,
},
];
]
const WEB_SCROLL_INACTIVE_DELTA = 0.05;
const WEB_SCROLL_INACTIVE_DELTA = 0.05
const FeatureCarousel = ({
style,
@@ -59,231 +46,225 @@ const FeatureCarousel = ({
}) => {
const stageStates = useMemo(() => {
if (stageStatesProp) {
return stageStatesProp;
return stageStatesProp
}
return getCreationStageStates(selectedProject);
}, [stageStatesProp, selectedProject]);
return getCreationStageStates(selectedProject)
}, [stageStatesProp, selectedProject])
const stageStatesByKey = useMemo(() => {
if (!Array.isArray(stageStates)) {
return {};
return {}
}
return stageStates.reduce((acc, stage) => {
if (stage?.key) {
acc[stage.key] = stage;
acc[stage.key] = stage
}
return acc;
}, {});
}, [stageStates]);
return acc
}, {})
}, [stageStates])
const carouselItems = useMemo(
() =>
STAGE_CARD_CONTENT.map((item) => {
const state = stageStatesByKey[item.key];
const state = stageStatesByKey[item.key]
return {
...item,
isLocked: state?.isLocked ?? true,
description: state?.description ?? item.description,
};
}
}),
[stageStatesByKey]
);
)
const { height: windowHeight } = useWindowDimensions();
const isWeb = Platform.OS === "web";
const { height: windowHeight } = useWindowDimensions()
const isWeb = Platform.OS === 'web'
const [viewportHeight, setViewportHeight] = useState(() =>
Math.max(windowHeight, 1)
);
const [viewportHeight, setViewportHeight] = useState(() => Math.max(windowHeight, 1))
const updateSnapHeight = useCallback((height) => {
if (!height || Number.isNaN(height)) {
return;
return
}
setViewportHeight((prev) => {
if (prev == null || Math.abs(prev - height) > 0.5) {
return height;
return height
}
return prev;
});
}, []);
return prev
})
}, [])
useEffect(() => {
updateSnapHeight(Math.max(windowHeight, 1));
}, [updateSnapHeight, windowHeight]);
updateSnapHeight(Math.max(windowHeight, 1))
}, [updateSnapHeight, windowHeight])
const itemHeight = Math.max(viewportHeight, 1);
const itemHeight = Math.max(viewportHeight, 1)
const listRef = useRef(null);
const pendingScrollRef = useRef(false);
const alignTimeoutRef = useRef(null);
const activeIndexRef = useRef(
typeof activeIndex === "number" ? activeIndex : 0
);
const onActiveIndexChangeRef = useRef(onActiveIndexChange);
const scrollY = useRef(new Animated.Value(0)).current;
const listRef = useRef(null)
const pendingScrollRef = useRef(false)
const alignTimeoutRef = useRef(null)
const activeIndexRef = useRef(typeof activeIndex === 'number' ? activeIndex : 0)
const onActiveIndexChangeRef = useRef(onActiveIndexChange)
const scrollY = useRef(new Animated.Value(0)).current
useEffect(() => {
onActiveIndexChangeRef.current = onActiveIndexChange;
}, [onActiveIndexChange]);
onActiveIndexChangeRef.current = onActiveIndexChange
}, [onActiveIndexChange])
useEffect(() => {
if (typeof activeIndex === "number") {
activeIndexRef.current = activeIndex;
if (typeof activeIndex === 'number') {
activeIndexRef.current = activeIndex
}
}, [activeIndex]);
}, [activeIndex])
const clampIndex = useCallback(
(index) => {
if (!carouselItems.length) {
return 0;
return 0
}
if (index < 0) {
return 0;
return 0
}
if (index >= carouselItems.length) {
return carouselItems.length - 1;
return carouselItems.length - 1
}
return index;
return index
},
[carouselItems.length]
);
)
const clearPendingAlignment = useCallback(() => {
if (alignTimeoutRef.current != null) {
globalThis.clearTimeout(alignTimeoutRef.current);
alignTimeoutRef.current = null;
globalThis.clearTimeout(alignTimeoutRef.current)
alignTimeoutRef.current = null
}
}, []);
}, [])
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]);
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment])
const scrollToIndex = useCallback(
(index, animated = true, heightOverride) => {
const ref = listRef.current;
const ref = listRef.current
if (!ref) {
return;
return
}
const clamped = clampIndex(index);
const height =
heightOverride && heightOverride > 0 ? heightOverride : itemHeight;
const clamped = clampIndex(index)
const height = heightOverride && heightOverride > 0 ? heightOverride : itemHeight
if (isWeb) {
if (!height) {
return;
return
}
updateSnapHeight(height);
updateSnapHeight(height)
try {
ref.scrollToOffset({ offset: clamped * height, animated });
ref.scrollToOffset({ offset: clamped * height, animated })
} catch (_error) {
// Ignore scroll errors when list is not ready yet.
}
activeIndexRef.current = clamped;
return;
activeIndexRef.current = clamped
return
}
try {
pendingScrollRef.current = !!animated;
ref.scrollToIndex({ index: clamped, animated });
activeIndexRef.current = clamped;
pendingScrollRef.current = !!animated
ref.scrollToIndex({ index: clamped, animated })
activeIndexRef.current = clamped
} catch (_error) {
pendingScrollRef.current = false;
pendingScrollRef.current = false
}
},
[clampIndex, isWeb, itemHeight, updateSnapHeight]
);
)
useEffect(() => {
if (
listRef.current == null ||
typeof activeIndex !== "number" ||
typeof activeIndex !== 'number' ||
activeIndex < 0 ||
activeIndex >= carouselItems.length ||
(isWeb && !itemHeight)
) {
return;
return
}
scrollToIndex(activeIndex);
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]);
scrollToIndex(activeIndex)
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex])
useEffect(() => {
if (listRef.current == null || (isWeb && !itemHeight)) {
return;
return
}
scrollToIndex(activeIndexRef.current, false);
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex]);
scrollToIndex(activeIndexRef.current, false)
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex])
const alignToOffset = useCallback(
(offset, layoutHeight) => {
const height =
layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight;
const height = layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight
if (!height) {
return;
return
}
updateSnapHeight(height);
updateSnapHeight(height)
const currentIndex = activeIndexRef.current;
const rawIndex = height ? offset / height : currentIndex;
const currentIndex = activeIndexRef.current
const rawIndex = height ? offset / height : currentIndex
let nextIndex = currentIndex;
let nextIndex = currentIndex
if (isWeb) {
const delta = rawIndex - currentIndex;
const delta = rawIndex - currentIndex
if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) {
if (Math.abs(delta) <= 1) {
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1));
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1))
} else {
nextIndex = clampIndex(currentIndex + Math.round(delta));
nextIndex = clampIndex(currentIndex + Math.round(delta))
}
}
} else {
nextIndex = clampIndex(Math.round(rawIndex));
nextIndex = clampIndex(Math.round(rawIndex))
}
const hasChanged = nextIndex !== activeIndexRef.current;
const hasChanged = nextIndex !== activeIndexRef.current
if (hasChanged) {
activeIndexRef.current = nextIndex;
const callback = onActiveIndexChangeRef.current;
activeIndexRef.current = nextIndex
const callback = onActiveIndexChangeRef.current
if (callback) {
callback(nextIndex);
callback(nextIndex)
}
}
if (isWeb || hasChanged) {
const shouldAnimate = isWeb ? true : !isWeb;
scrollToIndex(nextIndex, shouldAnimate, height);
const shouldAnimate = isWeb ? true : !isWeb
scrollToIndex(nextIndex, shouldAnimate, height)
}
},
[clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight]
);
)
const handleScrollEnd = useCallback(
(event) => {
clearPendingAlignment();
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
alignToOffset(offsetY, layoutHeight);
clearPendingAlignment()
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
alignToOffset(offsetY, layoutHeight)
},
[alignToOffset, clearPendingAlignment]
);
)
const handleScroll = useCallback(
(event) => {
if (!isWeb) {
return;
return
}
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
clearPendingAlignment();
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
clearPendingAlignment()
alignTimeoutRef.current = globalThis.setTimeout(() => {
alignToOffset(offsetY, layoutHeight);
alignTimeoutRef.current = null;
}, 80);
alignToOffset(offsetY, layoutHeight)
alignTimeoutRef.current = null
}, 80)
},
[alignToOffset, clearPendingAlignment, isWeb]
);
)
const animatedScrollHandler = useMemo(
() =>
@@ -292,31 +273,26 @@ const FeatureCarousel = ({
listener: isWeb ? handleScroll : undefined,
}),
[handleScroll, isWeb, scrollY]
);
)
const handleLayout = useCallback(
(event) => {
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0;
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0
if (layoutHeight > 0) {
updateSnapHeight(layoutHeight);
updateSnapHeight(layoutHeight)
}
},
[updateSnapHeight]
);
)
const keyExtractor = useCallback((item) => item.key, []);
const keyExtractor = useCallback((item) => item.key, [])
const renderItem = useCallback(
({ item, index }) => (
<PersonaCard
item={item}
index={index}
isLock={item.isLocked}
height={itemHeight}
/>
<PersonaCard item={item} index={index} isLock={item.isLocked} height={itemHeight} />
),
[itemHeight]
);
)
const getItemLayout = useCallback(
(_data, index) => ({
@@ -325,50 +301,44 @@ const FeatureCarousel = ({
index,
}),
[itemHeight]
);
)
const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 });
const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 })
const handleViewableItemsChangedRef = useRef();
const handleViewableItemsChangedRef = useRef()
if (!handleViewableItemsChangedRef.current) {
handleViewableItemsChangedRef.current = ({ viewableItems }) => {
if (!viewableItems?.length) return;
const firstVisible = viewableItems.find((item) => item?.isViewable);
if (!firstVisible || firstVisible.index == null) return;
if (!viewableItems?.length) return
const firstVisible = viewableItems.find((item) => item?.isViewable)
if (!firstVisible || firstVisible.index == null) return
if (pendingScrollRef.current) {
if (firstVisible.index === activeIndexRef.current) {
pendingScrollRef.current = false;
pendingScrollRef.current = false
}
return;
return
}
const callback = onActiveIndexChangeRef.current;
const callback = onActiveIndexChangeRef.current
if (callback && firstVisible.index !== activeIndexRef.current) {
callback(firstVisible.index);
callback(firstVisible.index)
}
};
}
}
const snapOffsets = useMemo(() => {
if (!itemHeight || !isWeb) {
return undefined;
return undefined
}
return carouselItems.map((_, index) => index * itemHeight);
}, [carouselItems, isWeb, itemHeight]);
return carouselItems.map((_, index) => index * itemHeight)
}, [carouselItems, isWeb, itemHeight])
const blurIntensity = isWeb ? 80 : 30;
const blurIntensity = isWeb ? 80 : 30
const dotsWrapperStyle = useMemo(
() => [
styles.dotsWrapperBase,
isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative,
],
() => [styles.dotsWrapperBase, isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative],
[isWeb]
);
)
return (
<View
style={[styles.container, isWeb && styles.containerWeb, style]}
onLayout={handleLayout}
>
<View style={[styles.container, isWeb && styles.containerWeb, style]} onLayout={handleLayout}>
<FlatList
ref={listRef}
data={carouselItems}
@@ -385,11 +355,11 @@ const FeatureCarousel = ({
maxToRenderPerBatch={2}
windowSize={3}
scrollEventThrottle={16}
snapToAlignment={isWeb ? undefined : "start"}
snapToAlignment={isWeb ? undefined : 'start'}
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
snapToOffsets={snapOffsets}
disableIntervalMomentum={!isWeb}
decelerationRate={!isWeb ? "fast" : undefined}
decelerationRate={!isWeb ? 'fast' : undefined}
style={styles.list}
onScroll={animatedScrollHandler}
onMomentumScrollEnd={handleScrollEnd}
@@ -397,7 +367,7 @@ const FeatureCarousel = ({
/>
<BlurView
intensity={blurIntensity}
tint={Platform.OS === "web" ? undefined : "dark"}
tint={Platform.OS === 'web' ? undefined : 'dark'}
style={dotsWrapperStyle}
pointerEvents="none"
>
@@ -414,16 +384,16 @@ const FeatureCarousel = ({
/>
</BlurView>
</View>
);
};
)
}
export default FeatureCarousel;
export default FeatureCarousel
const styles = StyleSheet.create({
container: {
flex: 1,
width: "100%",
flexDirection: "row",
width: '100%',
flexDirection: 'row',
paddingBottom: responsiveHeight(5),
paddingHorizontal: gutters,
},
@@ -434,28 +404,28 @@ const styles = StyleSheet.create({
flex: 1,
},
dotsWrapperBase: {
justifyContent: "center",
alignItems: "center",
justifyContent: 'center',
alignItems: 'center',
borderRadius: 16,
paddingVertical: 12,
paddingHorizontal: 8,
overflow: "hidden",
backgroundColor: "rgba(18, 18, 18, 0.2)",
overflow: 'hidden',
backgroundColor: 'rgba(18, 18, 18, 0.2)',
},
dotsWrapperWeb: {
position: "absolute",
position: 'absolute',
right: 12,
top: 0,
bottom: 0,
maxHeight: "75%",
alignSelf: "center",
maxHeight: '75%',
alignSelf: 'center',
},
dotsWrapperNative: {
marginLeft: 12,
alignSelf: "center",
alignSelf: 'center',
},
dotsContainer: {
flexDirection: "column",
flexDirection: 'column',
},
dot: {
width: 8,
@@ -463,6 +433,6 @@ const styles = StyleSheet.create({
marginHorizontal: 0,
marginVertical: 6,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.4)",
backgroundColor: 'rgba(255,255,255,0.4)',
},
});
})
@@ -1,11 +1,5 @@
import { BlurView } from "expo-blur";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { BlurView } from 'expo-blur'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Animated,
ImageBackground,
@@ -13,42 +7,42 @@ import {
StyleSheet,
View,
useWindowDimensions,
} from "react-native";
import { ai, cardsImg } from "../../assets";
import { getCreationStageStates } from "../../utils/projectStages";
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
import PersonaCard from "../cards/PersonaCard/PersonaCard";
import { LinearGradient } from "../LinearGradient/LinearGradient";
} from 'react-native'
import { ai, cardsImg } from '../../assets'
import { getCreationStageStates } from '../../utils/projectStages'
import AnimatedPaginationDot from '../AnimatedPaginationDot/AnimatedPaginationDot'
import PersonaCard from '../cards/PersonaCard/PersonaCard'
import { LinearGradient } from '../LinearGradient/LinearGradient'
const STAGE_CARD_CONTENT = [
{
key: "songwriter",
title: "Céline",
description: "Lets write lyrics together !",
key: 'songwriter',
title: 'Céline',
description: 'Lets write lyrics together !',
image: ai.leftIcon,
},
{
key: "beatmaker",
title: "Theo",
key: 'beatmaker',
title: 'Theo',
description: "Come back, when you'll have lyrics!",
image: ai.rightIcon,
},
{
key: "director",
title: "Theo",
key: 'director',
title: 'Theo',
description: "Theo t'accompagne pour créer ton playback.",
image: ai.rightIcon,
},
{
key: "publisher",
title: "Publication",
description: "Ta vidéo est prête ? Direction YouTube !",
key: 'publisher',
title: 'Publication',
description: 'Ta vidéo est prête ? Direction YouTube !',
image: cardsImg.production,
},
];
]
const WEB_SCROLL_INACTIVE_DELTA = 0.05;
const HOME_BACKGROUND_COLOR = "#425B87"; // Derived from the bottom color of the home hero image
const WEB_SCROLL_INACTIVE_DELTA = 0.05
const HOME_BACKGROUND_COLOR = '#425B87' // Derived from the bottom color of the home hero image
const FeatureCarousel = ({
style,
selectedProject,
@@ -60,257 +54,245 @@ const FeatureCarousel = ({
}) => {
const stageStates = useMemo(() => {
if (stageStatesProp) {
return stageStatesProp;
return stageStatesProp
}
return getCreationStageStates(selectedProject);
}, [stageStatesProp, selectedProject]);
return getCreationStageStates(selectedProject)
}, [stageStatesProp, selectedProject])
const stageStatesByKey = useMemo(() => {
if (!Array.isArray(stageStates)) {
return {};
return {}
}
return stageStates.reduce((acc, stage) => {
if (stage?.key) {
acc[stage.key] = stage;
acc[stage.key] = stage
}
return acc;
}, {});
}, [stageStates]);
return acc
}, {})
}, [stageStates])
const carouselItems = useMemo(
() =>
STAGE_CARD_CONTENT.map((item) => {
const state = stageStatesByKey[item.key];
const state = stageStatesByKey[item.key]
return {
...item,
isLocked: state?.isLocked ?? true,
description: state?.description ?? item.description,
};
}
}),
[stageStatesByKey]
);
)
const { height: windowHeight } = useWindowDimensions();
const isWeb = Platform.OS === "web";
const { height: windowHeight } = useWindowDimensions()
const isWeb = Platform.OS === 'web'
const [viewportHeight, setViewportHeight] = useState(() =>
Math.max(windowHeight, 1)
);
const [viewportHeight, setViewportHeight] = useState(() => Math.max(windowHeight, 1))
const updateSnapHeight = useCallback((height) => {
if (!height || Number.isNaN(height)) {
return;
return
}
setViewportHeight((prev) => {
if (prev == null || Math.abs(prev - height) > 0.5) {
return height;
return height
}
return prev;
});
}, []);
return prev
})
}, [])
useEffect(() => {
updateSnapHeight(Math.max(windowHeight, 1));
}, [updateSnapHeight, windowHeight]);
updateSnapHeight(Math.max(windowHeight, 1))
}, [updateSnapHeight, windowHeight])
const itemHeight = Math.max(viewportHeight, 1);
const itemHeight = Math.max(viewportHeight, 1)
const scrollViewRef = useRef(null);
const alignTimeoutRef = useRef(null);
const activeIndexRef = useRef(
typeof activeIndex === "number" ? activeIndex : 0
);
const onActiveIndexChangeRef = useRef(onActiveIndexChange);
const scrollY = useRef(new Animated.Value(0)).current;
const scrollViewRef = useRef(null)
const alignTimeoutRef = useRef(null)
const activeIndexRef = useRef(typeof activeIndex === 'number' ? activeIndex : 0)
const onActiveIndexChangeRef = useRef(onActiveIndexChange)
const scrollY = useRef(new Animated.Value(0)).current
useEffect(() => {
onActiveIndexChangeRef.current = onActiveIndexChange;
}, [onActiveIndexChange]);
onActiveIndexChangeRef.current = onActiveIndexChange
}, [onActiveIndexChange])
useEffect(() => {
if (typeof activeIndex === "number") {
activeIndexRef.current = activeIndex;
if (typeof activeIndex === 'number') {
activeIndexRef.current = activeIndex
}
}, [activeIndex]);
}, [activeIndex])
const clampIndex = useCallback(
(index) => {
if (!carouselItems.length) {
return 0;
return 0
}
if (index < 0) {
return 0;
return 0
}
if (index >= carouselItems.length) {
return carouselItems.length - 1;
return carouselItems.length - 1
}
return index;
return index
},
[carouselItems.length]
);
)
const clearPendingAlignment = useCallback(() => {
if (alignTimeoutRef.current != null) {
globalThis.clearTimeout(alignTimeoutRef.current);
alignTimeoutRef.current = null;
globalThis.clearTimeout(alignTimeoutRef.current)
alignTimeoutRef.current = null
}
}, []);
}, [])
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]);
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment])
const getScrollNode = useCallback(() => {
const node = scrollViewRef.current;
const node = scrollViewRef.current
if (!node) {
return null;
return null
}
if (typeof node.scrollTo === "function") {
return node;
if (typeof node.scrollTo === 'function') {
return node
}
if (typeof node.getNode === "function") {
return node.getNode();
if (typeof node.getNode === 'function') {
return node.getNode()
}
return null;
}, []);
return null
}, [])
const scrollToIndex = useCallback(
(index, animated = true, heightOverride) => {
const target = getScrollNode();
const target = getScrollNode()
if (!target) {
return;
return
}
const clamped = clampIndex(index);
const height =
heightOverride && heightOverride > 0 ? heightOverride : itemHeight;
const clamped = clampIndex(index)
const height = heightOverride && heightOverride > 0 ? heightOverride : itemHeight
if (!height) {
return;
return
}
updateSnapHeight(height);
const offset = clamped * height;
updateSnapHeight(height)
const offset = clamped * height
try {
if (typeof target.scrollTo === "function") {
target.scrollTo({ y: offset, animated });
} else if (typeof target.scrollToOffset === "function") {
target.scrollToOffset({ offset, animated });
if (typeof target.scrollTo === 'function') {
target.scrollTo({ y: offset, animated })
} else if (typeof target.scrollToOffset === 'function') {
target.scrollToOffset({ offset, animated })
}
} catch (_error) {
// ScrollView not ready yet, ignore.
}
activeIndexRef.current = clamped;
activeIndexRef.current = clamped
},
[clampIndex, getScrollNode, itemHeight, updateSnapHeight]
);
)
useEffect(() => {
if (
scrollViewRef.current == null ||
typeof activeIndex !== "number" ||
typeof activeIndex !== 'number' ||
activeIndex < 0 ||
activeIndex >= carouselItems.length ||
(isWeb && !itemHeight)
) {
return;
return
}
scrollToIndex(activeIndex);
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]);
scrollToIndex(activeIndex)
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex])
useEffect(() => {
if (scrollViewRef.current == null || (isWeb && !itemHeight)) {
return;
return
}
scrollToIndex(activeIndexRef.current, false);
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex]);
scrollToIndex(activeIndexRef.current, false)
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex])
useEffect(() => {
if (!isWeb || !isFocused) {
return;
return
}
if (!itemHeight) {
return;
return
}
clearPendingAlignment();
scrollToIndex(activeIndexRef.current, false);
}, [
clearPendingAlignment,
isFocused,
isWeb,
itemHeight,
scrollToIndex,
]);
clearPendingAlignment()
scrollToIndex(activeIndexRef.current, false)
}, [clearPendingAlignment, isFocused, isWeb, itemHeight, scrollToIndex])
const alignToOffset = useCallback(
(offset, layoutHeight, options = {}) => {
const { forceSnap = false } = options || {};
const height =
layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight;
const { forceSnap = false } = options || {}
const height = layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight
if (!height) {
return;
return
}
updateSnapHeight(height);
updateSnapHeight(height)
const currentIndex = activeIndexRef.current;
const rawIndex = height ? offset / height : currentIndex;
const currentIndex = activeIndexRef.current
const rawIndex = height ? offset / height : currentIndex
let nextIndex = currentIndex;
let nextIndex = currentIndex
if (isWeb) {
const delta = rawIndex - currentIndex;
const delta = rawIndex - currentIndex
if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) {
if (Math.abs(delta) <= 1) {
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1));
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1))
} else {
nextIndex = clampIndex(currentIndex + Math.round(delta));
nextIndex = clampIndex(currentIndex + Math.round(delta))
}
}
} else {
nextIndex = clampIndex(Math.round(rawIndex));
nextIndex = clampIndex(Math.round(rawIndex))
}
const hasChanged = nextIndex !== activeIndexRef.current;
const hasChanged = nextIndex !== activeIndexRef.current
if (hasChanged) {
activeIndexRef.current = nextIndex;
const callback = onActiveIndexChangeRef.current;
activeIndexRef.current = nextIndex
const callback = onActiveIndexChangeRef.current
if (callback) {
callback(nextIndex);
callback(nextIndex)
}
}
if (forceSnap || hasChanged) {
scrollToIndex(nextIndex, true, height);
scrollToIndex(nextIndex, true, height)
}
},
[clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight]
);
)
const handleScrollEnd = useCallback(
(event) => {
clearPendingAlignment();
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
alignToOffset(offsetY, layoutHeight, { forceSnap: true });
clearPendingAlignment()
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
alignToOffset(offsetY, layoutHeight, { forceSnap: true })
},
[alignToOffset, clearPendingAlignment]
);
)
const handleScroll = useCallback(
(event) => {
if (!isWeb) {
return;
return
}
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
clearPendingAlignment();
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
clearPendingAlignment()
alignTimeoutRef.current = globalThis.setTimeout(() => {
alignToOffset(offsetY, layoutHeight, { forceSnap: false });
alignTimeoutRef.current = null;
}, 80);
alignToOffset(offsetY, layoutHeight, { forceSnap: false })
alignTimeoutRef.current = null
}, 80)
},
[alignToOffset, clearPendingAlignment, isWeb]
);
)
const animatedScrollHandler = useMemo(
() =>
@@ -319,19 +301,19 @@ const FeatureCarousel = ({
listener: isWeb ? handleScroll : undefined,
}),
[handleScroll, isWeb, scrollY]
);
)
const handleLayout = useCallback(
(event) => {
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0;
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0
if (layoutHeight > 0) {
updateSnapHeight(layoutHeight);
updateSnapHeight(layoutHeight)
}
},
[updateSnapHeight]
);
)
const hasBackgroundImage = !!backgroundImage;
const hasBackgroundImage = !!backgroundImage
const renderContent = useMemo(
() =>
@@ -345,43 +327,35 @@ const FeatureCarousel = ({
]}
>
<View style={styles.cardWrapper}>
<PersonaCard
item={item}
index={index}
isLock={item.isLocked}
height={itemHeight}
/>
<PersonaCard item={item} index={index} isLock={item.isLocked} height={itemHeight} />
</View>
</View>
)),
[carouselItems, hasBackgroundImage, itemHeight]
);
)
const snapOffsets = useMemo(() => {
if (!itemHeight || !isWeb) {
return undefined;
return undefined
}
return carouselItems.map((_, index) => index * itemHeight);
}, [carouselItems, isWeb, itemHeight]);
return carouselItems.map((_, index) => index * itemHeight)
}, [carouselItems, isWeb, itemHeight])
const blurIntensity = isWeb ? 80 : 30;
const blurIntensity = isWeb ? 80 : 30
const dotsWrapperStyle = useMemo(
() => [
styles.dotsWrapperBase,
isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative,
],
() => [styles.dotsWrapperBase, isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative],
[isWeb]
);
)
const containerProps = hasBackgroundImage
? {
source: backgroundImage,
resizeMode: "cover",
resizeMode: 'cover',
imageStyle: styles.backgroundImage,
}
: {};
: {}
const ContainerComponent = hasBackgroundImage ? ImageBackground : View;
const ContainerComponent = hasBackgroundImage ? ImageBackground : View
return (
<ContainerComponent
@@ -396,11 +370,7 @@ const FeatureCarousel = ({
>
{hasBackgroundImage && (
<LinearGradient
colors={[
"rgba(66, 91, 135, 0)",
"rgba(66, 91, 135, 0.4)",
HOME_BACKGROUND_COLOR,
]}
colors={['rgba(66, 91, 135, 0)', 'rgba(66, 91, 135, 0.4)', HOME_BACKGROUND_COLOR]}
locations={[0, 0.75, 1]}
style={styles.backgroundGradient}
pointerEvents="none"
@@ -416,7 +386,7 @@ const FeatureCarousel = ({
snapToAlignment="start"
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
snapToOffsets={snapOffsets}
decelerationRate={!isWeb ? "fast" : "normal"}
decelerationRate={!isWeb ? 'fast' : 'normal'}
style={styles.list}
contentContainerStyle={styles.scrollContent}
onScroll={animatedScrollHandler}
@@ -427,7 +397,7 @@ const FeatureCarousel = ({
</Animated.ScrollView>
<BlurView
intensity={blurIntensity}
tint={Platform.OS === "web" ? undefined : "dark"}
tint={Platform.OS === 'web' ? undefined : 'dark'}
style={dotsWrapperStyle}
pointerEvents="none"
>
@@ -444,16 +414,16 @@ const FeatureCarousel = ({
/>
</BlurView>
</ContainerComponent>
);
};
)
}
export default FeatureCarousel;
export default FeatureCarousel
const styles = StyleSheet.create({
container: {
flex: 1,
width: "100%",
flexDirection: "row",
width: '100%',
flexDirection: 'row',
},
containerWeb: {
// paddingRight: 56,
@@ -462,7 +432,7 @@ const styles = StyleSheet.create({
backgroundColor: HOME_BACKGROUND_COLOR,
},
containerTransparent: {
backgroundColor: "transparent",
backgroundColor: 'transparent',
},
list: {
flex: 1,
@@ -477,45 +447,45 @@ const styles = StyleSheet.create({
...StyleSheet.absoluteFillObject,
},
slide: {
width: "100%",
justifyContent: "flex-start",
width: '100%',
justifyContent: 'flex-start',
},
slideColored: {
backgroundColor: HOME_BACKGROUND_COLOR,
},
slideTransparent: {
backgroundColor: "transparent",
backgroundColor: 'transparent',
},
cardWrapper: {
flex: 1,
width: "60%",
justifyContent: "center",
alignSelf: "center",
width: '60%',
justifyContent: 'center',
alignSelf: 'center',
zIndex: 1,
},
dotsWrapperBase: {
justifyContent: "center",
alignItems: "center",
justifyContent: 'center',
alignItems: 'center',
borderRadius: 16,
paddingVertical: 12,
paddingHorizontal: 8,
overflow: "hidden",
backgroundColor: "rgba(18, 18, 18, 0.2)",
overflow: 'hidden',
backgroundColor: 'rgba(18, 18, 18, 0.2)',
},
dotsWrapperWeb: {
position: "absolute",
position: 'absolute',
right: 12,
top: 0,
bottom: 0,
maxHeight: "75%",
alignSelf: "center",
maxHeight: '75%',
alignSelf: 'center',
},
dotsWrapperNative: {
marginLeft: 12,
alignSelf: "center",
alignSelf: 'center',
},
dotsContainer: {
flexDirection: "column",
flexDirection: 'column',
},
dot: {
width: 8,
@@ -523,6 +493,6 @@ const styles = StyleSheet.create({
marginHorizontal: 0,
marginVertical: 6,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.4)",
backgroundColor: 'rgba(255,255,255,0.4)',
},
});
})
+36 -46
View File
@@ -1,14 +1,14 @@
import { useContext, useGlobal } from "reactn";
import { ScrollView, Text, View, Image, Pressable } from "react-native";
import { useContext, useGlobal } from 'reactn'
import { ScrollView, Text, View, Image, Pressable } from 'react-native'
import firebase, { arrayRemove } from "../config/firebase";
import firebase, { arrayRemove } from '../config/firebase'
import { getFileNameFromURL } from "../helpers";
import { Fonts, Palette, Style, gutters } from "../styles";
import { icons } from "../assets";
import { getFileNameFromURL } from '../helpers'
import { Fonts, Palette, Style, gutters } from '../styles'
import { icons } from '../assets'
import { WebViewContext } from "../providers/WebViewProvider";
import alert from "./Alert";
import { WebViewContext } from '../providers/WebViewProvider'
import alert from './Alert'
export default ({
files = [],
@@ -17,67 +17,61 @@ export default ({
collectionRef = null,
containerStyle = {},
}) => {
const [, setIsLoading] = useGlobal("_isLoading");
const [, setTooltip] = useGlobal("_tooltip");
const [, setIsLoading] = useGlobal('_isLoading')
const [, setTooltip] = useGlobal('_tooltip')
const { setWebViewUrl } = useContext(WebViewContext);
const { setWebViewUrl } = useContext(WebViewContext)
const onDeleteFile = async (url) => {
alert(
"Êtes-vous sûr ?",
"Cette action est irréversible.",
'Êtes-vous sûr ?',
'Cette action est irréversible.',
[
{
text: "Annuler",
style: "cancel",
text: 'Annuler',
style: 'cancel',
},
{
text: "Confirmer",
text: 'Confirmer',
onPress: async () => {
try {
setIsLoading(true);
setIsLoading(true)
setFiles(
files.filter((file) => file !== url && file.uri !== url)
);
await firebase.storage().refFromURL(url).delete();
setFiles(files.filter((file) => file !== url && file.uri !== url))
await firebase.storage().refFromURL(url).delete()
if (documentID) {
await collectionRef.doc(documentID).update({
files: arrayRemove(url),
});
})
}
setTooltip({
type: "success",
text: "Fichier supprimé avec succès !",
});
type: 'success',
text: 'Fichier supprimé avec succès !',
})
} catch (error) {
console.log(error);
console.log(error)
} finally {
setIsLoading(false);
setIsLoading(false)
}
},
style: "confirm",
style: 'confirm',
},
],
{ cancelable: false }
);
};
)
}
if (files.length === 0) {
return null;
return null
}
return (
<View>
<ScrollView
horizontal
style={{ ...containerStyle }}
showsHorizontalScrollIndicator={false}
>
<ScrollView horizontal style={{ ...containerStyle }} showsHorizontalScrollIndicator={false}>
{files.map((file, index) => {
const uri = file.uri || file;
const uri = file.uri || file
return (
<View
@@ -97,7 +91,7 @@ export default ({
<Text
numberOfLines={1}
style={Fonts({
type: "default",
type: 'default',
color: Palette.white,
style: {
maxWidth: files?.length > 1 ? 100 : 200,
@@ -119,16 +113,12 @@ export default ({
)}
<Pressable onPress={() => setWebViewUrl(uri)}>
<Image
source={icons.eye}
style={Style.iconDefault}
resizeMode="contain"
/>
<Image source={icons.eye} style={Style.iconDefault} resizeMode="contain" />
</Pressable>
</View>
);
)
})}
</ScrollView>
</View>
);
};
)
}
+57 -73
View File
@@ -1,102 +1,86 @@
import React, { useCallback, useEffect, useRef } from "react";
import { View, Pressable, Text } from "react-native";
import { VideoView, useVideoPlayer } from "expo-video";
import { videos } from "../assets";
import { Portal } from "@gorhom/portal";
import React, { useCallback, useEffect, useRef } from 'react'
import { View, Pressable, Text } from 'react-native'
import { VideoView, useVideoPlayer } from 'expo-video'
import { videos } from '../assets'
import { Portal } from '@gorhom/portal'
// Fullscreen vertical video overlay without controls
// Props:
// - url?: string | number (require), source of the video. Defaults to videos.test
// - visible?: boolean, when false returns null
// - onClose: () => void, called when user skips or when video ends
const CLOSE_THRESHOLD_SECONDS = 0.35;
const CLOSE_THRESHOLD_SECONDS = 0.35
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
const source = url
? typeof url === "string"
? { uri: url }
: url
: videos.test;
const source = url ? (typeof url === 'string' ? { uri: url } : url) : videos.test
const hasClosedRef = useRef(false);
const hasClosedRef = useRef(false)
const handleClose = useCallback(() => {
if (hasClosedRef.current) return;
hasClosedRef.current = true;
if (hasClosedRef.current) return
hasClosedRef.current = true
try {
onClose?.();
onClose?.()
} catch (e) {}
}, [onClose]);
}, [onClose])
const player = useVideoPlayer(source, (p) => {
p.loop = false;
p.timeUpdateEventInterval = 0.25;
});
p.loop = false
p.timeUpdateEventInterval = 0.25
})
useEffect(() => {
if (visible) {
hasClosedRef.current = false;
hasClosedRef.current = false
} else {
hasClosedRef.current = true;
hasClosedRef.current = true
try {
player?.pause?.();
player?.pause?.()
} catch (e) {}
}
}, [player, visible]);
}, [player, visible])
useEffect(() => {
if (!visible) {
return;
return
}
try {
player?.play?.();
player?.play?.()
} catch (e) {}
}, [player, visible]);
}, [player, visible])
useEffect(() => {
if (!player || !visible) return;
const playToEndSub = player.addListener?.("playToEnd", handleClose);
const timeUpdateSub = player.addListener?.(
"timeUpdate",
({ currentTime } = {}) => {
if (!player?.duration || hasClosedRef.current) {
return;
}
const remaining = player.duration - currentTime;
if (
Number.isFinite(remaining) &&
remaining <= CLOSE_THRESHOLD_SECONDS
) {
handleClose();
}
if (!player || !visible) return
const playToEndSub = player.addListener?.('playToEnd', handleClose)
const timeUpdateSub = player.addListener?.('timeUpdate', ({ currentTime } = {}) => {
if (!player?.duration || hasClosedRef.current) {
return
}
);
const playingChangeSub = player.addListener?.(
"playingChange",
({ isPlaying } = {}) => {
if (isPlaying || !player?.duration || hasClosedRef.current) {
return;
}
const remaining = player.duration - (player.currentTime ?? 0);
if (
Number.isFinite(remaining) &&
remaining <= CLOSE_THRESHOLD_SECONDS
) {
handleClose();
}
const remaining = player.duration - currentTime
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
handleClose()
}
);
})
const playingChangeSub = player.addListener?.('playingChange', ({ isPlaying } = {}) => {
if (isPlaying || !player?.duration || hasClosedRef.current) {
return
}
const remaining = player.duration - (player.currentTime ?? 0)
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
handleClose()
}
})
return () => {
try {
playToEndSub?.remove?.();
timeUpdateSub?.remove?.();
playingChangeSub?.remove?.();
playToEndSub?.remove?.()
timeUpdateSub?.remove?.()
playingChangeSub?.remove?.()
} catch (e) {}
};
}, [handleClose, player, visible]);
}
}, [handleClose, player, visible])
if (!visible) {
return null;
return null
}
return (
@@ -104,15 +88,15 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
<View
pointerEvents="box-none"
style={{
position: "absolute",
position: 'absolute',
paddingHorizontal: 10,
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "black",
justifyContent: "center",
alignItems: "center",
backgroundColor: 'black',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
}}
>
@@ -133,22 +117,22 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
<Pressable
onPress={handleClose}
style={{
position: "absolute",
position: 'absolute',
top: 50,
right: 20,
backgroundColor: "#00000080",
backgroundColor: '#00000080',
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 20,
borderWidth: 1,
borderColor: "#FFFFFF55",
borderColor: '#FFFFFF55',
}}
>
<Text style={{ color: "#FFF", fontSize: 14 }}>Passer la vidéo</Text>
<Text style={{ color: '#FFF', fontSize: 14 }}>Passer la vidéo</Text>
</Pressable>
</View>
</Portal>
);
};
)
}
export default FullscreenIntroVideo;
export default FullscreenIntroVideo
+87 -87
View File
@@ -1,183 +1,183 @@
import { Portal } from "@gorhom/portal";
import { Asset } from "expo-asset";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { Portal } from '@gorhom/portal'
import { Asset } from 'expo-asset'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Pressable, Text, View } from 'react-native'
const CLOSE_THRESHOLD_SECONDS = 0.35;
const CLOSE_POLL_INTERVAL_MS = 500;
const CLOSE_THRESHOLD_SECONDS = 0.35
const CLOSE_POLL_INTERVAL_MS = 500
const overlayStyle = {
position: "fixed",
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: "black",
justifyContent: "center",
alignItems: "center",
backgroundColor: 'black',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
};
}
const videoStyle = {
position: "absolute",
position: 'absolute',
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "cover",
};
width: '100%',
height: '100%',
objectFit: 'cover',
}
const closeButtonStyle = {
position: "absolute",
position: 'absolute',
top: 50,
right: 20,
backgroundColor: "#00000080",
backgroundColor: '#00000080',
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 20,
borderWidth: 1,
borderColor: "#FFFFFF55",
cursor: "pointer",
};
borderColor: '#FFFFFF55',
cursor: 'pointer',
}
const closeTextStyle = {
color: "#FFF",
color: '#FFF',
fontSize: 14,
};
}
const resolveModuleUri = async (module) => {
const asset = Asset.fromModule(module);
const asset = Asset.fromModule(module)
if (!asset.localUri && !asset.uri) {
await asset.downloadAsync();
await asset.downloadAsync()
}
return asset.localUri ?? asset.uri ?? null;
};
return asset.localUri ?? asset.uri ?? null
}
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
const videoRef = useRef(null);
const [uri, setUri] = useState(null);
const [muted, setMuted] = useState(false);
const hasClosedRef = useRef(false);
const videoRef = useRef(null)
const [uri, setUri] = useState(null)
const [muted, setMuted] = useState(false)
const hasClosedRef = useRef(false)
const handleClose = useCallback(() => {
if (hasClosedRef.current) return;
hasClosedRef.current = true;
onClose?.();
}, [onClose]);
if (hasClosedRef.current) return
hasClosedRef.current = true
onClose?.()
}, [onClose])
const evaluateShouldClose = useCallback(() => {
const video = videoRef.current;
const video = videoRef.current
if (!video || hasClosedRef.current) {
return;
return
}
if (video.ended) {
handleClose();
return;
handleClose()
return
}
const remaining = video.duration - video.currentTime;
const remaining = video.duration - video.currentTime
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
handleClose();
handleClose()
}
}, [handleClose]);
}, [handleClose])
useEffect(() => {
let isMounted = true;
let isMounted = true
const assignUri = (nextUri) => {
if (isMounted) {
setMuted(false);
setUri(nextUri);
setMuted(false)
setUri(nextUri)
}
};
}
if (typeof url === "string") {
assignUri(url);
if (typeof url === 'string') {
assignUri(url)
return () => {
isMounted = false;
};
isMounted = false
}
}
const load = async () => {
try {
const nextUri = await resolveModuleUri(url);
assignUri(nextUri);
const nextUri = await resolveModuleUri(url)
assignUri(nextUri)
} catch {
if (isMounted) {
setUri(null);
setUri(null)
}
}
};
}
load();
load()
return () => {
isMounted = false;
};
}, [url]);
isMounted = false
}
}, [url])
useEffect(() => {
if (!visible || !uri) {
return undefined;
return undefined
}
hasClosedRef.current = false;
let rafId;
hasClosedRef.current = false
let rafId
const attemptPlay = () => {
const video = videoRef.current;
const video = videoRef.current
if (!video) {
rafId = requestAnimationFrame(attemptPlay);
return;
rafId = requestAnimationFrame(attemptPlay)
return
}
video.currentTime = 0;
const result = video.play();
video.currentTime = 0
const result = video.play()
if (result?.catch) {
result.catch((error) => {
if (error?.name === "NotAllowedError" && !muted) {
setMuted(true);
if (error?.name === 'NotAllowedError' && !muted) {
setMuted(true)
}
});
})
}
};
}
attemptPlay();
const pollId = setInterval(evaluateShouldClose, CLOSE_POLL_INTERVAL_MS);
attemptPlay()
const pollId = setInterval(evaluateShouldClose, CLOSE_POLL_INTERVAL_MS)
return () => {
if (rafId) {
cancelAnimationFrame(rafId);
cancelAnimationFrame(rafId)
}
clearInterval(pollId);
const video = videoRef.current;
video?.pause();
};
}, [evaluateShouldClose, muted, uri, visible]);
clearInterval(pollId)
const video = videoRef.current
video?.pause()
}
}, [evaluateShouldClose, muted, uri, visible])
useEffect(() => {
const video = videoRef.current;
const video = videoRef.current
if (!video || !muted) {
return;
return
}
const result = video.play();
const result = video.play()
if (result?.catch) {
result.catch(() => {});
result.catch(() => {})
}
}, [muted]);
}, [muted])
if (!visible) {
return null;
return null
}
return (
@@ -204,7 +204,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
</Pressable>
</View>
</Portal>
);
};
)
}
export default FullscreenIntroVideo;
export default FullscreenIntroVideo
+17 -18
View File
@@ -1,40 +1,39 @@
import React from "react";
import { Image, Pressable, Text } from "react-native";
import { Palette, Style } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { size } from "../styles/Style";
import { LinearGradient } from "./LinearGradient/LinearGradient";
import React from 'react'
import { Image, Pressable, Text } from 'react-native'
import { Palette, Style } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts'
import { size } from '../styles/Style'
import { LinearGradient } from './LinearGradient/LinearGradient'
const HEIGHT_BY_SIZE = {
small: 44,
medium: 50,
large: 58,
};
}
const FONT_SIZE_BY_SIZE = {
small: 14,
medium: 15,
large: 17,
};
}
const GradientButton = ({
title = "",
colors = ["#F94697", "#7023F7"],
title = '',
colors = ['#F94697', '#7023F7'],
onPress,
props,
containerStyle = {},
icon,
disabled = false,
maxWidth = null,
size = "medium",
size = 'medium',
textStyle = {},
gradientStyle = {},
height = null,
}) => {
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
const buttonHeight =
typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize];
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : 'medium'
const buttonHeight = typeof height === 'number' ? height : HEIGHT_BY_SIZE[resolvedSize]
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]
return (
<Pressable
@@ -79,7 +78,7 @@ const GradientButton = ({
</Text>
</LinearGradient>
</Pressable>
);
};
)
}
export default GradientButton;
export default GradientButton
+9 -9
View File
@@ -1,24 +1,24 @@
import Hyperlink from "react-native-hyperlink";
import Hyperlink from 'react-native-hyperlink'
import { useWebView } from "../providers/WebViewProvider";
import { Palette } from "../styles";
import { useWebView } from '../providers/WebViewProvider'
import { Palette } from '../styles'
const HyperlinkContainer = ({ children }) => {
const { setWebViewUrl } = useWebView();
const { setWebViewUrl } = useWebView()
return (
<Hyperlink
onPress={(url) => {
setWebViewUrl(url);
setWebViewUrl(url)
}}
linkStyle={{
color: Palette.primary,
textDecorationLine: "underline",
textDecorationLine: 'underline',
}}
>
{children}
</Hyperlink>
);
};
)
}
export default HyperlinkContainer;
export default HyperlinkContainer
+8 -8
View File
@@ -1,7 +1,7 @@
import { Image, View } from "react-native";
import { Image, View } from 'react-native'
import { Palette, Style } from "../styles";
import { gutters, mainBorderRadius } from "../styles/Style";
import { Palette, Style } from '../styles'
import { gutters, mainBorderRadius } from '../styles/Style'
const IconContainer = ({ icon }) => {
return (
@@ -19,13 +19,13 @@ const IconContainer = ({ icon }) => {
source={icon}
resizeMode="contain"
style={{
width: "50%",
height: "50%",
width: '50%',
height: '50%',
tintColor: Palette.primary,
}}
/>
</View>
);
};
)
}
export default IconContainer;
export default IconContainer
+25 -27
View File
@@ -1,29 +1,28 @@
import React, { useState, useEffect } from "reactn";
import { Image, Pressable, Text, View } from "react-native";
import { FlatGrid } from "react-native-super-grid";
import React, { useState, useEffect } from 'reactn'
import { Image, Pressable, Text, View } from 'react-native'
import { FlatGrid } from 'react-native-super-grid'
import { responsiveWidth } from "../actions/responsiveSizes.js";
import { responsiveWidth } from '../actions/responsiveSizes.js'
import { Fonts, Palette, Style, gutters } from "../styles";
import { mainBorderRadius } from "../styles/Style";
import { Fonts, Palette, Style, gutters } from '../styles'
import { mainBorderRadius } from '../styles/Style'
import useLayoutType from "../hooks/useLayoutType.js";
import useLayoutType from '../hooks/useLayoutType.js'
const IconSelector = ({ onClose } = {}) => {
const { isNative } = useLayoutType();
const { isNative } = useLayoutType()
const [currentIconIndex, setCurrentIconIndex] = useState(0);
const [currentIconIndex, setCurrentIconIndex] = useState(0)
useEffect(() => {
if (isNative) {
// import("expo-dynamic-app-icon").then((module) => {
// getAppIcon = module.getAppIcon;
// const iconIndex = getAppIcon();
// setCurrentIconIndex(Number(iconIndex));
// });
}
}, []);
}, [])
return (
<FlatGrid
@@ -31,8 +30,8 @@ const IconSelector = ({ onClose } = {}) => {
<View>
<Text
style={{
...Fonts({ type: "title" }),
textAlign: "center",
...Fonts({ type: 'title' }),
textAlign: 'center',
marginBottom: gutters / 2,
}}
>
@@ -40,10 +39,10 @@ const IconSelector = ({ onClose } = {}) => {
</Text>
<Text
style={{
...Fonts({ type: "default" }),
textAlign: "center",
width: "60%",
alignSelf: "center",
...Fonts({ type: 'default' }),
textAlign: 'center',
width: '60%',
alignSelf: 'center',
marginBottom: gutters,
}}
>
@@ -61,13 +60,13 @@ const IconSelector = ({ onClose } = {}) => {
style={{ flex: 1, backgroundColor: Palette.darkPurple }}
spacing={10}
renderItem={({ item, index }) => {
const isSelected = currentIconIndex === index + 1;
const isSelected = currentIconIndex === index + 1
return (
<Pressable
key={index}
style={{
width: "100%",
width: '100%',
height: responsiveWidth(45),
...Style.containerCenter,
...Style.defaultShadows,
@@ -75,7 +74,6 @@ const IconSelector = ({ onClose } = {}) => {
onPress={() => {
// import("expo-dynamic-app-icon").then((module) => {
// setAppIcon = module.setAppIcon;
// setAppIcon((index + 1).toString());
// setCurrentIconIndex(index + 1);
// onClose?.();
@@ -86,10 +84,10 @@ const IconSelector = ({ onClose } = {}) => {
source={item}
resizeMode="cover"
style={{
width: "90%",
height: "90%",
width: '90%',
height: '90%',
borderRadius: mainBorderRadius * 2,
overflow: "hidden",
overflow: 'hidden',
...(isSelected && {
borderColor: Palette.primary,
borderWidth: 2,
@@ -97,10 +95,10 @@ const IconSelector = ({ onClose } = {}) => {
}}
/>
</Pressable>
);
)
}}
/>
);
};
)
}
export default IconSelector;
export default IconSelector
+64 -76
View File
@@ -1,30 +1,22 @@
import { useState } from "react";
import {
Image,
InputAccessoryView,
Keyboard,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { useState } from 'react'
import { Image, InputAccessoryView, Keyboard, Pressable, Text, TextInput, View } from 'react-native'
// import CountryPicker, { DARK_THEME } from "react-native-country-picker-modal";
import usePlaceApi from "react-native-minuit/src/hooks/usePlacesApi";
import usePlaceApi from 'react-native-minuit/src/hooks/usePlacesApi'
import { icons } from "../assets";
import { Fonts, Palette, Style, gutters } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { icons } from '../assets'
import { Fonts, Palette, Style, gutters } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts'
import { BlurView } from "expo-blur";
import EyeSlashSVG from "../assets/UI/EyeSlashSVG";
import EyeSVG from "../assets/UI/EyeSVG";
import { GOOGLE_API_KEY } from "../data/keys";
import { isWeb } from "../hooks/useLayoutType";
import { BlurView } from 'expo-blur'
import EyeSlashSVG from '../assets/UI/EyeSlashSVG'
import EyeSVG from '../assets/UI/EyeSVG'
import { GOOGLE_API_KEY } from '../data/keys'
import { isWeb } from '../hooks/useLayoutType'
const Input = ({
inputRef = null,
label = "",
placeholder = "",
label = '',
placeholder = '',
containerStyle = {},
textInputStyle = {},
@@ -34,48 +26,46 @@ const Input = ({
textInputProps = {},
type = "default", // "default" | "password" | "textarea" | "coinAmount" | "countryPicker" | "autoCompleteAddress"
theme = "default", // "default" | "radioactiv" | "dashed"
type = 'default', // "default" | "password" | "textarea" | "coinAmount" | "countryPicker" | "autoCompleteAddress"
theme = 'default', // "default" | "radioactiv" | "dashed"
borderType = "none", // "solid" | "dashed" | "none"
borderType = 'none', // "solid" | "dashed" | "none"
layout = "default", // "default" | "line"
layout = 'default', // "default" | "line"
isNumeric = false,
isBlur = false,
}) => {
const [isFocused, setIsFocused] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [isFocused, setIsFocused] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const isDefaultLayout = layout === "default";
const isDefaultLayout = layout === 'default'
const mainColor =
theme === "radioactiv" ? Palette.radioactivGreen : Palette.primary;
const mainColor = theme === 'radioactiv' ? Palette.radioactivGreen : Palette.primary
const isRoundedRectangle =
["textarea", "coinAmount"].includes(type) || layout === "default";
const isRoundedRectangle = ['textarea', 'coinAmount'].includes(type) || layout === 'default'
const inputAccessoryViewID = "uniqueID";
const inputAccessoryViewID = 'uniqueID'
const { places } = usePlaceApi({
query: type === "autoCompleteAddress" ? value : "",
query: type === 'autoCompleteAddress' ? value : '',
apiKey: GOOGLE_API_KEY, // Your Google API Key
queryFields: "formatted_address,geometry,name,address_components",
queryCountries: ["fr"],
language: "fr-FR",
queryFields: 'formatted_address,geometry,name,address_components',
queryCountries: ['fr'],
language: 'fr-FR',
minChars: 2,
});
})
const ContainerView = isBlur ? BlurView : View;
const ContainerView = isBlur ? BlurView : View
const resolvedKeyboardType =
textInputProps?.keyboardType ??
(isNumeric ? "numeric" : type === "email" ? "email-address" : "default");
(isNumeric ? 'numeric' : type === 'email' ? 'email-address' : 'default')
return (
<>
<View
style={{
width: "100%",
width: '100%',
...containerStyle,
gap: 4,
}}
@@ -106,11 +96,11 @@ const Input = ({
? {
paddingVertical: 10,
backgroundColor:
type === "coinAmount"
type === 'coinAmount'
? Palette.transparentRadioactivGreen
: Palette.glass,
height: type === "textarea" ? 150 : 50,
overflow: "hidden",
height: type === 'textarea' ? 150 : 50,
overflow: 'hidden',
borderRadius: 12,
}
: {}),
@@ -120,68 +110,66 @@ const Input = ({
borderBottomColor: mainColor,
borderBottomWidth: 1,
}),
...(borderType === "dashed"
...(borderType === 'dashed'
? {
borderStyle: "dashed",
borderColor: isFocused
? Palette.primary
: Palette.transparentPrimary,
borderStyle: 'dashed',
borderColor: isFocused ? Palette.primary : Palette.transparentPrimary,
borderWidth: 1,
}
: {}),
width: "100%",
width: '100%',
}}
>
{type === "search" ? (
{type === 'search' ? (
<Image
source={icons.search}
style={[Style.iconDefault, { marginRight: 10 }]}
resizeMode="contain"
/>
) : null}
{type !== "countryPicker" && (
{type !== 'countryPicker' && (
<TextInput
ref={inputRef}
placeholder={placeholder}
placeholderTextColor={Palette.gray}
value={value}
onChangeText={setValue}
multiline={type === "textarea"}
editable={type !== "countryPicker"}
multiline={type === 'textarea'}
editable={type !== 'countryPicker'}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
style={{
width: "100%",
width: '100%',
...(isRoundedRectangle
? {
height: "100%",
height: '100%',
flex: 1,
textAlignVertical: type === "textarea" ? "top" : "center",
textAlignVertical: type === 'textarea' ? 'top' : 'center',
}
: { textAlign: "center" }),
: { textAlign: 'center' }),
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
...(isWeb
? {
lineHeight: "auto",
lineHeight: 'auto',
}
: {}),
...textInputStyle,
}}
{...(type === "password"
{...(type === 'password'
? {
secureTextEntry: !showPassword,
autoCapitalize: "none",
autoCompleteType: "password",
textContentType: "password",
autoCapitalize: 'none',
autoCompleteType: 'password',
textContentType: 'password',
}
: {})}
{...(type === "email"
{...(type === 'email'
? {
autoCapitalize: "none",
autoCompleteType: "email",
textContentType: "emailAddress",
autoCapitalize: 'none',
autoCompleteType: 'email',
textContentType: 'emailAddress',
}
: {})}
keyboardType={resolvedKeyboardType}
@@ -190,21 +178,21 @@ const Input = ({
{...textInputProps}
/>
)}
{type === "password" ? (
{type === 'password' ? (
<Pressable onPress={() => setShowPassword(!showPassword)}>
{showPassword ? <EyeSVG /> : <EyeSlashSVG />}
</Pressable>
) : null}
</ContainerView>
{type === "autoCompleteAddress" &&
{type === 'autoCompleteAddress' &&
places?.[0]?.description &&
places?.[0]?.description !== value &&
places.map((place, index) => (
<Pressable
key={index}
onPress={() => {
setValue(place?.description);
setValue(place?.description)
}}
style={{
...Style.containerSpaceBetween,
@@ -216,19 +204,19 @@ const Input = ({
...Fonts({}),
}}
>
{place?.description || "-"}
{place?.description || '-'}
</Text>
</Pressable>
))}
</View>
{(type === "textarea" || isNumeric) && !isWeb && (
{(type === 'textarea' || isNumeric) && !isWeb && (
<InputAccessoryView nativeID={inputAccessoryViewID}>
<Pressable
onPress={() => Keyboard.dismiss()}
style={{
...Style.containerRow,
justifyContent: "flex-end",
justifyContent: 'flex-end',
backgroundColor: Palette.transparentPrimary,
padding: gutters,
paddingVertical: gutters / 2,
@@ -245,7 +233,7 @@ const Input = ({
</InputAccessoryView>
)}
</>
);
};
)
}
export { Input };
export { Input }
+51 -70
View File
@@ -1,78 +1,69 @@
import { useState } from "react";
import { View, Text, Image, Pressable, StyleSheet } from "react-native";
import { BlurView } from "expo-blur";
import { useState } from 'react'
import { View, Text, Image, Pressable, StyleSheet } from 'react-native'
import { BlurView } from 'expo-blur'
import Switch from "../components/Switch";
import OptionSelector from "../components/OptionSelector";
import { Container, Title, Input, Button } from "../components/Dialog";
import Switch from '../components/Switch'
import OptionSelector from '../components/OptionSelector'
import { Container, Title, Input, Button } from '../components/Dialog'
import { Fonts, Style, gutters } from "../styles";
import { icons } from "../assets";
import { Fonts, Style, gutters } from '../styles'
import { icons } from '../assets'
const labelOptions = {
name: "Nouveau nom",
email: "Nouvelle adresse email",
password: "Nouveau mot de passe",
language: "Nouvelle langue",
};
name: 'Nouveau nom',
email: 'Nouvelle adresse email',
password: 'Nouveau mot de passe',
language: 'Nouvelle langue',
}
const languageOptions = {
fr: "Français",
en: "English",
es: "Español",
de: "Deutsch",
it: "Italiano",
};
fr: 'Français',
en: 'English',
es: 'Español',
de: 'Deutsch',
it: 'Italiano',
}
export default ({ itemKey, type, value, title, onUpdateValue }) => {
const [showDialog, setShowDialog] = useState(false);
const [inputData, setInputData] = useState(value);
const [currentPassword, setCurrentPassword] = useState("");
const [showDialog, setShowDialog] = useState(false)
const [inputData, setInputData] = useState(value)
const [currentPassword, setCurrentPassword] = useState('')
return (
<>
<View style={{}}>
<View style={Style.containerSpaceBetween}>
<Text style={Fonts({ type: "section" })}>{title}</Text>
{type === "boolean" ? (
<Text style={Fonts({ type: 'section' })}>{title}</Text>
{type === 'boolean' ? (
<Switch
value={value}
setValue={(newValue) =>
onUpdateValue({ key: itemKey, value: newValue })
}
setValue={(newValue) => onUpdateValue({ key: itemKey, value: newValue })}
/>
) : (
<Pressable
onPress={() => setShowDialog(true)}
style={[
Style.containerRow,
{ maxWidth: "50%", justifyContent: "flex-end" },
]}
style={[Style.containerRow, { maxWidth: '50%', justifyContent: 'flex-end' }]}
>
<Text
numberOfLines={1}
style={Fonts({
type: "section",
type: 'section',
style: {
opacity: 0.5,
textAlign: "right",
width: "80%",
textAlign: 'right',
width: '80%',
marginRight: gutters,
},
})}
>
{itemKey === "password"
? "********"
: itemKey === "language"
? languageOptions[value] || value
: value}
{itemKey === 'password'
? '********'
: itemKey === 'language'
? languageOptions[value] || value
: value}
</Text>
<Image
source={icons.edit}
style={Style.iconDefault}
resizeMode="contain"
/>
<Image source={icons.edit} style={Style.iconDefault} resizeMode="contain" />
</Pressable>
)}
</View>
@@ -83,37 +74,33 @@ export default ({ itemKey, type, value, title, onUpdateValue }) => {
<Container
visible={showDialog}
blurComponentIOS={
<BlurView
style={StyleSheet.absoluteFill}
blurType="xdark"
blurAmount={50}
/>
<BlurView style={StyleSheet.absoluteFill} blurType="xdark" blurAmount={50} />
}
>
<Title>{`Changement ${title?.toLowerCase()}`}</Title>
{["password", "email"].includes(itemKey) && (
{['password', 'email'].includes(itemKey) && (
<Input
label="Mot de passe actuel"
value={currentPassword}
onChangeText={(text) => setCurrentPassword(text)}
keyboardType="visible-password"
type={"password"}
type={'password'}
containerStyle={{ marginBottom: gutters / 2 }}
/>
)}
{itemKey === "language" ? (
{itemKey === 'language' ? (
<OptionSelector
optionTypeList={languageOptions}
selected={inputData || "fr"}
selected={inputData || 'fr'}
setSelected={setInputData}
containerStyle={{ marginBottom: gutters / 2 }}
colorMap={{
fr: { primary: "#F94697", secondary: "#F946971A" },
en: { primary: "#7023F7", secondary: "#7023F71A" },
es: { primary: "#FDBA74", secondary: "#FDBA741A" },
de: { primary: "#60A5FA", secondary: "#60A5FA1A" },
it: { primary: "#34D399", secondary: "#34D3991A" },
fr: { primary: '#F94697', secondary: '#F946971A' },
en: { primary: '#7023F7', secondary: '#7023F71A' },
es: { primary: '#FDBA74', secondary: '#FDBA741A' },
de: { primary: '#60A5FA', secondary: '#60A5FA1A' },
it: { primary: '#34D399', secondary: '#34D3991A' },
}}
/>
) : (
@@ -121,9 +108,7 @@ export default ({ itemKey, type, value, title, onUpdateValue }) => {
label={labelOptions[itemKey]}
value={inputData}
onChangeText={(text) => setInputData(text)}
autoCapitalize={
["password", "email"].includes(itemKey) ? "none" : "words"
}
autoCapitalize={['password', 'email'].includes(itemKey) ? 'none' : 'words'}
type={itemKey}
containerStyle={{ marginBottom: gutters / 2 }}
/>
@@ -131,16 +116,12 @@ export default ({ itemKey, type, value, title, onUpdateValue }) => {
<Button
label="Valider"
onPress={() => {
onUpdateValue({ key: itemKey, value: inputData, currentPassword });
setShowDialog(false);
onUpdateValue({ key: itemKey, value: inputData, currentPassword })
setShowDialog(false)
}}
/>
<Button
label="Annuler"
onPress={() => setShowDialog(false)}
type={"secondary"}
/>
<Button label="Annuler" onPress={() => setShowDialog(false)} type={'secondary'} />
</Container>
</>
);
};
)
}
+16 -20
View File
@@ -1,9 +1,9 @@
import { useKeyboard } from "@react-native-community/hooks";
import { BlurView } from "expo-blur";
import React from "react";
import { Platform, StyleSheet, View } from "react-native";
import { responsiveHeight } from "react-native-responsive-dimensions";
import BorderGradient from "../BorderGradient/BorderGradient";
import { useKeyboard } from '@react-native-community/hooks'
import { BlurView } from 'expo-blur'
import React from 'react'
import { Platform, StyleSheet, View } from 'react-native'
import { responsiveHeight } from 'react-native-responsive-dimensions'
import BorderGradient from '../BorderGradient/BorderGradient'
const ItemContainer = ({
height = responsiveHeight(40),
@@ -12,23 +12,19 @@ const ItemContainer = ({
style,
disableKeyboardHeight = false,
}) => {
const { keyboardShown } = useKeyboard();
const { keyboardShown } = useKeyboard()
return (
<BorderGradient
gradientProps={{
colors: ["#FFFFFF00", "#FFFFFF"],
colors: ['#FFFFFF00', '#FFFFFF'],
start: { x: 0.3, y: 0 },
end: { x: 1, y: 1 },
...gradientProps,
}}
style={{
...styles.borderGradientStyle,
height: !disableKeyboardHeight
? keyboardShown
? responsiveHeight(30)
: height
: height,
height: !disableKeyboardHeight ? (keyboardShown ? responsiveHeight(30) : height) : height,
...style,
}}
@@ -40,23 +36,23 @@ const ItemContainer = ({
android: 100,
web: 100,
})}
tint={"dark"}
tint={'dark'}
style={{ flex: 1, padding: 6 }}
>
{children}
</BlurView>
</View>
</BorderGradient>
);
};
)
}
export default ItemContainer;
export default ItemContainer
const styles = StyleSheet.create({
borderGradientStyle: {
borderWidth: 1,
borderRadius: 20,
shadowColor: "#000",
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
@@ -68,7 +64,7 @@ const styles = StyleSheet.create({
blurContainer: {
flex: 1,
borderRadius: 20,
overflow: "hidden",
overflow: 'hidden',
zIndex: 1,
},
});
})
@@ -1,27 +1,27 @@
import { View, StyleSheet } from "react-native";
import React, { useMemo, useState } from "react";
import omit from "lodash/omit";
import BorderGradient from "../BorderGradient/BorderGradient";
import { responsiveHeight } from "react-native-responsive-dimensions";
import { BlurView } from "expo-blur";
import { Style } from "../../styles";
import { View, StyleSheet } from 'react-native'
import React, { useMemo, useState } from 'react'
import omit from 'lodash/omit'
import BorderGradient from '../BorderGradient/BorderGradient'
import { responsiveHeight } from 'react-native-responsive-dimensions'
import { BlurView } from 'expo-blur'
import { Style } from '../../styles'
const ITEM_BORDER_RADIUS = 24;
const ITEM_BORDER_RADIUS = 24
const CONTAINER_STYLE_PROPS = [
"margin",
"marginTop",
"marginBottom",
"marginLeft",
"marginRight",
"marginHorizontal",
"marginVertical",
"alignSelf",
"alignItems",
"justifyContent",
"width",
"minWidth",
"maxWidth",
];
'margin',
'marginTop',
'marginBottom',
'marginLeft',
'marginRight',
'marginHorizontal',
'marginVertical',
'alignSelf',
'alignItems',
'justifyContent',
'width',
'minWidth',
'maxWidth',
]
const ItemContainer = ({
height = responsiveHeight(40),
@@ -32,33 +32,33 @@ const ItemContainer = ({
style,
disableKeyboardHeight = false, // parity with native signature
}) => {
const [containerLayout, setContainerLayout] = useState(null);
const flattenedStyle = StyleSheet.flatten(style) || {};
const [containerLayout, setContainerLayout] = useState(null)
const flattenedStyle = StyleSheet.flatten(style) || {}
const containerStyleOverrides = useMemo(() => {
return CONTAINER_STYLE_PROPS.reduce((acc, key) => {
if (typeof flattenedStyle[key] !== "undefined") {
acc[key] = flattenedStyle[key];
if (typeof flattenedStyle[key] !== 'undefined') {
acc[key] = flattenedStyle[key]
}
return acc;
}, {});
}, [flattenedStyle]);
return acc
}, {})
}, [flattenedStyle])
const {
width: styleWidth,
maxWidth: styleMaxWidth,
minWidth: styleMinWidth,
...remainingContainerStyle
} = containerStyleOverrides;
} = containerStyleOverrides
const gradientStyleOverrides = useMemo(
() => omit(flattenedStyle, CONTAINER_STYLE_PROPS),
[flattenedStyle]
);
)
const baseHeight = typeof height === "number" ? height : undefined;
const measuredHeight = containerLayout?.height ?? baseHeight;
const resolvedWidth = styleWidth ?? width ?? "100%";
const resolvedMaxWidth = styleMaxWidth ?? maxWidth;
const baseHeight = typeof height === 'number' ? height : undefined
const measuredHeight = containerLayout?.height ?? baseHeight
const resolvedWidth = styleWidth ?? width ?? '100%'
const resolvedMaxWidth = styleMaxWidth ?? maxWidth
return (
<View
@@ -72,7 +72,7 @@ const ItemContainer = ({
>
<BorderGradient
gradientProps={{
colors: ["rgba(72, 51, 51, 0)", "#FFFFFF"],
colors: ['rgba(72, 51, 51, 0)', '#FFFFFF'],
start: { x: 0.3, y: 0 },
end: { x: 1, y: 1 },
locations: [0, 1],
@@ -91,32 +91,28 @@ const ItemContainer = ({
baseHeight ? { height: baseHeight } : null,
]}
onLayout={(e) => {
setContainerLayout(e.nativeEvent.layout);
setContainerLayout(e.nativeEvent.layout)
}}
>
<BlurView
intensity={disableKeyboardHeight ? 35 : 45}
tint="dark"
style={styles.blurView}
>
<BlurView intensity={disableKeyboardHeight ? 35 : 45} tint="dark" style={styles.blurView}>
{children}
</BlurView>
</View>
</View>
);
};
)
}
export default ItemContainer;
export default ItemContainer
const styles = StyleSheet.create({
wrapper: {
position: "relative",
alignSelf: "stretch",
position: 'relative',
alignSelf: 'stretch',
},
borderGradientStyle: {
borderWidth: 1.2,
borderRadius: ITEM_BORDER_RADIUS,
shadowColor: "rgba(3, 0, 18, 0.58)",
shadowColor: 'rgba(3, 0, 18, 0.58)',
shadowOffset: {
width: 0,
height: 2,
@@ -124,21 +120,21 @@ const styles = StyleSheet.create({
shadowOpacity: 0.22,
shadowRadius: 18,
elevation: 8,
position: "absolute",
width: "100%",
position: 'absolute',
width: '100%',
},
contentWrapper: {
borderRadius: ITEM_BORDER_RADIUS,
overflow: "hidden",
overflow: 'hidden',
zIndex: 1,
},
blurView: {
flex: 1,
width: "100%",
height: "100%",
width: '100%',
height: '100%',
paddingHorizontal: 32,
paddingVertical: 28,
justifyContent: "center",
alignSelf: "stretch",
justifyContent: 'center',
alignSelf: 'stretch',
},
});
})
+13 -19
View File
@@ -1,11 +1,11 @@
import React from "react";
import { Image, Pressable, Text, View } from "react-native";
import React from 'react'
import { Image, Pressable, Text, View } from 'react-native'
import { responsiveHeight } from "../actions/responsiveSizes";
import { responsiveHeight } from '../actions/responsiveSizes'
import { Fonts, Palette, Style } from "../styles";
import { icons } from "../assets";
import IconContainer from "./IconContainer";
import { Fonts, Palette, Style } from '../styles'
import { icons } from '../assets'
import IconContainer from './IconContainer'
const ItemRowList = ({
title,
@@ -14,7 +14,7 @@ const ItemRowList = ({
textStyle = {},
addMarginTopFromPrevious = false,
containerStyle = {},
separatorPosition = "bottom",
separatorPosition = 'bottom',
}) => {
return (
<View
@@ -23,27 +23,21 @@ const ItemRowList = ({
...containerStyle,
}}
>
{separatorPosition === "top" && (
<View style={Style.separatorHorizontal} />
)}
{separatorPosition === 'top' && <View style={Style.separatorHorizontal} />}
<Pressable onPress={action} style={Style.containerSpaceBetween}>
<View style={Style.containerRow}>
{icon && <IconContainer icon={icon} />}
<Text style={Fonts({ type: "section", style: textStyle })}>
{title}
</Text>
<Text style={Fonts({ type: 'section', style: textStyle })}>{title}</Text>
</View>
<Image source={icons.arrowRight} style={Style.iconSmall} />
</Pressable>
{separatorPosition === "bottom" && (
<View style={Style.separatorHorizontal} />
)}
{separatorPosition === 'bottom' && <View style={Style.separatorHorizontal} />}
</View>
);
};
)
}
export default ItemRowList;
export default ItemRowList
+50 -60
View File
@@ -1,72 +1,68 @@
import React, { useMemo } from "react";
import { Text, View } from "react-native";
import { Palette } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import React, { useMemo } from 'react'
import { Text, View } from 'react-native'
import { Palette } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts'
export function groupAlignedWordsToLines(alignedWords = [], { removeTags = true } = {}) {
const out = [];
let buf = [];
let start = null;
const out = []
let buf = []
let start = null
const clean = (txt) =>
String(txt || "")
.replace(/\s+/g, " ")
.trim();
const isSentenceEnd = (txt) => /[.!?]$/.test((txt || "").trim());
const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || "").trim());
String(txt || '')
.replace(/\s+/g, ' ')
.trim()
const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim())
const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || '').trim())
for (let i = 0; i < alignedWords.length; i++) {
const w = alignedWords[i] || {};
const original = String(w.word || "");
const textNoNewline = original.replace(/\n/g, " ");
const textNoTag = removeTags
? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, "")
: textNoNewline;
const next = alignedWords[i + 1] || null;
const gapToNext = next
? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0))
: 0;
const w = alignedWords[i] || {}
const original = String(w.word || '')
const textNoNewline = original.replace(/\n/g, ' ')
const textNoTag = removeTags ? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, '') : textNoNewline
const next = alignedWords[i + 1] || null
const gapToNext = next ? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0)) : 0
if (buf.length === 0) start = Number(w.startS || 0);
if (buf.length === 0) start = Number(w.startS || 0)
if (isSectionTag(textNoNewline)) {
if (!removeTags) {
const joined = clean(textNoNewline);
const joined = clean(textNoNewline)
if (joined)
out.push({
text: joined,
startS: start ?? Number(w.startS || 0),
endS: Number(w.endS || 0),
});
})
}
buf = [];
start = null;
continue;
buf = []
start = null
continue
}
if (!clean(textNoTag)) {
continue;
continue
}
buf.push(textNoTag);
buf.push(textNoTag)
const eolByNewline = /\n/.test(original);
const eolByPause = gapToNext >= 0.6; // threshold for a logical break
const eolByPunct = isSentenceEnd(textNoTag);
const isLast = i === alignedWords.length - 1;
const eolByNewline = /\n/.test(original)
const eolByPause = gapToNext >= 0.6 // threshold for a logical break
const eolByPunct = isSentenceEnd(textNoTag)
const isLast = i === alignedWords.length - 1
if (eolByNewline || eolByPause || eolByPunct || isLast) {
const joined = clean(buf.join(" "));
const joined = clean(buf.join(' '))
if (joined)
out.push({
text: joined,
startS: start ?? Number(w.startS || 0),
endS: Number(w.endS || 0),
});
buf = [];
start = null;
})
buf = []
start = null
}
}
return out;
return out
}
export default function KaraokeLyrics({
@@ -78,30 +74,24 @@ export default function KaraokeLyrics({
const lines = useMemo(
() => groupAlignedWordsToLines(alignedWords, { removeTags }),
[alignedWords, removeTags]
);
)
const currentLineIdx = useMemo(() => {
if (!lines || lines.length === 0) return -1;
if (!lines || lines.length === 0) return -1
for (let i = 0; i < lines.length; i++) {
const L = lines[i];
if (currentTimeS >= (L.startS || 0) && currentTimeS <= (L.endS || 0))
return i;
const L = lines[i]
if (currentTimeS >= (L.startS || 0) && currentTimeS <= (L.endS || 0)) return i
}
if (currentTimeS > (lines[lines.length - 1]?.endS || 0))
return lines.length - 1;
return -1;
}, [lines, currentTimeS]);
if (currentTimeS > (lines[lines.length - 1]?.endS || 0)) return lines.length - 1
return -1
}, [lines, currentTimeS])
if (!lines.length) return null;
if (!lines.length) return null
const prev =
showContext && currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : "";
const curr =
currentLineIdx >= 0 ? lines[currentLineIdx]?.text : lines[0]?.text;
const prev = showContext && currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : ''
const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : lines[0]?.text
const next =
showContext && currentLineIdx + 1 < lines.length
? lines[currentLineIdx + 1]?.text
: "";
showContext && currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : ''
return (
<View style={{ gap: 4 }}>
@@ -121,7 +111,7 @@ export default function KaraokeLyrics({
style={{
color: Palette.white,
fontSize: 18,
textAlign: "center",
textAlign: 'center',
fontFamily: FONT_FAMILY.InterSemiBold,
}}
>
@@ -130,9 +120,9 @@ export default function KaraokeLyrics({
{next ? (
<Text
style={{
color: "#FFFFFF99",
color: '#FFFFFF99',
fontSize: 14,
textAlign: "center",
textAlign: 'center',
fontFamily: FONT_FAMILY.InterMedium,
}}
>
@@ -140,5 +130,5 @@ export default function KaraokeLyrics({
</Text>
) : null}
</View>
);
)
}
+107 -137
View File
@@ -1,20 +1,19 @@
import { BlurView } from "expo-blur";
import React, { useEffect, useMemo, useState } from "react";
import { FlatList, Platform, SectionList, Text, View } from "react-native";
import useLayoutType from "../../hooks/useLayoutType";
import CreateLyricsHeader from "../../screens/Writing/components/CreateLyricsHeader";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { LinearGradient } from "../LinearGradient/LinearGradient";
import { BlurView } from 'expo-blur'
import React, { useEffect, useMemo, useState } from 'react'
import { FlatList, Platform, SectionList, Text, View } from 'react-native'
import useLayoutType from '../../hooks/useLayoutType'
import CreateLyricsHeader from '../../screens/Writing/components/CreateLyricsHeader'
import { Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import { LinearGradient } from '../LinearGradient/LinearGradient'
const BLUE_SELECTION_GRADIENT = ["#4673F9", "#7023F7"];
const BLUE_SELECTION_GRADIENT = ['#4673F9', '#7023F7']
const buildItemKey = (value, category) =>
category ? `${category}::${value}` : `${value}`;
const buildItemKey = (value, category) => (category ? `${category}::${value}` : `${value}`)
const ListSelection = ({
options = [],
variant = "simple",
variant = 'simple',
selected: selectedProp,
setSelected: setSelectedProp,
multiple = false,
@@ -27,92 +26,88 @@ const ListSelection = ({
itemContainerStyle,
itemOuterStyle,
itemTextStyle,
highlightColor = "#F94697",
highlightColor = '#F94697',
disableHover = false,
}) => {
// état interne si non contrôlé
const [internalSelected, setInternalSelected] = useState(
variant === "sectioned" ? {} : multiple ? [] : null
);
const selected = selectedProp !== undefined ? selectedProp : internalSelected;
const setSelected =
setSelectedProp !== undefined ? setSelectedProp : setInternalSelected;
variant === 'sectioned' ? {} : multiple ? [] : null
)
const selected = selectedProp !== undefined ? selectedProp : internalSelected
const setSelected = setSelectedProp !== undefined ? setSelectedProp : setInternalSelected
const { isWeb } = useLayoutType();
const [hoveredKey, setHoveredKey] = useState(null);
const { isWeb } = useLayoutType()
const [hoveredKey, setHoveredKey] = useState(null)
useEffect(() => {
if (disableHover && hoveredKey !== null) setHoveredKey(null);
}, [disableHover, hoveredKey]);
if (disableHover && hoveredKey !== null) setHoveredKey(null)
}, [disableHover, hoveredKey])
// helpers
const valueOf = useMemo(() => {
if (typeof getItemValue === "function") return getItemValue;
if (variant === "simple") return (item) => item;
return (item) => item?.title ?? item;
}, [getItemValue, variant]);
if (typeof getItemValue === 'function') return getItemValue
if (variant === 'simple') return (item) => item
return (item) => item?.title ?? item
}, [getItemValue, variant])
const formatValue = (item) =>
typeof formatSelectedValue === "function"
? formatSelectedValue(item)
: valueOf(item);
typeof formatSelectedValue === 'function' ? formatSelectedValue(item) : valueOf(item)
const extractSelectedComparable = (s) => {
if (typeof selectedValueExtractor === "function")
return selectedValueExtractor(s);
if (s && typeof s === "object" && "title" in s) return s.title;
return s;
};
if (typeof selectedValueExtractor === 'function') return selectedValueExtractor(s)
if (s && typeof s === 'object' && 'title' in s) return s.title
return s
}
const isSelected = (val, category) => {
if (variant === "sectioned") return selected?.[category] === val;
if (variant === 'sectioned') return selected?.[category] === val
if (multiple) {
const list = Array.isArray(selected) ? selected : [];
return list.some((v) => v === val);
const list = Array.isArray(selected) ? selected : []
return list.some((v) => v === val)
}
return extractSelectedComparable(selected) === val;
};
return extractSelectedComparable(selected) === val
}
const toggleSelect = (item, category) => {
const val = valueOf(item);
const val = valueOf(item)
if (variant === "sectioned") {
const current = selected && typeof selected === "object" ? selected : {};
const next = { ...current };
if (current?.[category] === val) next[category] = null;
else next[category] = formatValue(item);
setSelected(next);
return;
if (variant === 'sectioned') {
const current = selected && typeof selected === 'object' ? selected : {}
const next = { ...current }
if (current?.[category] === val) next[category] = null
else next[category] = formatValue(item)
setSelected(next)
return
}
if (multiple) {
const list = Array.isArray(selected) ? selected : [];
const exists = list.some((v) => v === val);
if (exists) setSelected(list.filter((v) => v !== val));
const list = Array.isArray(selected) ? selected : []
const exists = list.some((v) => v === val)
if (exists) setSelected(list.filter((v) => v !== val))
else if (!maxSelection || list.length < maxSelection)
setSelected([...list, formatValue(item)]);
setSelected([...list, formatValue(item)])
} else {
if (extractSelectedComparable(selected) === val) setSelected(null);
else setSelected(formatValue(item));
if (extractSelectedComparable(selected) === val) setSelected(null)
else setSelected(formatValue(item))
}
};
}
// contenus élémentaires : toujours encapsuler le texte dans <Text>
const renderSimpleContent = (label) => (
<View style={{ minHeight: 40, justifyContent: "center" }}>
<View style={{ minHeight: 40, justifyContent: 'center' }}>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "left",
textAlign: 'left',
...itemTextStyle,
}}
>
{typeof label === "string" ? label : String(label)}
{typeof label === 'string' ? label : String(label)}
</Text>
</View>
);
)
const renderTitleDescriptionContent = (item) => (
<View style={{ paddingVertical: 6 }}>
@@ -125,14 +120,14 @@ const ListSelection = ({
}}
>
<Text style={{ fontFamily: FONT_FAMILY.InterBold }}>{item?.title}</Text>
<Text>{` : ${item?.description ?? ""}`}</Text>
<Text>{` : ${item?.description ?? ''}`}</Text>
</Text>
</View>
);
)
// applique le gradient bleu UNIQUEMENT sur web et quand sélectionné
const withWebBlueGradientIfSelected = (content, sel) => {
if (!isWeb || !sel) return content;
if (!isWeb || !sel) return content
// wrap dans un View pour éviter texte brut direct sous le gradient (compat RN Web)
return (
<LinearGradient
@@ -144,62 +139,53 @@ const ListSelection = ({
borderRadius: 14,
paddingHorizontal: 12,
paddingVertical: 8,
overflow: "hidden",
overflow: 'hidden',
}}
>
<View>{content}</View>
</LinearGradient>
);
};
)
}
// ============ SECTIONED ============
if (variant === "sectioned") {
if (variant === 'sectioned') {
return (
<SectionList
sections={options}
showsVerticalScrollIndicator={false}
contentContainerStyle={contentContainerStyle}
renderItem={({ item, section }) => {
const cat = section?.title;
const val = valueOf(item);
const sel = isSelected(val, cat);
const itemKey = buildItemKey(String(val ?? ""), cat);
const isHovered = isWeb && !disableHover && hoveredKey === itemKey;
const showHoverOutline = isHovered && !sel;
const showSelectionOutline = !isWeb && sel;
const cat = section?.title
const val = valueOf(item)
const sel = isSelected(val, cat)
const itemKey = buildItemKey(String(val ?? ''), cat)
const isHovered = isWeb && !disableHover && hoveredKey === itemKey
const showHoverOutline = isHovered && !sel
const showSelectionOutline = !isWeb && sel
const baseContent = renderSimpleContent(item);
const baseContent = renderSimpleContent(item)
const headerContainerStyle = {
borderWidth: 0,
borderColor: "transparent",
borderColor: 'transparent',
borderRadius: 14,
...itemContainerStyle,
};
if (sel && isWeb)
headerContainerStyle.backgroundColor = "transparent";
}
if (sel && isWeb) headerContainerStyle.backgroundColor = 'transparent'
return (
<View
style={[{ position: "relative" }, itemOuterStyle]}
onMouseEnter={
isWeb && !disableHover
? () => setHoveredKey(itemKey)
: undefined
}
onMouseLeave={
isWeb && !disableHover ? () => setHoveredKey(null) : undefined
}
style={[{ position: 'relative' }, itemOuterStyle]}
onMouseEnter={isWeb && !disableHover ? () => setHoveredKey(itemKey) : undefined}
onMouseLeave={isWeb && !disableHover ? () => setHoveredKey(null) : undefined}
>
<CreateLyricsHeader
onPress={() => toggleSelect(item, cat)}
tint={sel ? "default" : "dark"}
tint={sel ? 'default' : 'dark'}
colors={[Palette.tran, Palette.tran]}
showBorder={!sel}
disableBlur={sel && isWeb}
blurViewStyle={
sel && isWeb
? { paddingHorizontal: 0, paddingVertical: 0 }
: undefined
sel && isWeb ? { paddingHorizontal: 0, paddingVertical: 0 } : undefined
}
containerStyle={headerContainerStyle}
>
@@ -210,7 +196,7 @@ const ListSelection = ({
<View
pointerEvents="none"
style={{
position: "absolute",
position: 'absolute',
top: 0,
left: 0,
right: 0,
@@ -223,21 +209,17 @@ const ListSelection = ({
/>
)}
</View>
);
)
}}
renderSectionHeader={({ section: { title } }) => (
<View
style={{ alignSelf: "flex-start", marginLeft: 10, marginBottom: 6 }}
>
<View style={{ alignSelf: 'flex-start', marginLeft: 10, marginBottom: 6 }}>
<BlurView
intensity={40}
tint="dark"
experimentalBlurMethod={
Platform.OS !== "ios" ? "dimezisBlurView" : "none"
}
experimentalBlurMethod={Platform.OS !== 'ios' ? 'dimezisBlurView' : 'none'}
style={{
borderRadius: 18,
overflow: "hidden",
overflow: 'hidden',
paddingHorizontal: 8,
paddingVertical: 4,
}}
@@ -256,7 +238,7 @@ const ListSelection = ({
)}
keyExtractor={(item, idx) => `${valueOf(item)}-${idx}`}
/>
);
)
}
// ============ FLAT (simple/titleDescription/emotion) ============
@@ -266,66 +248,54 @@ const ListSelection = ({
showsVerticalScrollIndicator={false}
contentContainerStyle={contentContainerStyle}
renderItem={({ item }) => {
const val = valueOf(item);
const sel = isSelected(val);
const useEmotionColors = variant === "emotion";
const val = valueOf(item)
const sel = isSelected(val)
const useEmotionColors = variant === 'emotion'
// pour "emotion", on masque le bord gauche si sélectionné
const borderColors = useEmotionColors
? sel
? [Palette.tran, Palette.tran]
: item?.color
: [Palette.tran, Palette.tran];
const tint = useEmotionColors ? "dark" : sel ? "default" : "dark";
: [Palette.tran, Palette.tran]
const tint = useEmotionColors ? 'dark' : sel ? 'default' : 'dark'
const itemKey = buildItemKey(String(val ?? ""));
const isHovered = isWeb && !disableHover && hoveredKey === itemKey;
const showHoverOutline = isHovered && !sel;
const itemKey = buildItemKey(String(val ?? ''))
const isHovered = isWeb && !disableHover && hoveredKey === itemKey
const showHoverOutline = isHovered && !sel
const showSelectionOutline =
!isWeb &&
(variant === "simple" ||
variant === "emotion" ||
variant === "titleDescription") &&
sel;
(variant === 'simple' || variant === 'emotion' || variant === 'titleDescription') &&
sel
const baseContent =
variant === "simple"
? renderSimpleContent(item)
: renderTitleDescriptionContent(item);
variant === 'simple' ? renderSimpleContent(item) : renderTitleDescriptionContent(item)
const headerContainerStyle = {
borderWidth: 0,
borderColor: "transparent",
borderColor: 'transparent',
borderRadius: 14,
...itemContainerStyle,
};
if (sel && isWeb) headerContainerStyle.backgroundColor = "transparent";
}
if (sel && isWeb) headerContainerStyle.backgroundColor = 'transparent'
const shouldShowBorder = !sel;
const shouldShowBorder = !sel
return (
<View
style={[{ position: "relative" }, itemOuterStyle]}
onMouseEnter={
isWeb && !disableHover ? () => setHoveredKey(itemKey) : undefined
}
onMouseLeave={
isWeb && !disableHover ? () => setHoveredKey(null) : undefined
}
style={[{ position: 'relative' }, itemOuterStyle]}
onMouseEnter={isWeb && !disableHover ? () => setHoveredKey(itemKey) : undefined}
onMouseLeave={isWeb && !disableHover ? () => setHoveredKey(null) : undefined}
>
<CreateLyricsHeader
colors={borderColors}
tint={tint}
onPress={() => toggleSelect(item)}
gradientProps={
useEmotionColors && !sel ? { locations: [0.24, 1] } : undefined
}
gradientProps={useEmotionColors && !sel ? { locations: [0.24, 1] } : undefined}
showBorder={shouldShowBorder}
disableBlur={sel && isWeb} // web: pas de blur si gradient
blurViewStyle={
sel && isWeb
? { paddingHorizontal: 0, paddingVertical: 0 }
: undefined
sel && isWeb ? { paddingHorizontal: 0, paddingVertical: 0 } : undefined
}
containerStyle={headerContainerStyle}
>
@@ -336,7 +306,7 @@ const ListSelection = ({
<View
pointerEvents="none"
style={{
position: "absolute",
position: 'absolute',
top: 0,
left: 0,
right: 0,
@@ -349,11 +319,11 @@ const ListSelection = ({
/>
)}
</View>
);
)
}}
keyExtractor={(item, idx) => `${valueOf(item)}-${idx}`}
/>
);
};
)
}
export default ListSelection;
export default ListSelection
+3 -3
View File
@@ -1,4 +1,4 @@
import NativeMaskedView from "@react-native-masked-view/masked-view";
import NativeMaskedView from '@react-native-masked-view/masked-view'
const MaskedView = NativeMaskedView;
export default MaskedView;
const MaskedView = NativeMaskedView
export default MaskedView
+4 -4
View File
@@ -1,8 +1,8 @@
import React from "react";
import { View } from "react-native";
import React from 'react'
import { View } from 'react-native'
function MaskedView({ maskElement, ...props }) {
return React.createElement(View, props, maskElement);
return React.createElement(View, props, maskElement)
}
export default MaskedView;
export default MaskedView

Some files were not shown because too many files have changed in this diff Show More