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 = { module.exports = {
extends: ["expo", "prettier"], extends: ['expo', 'prettier'],
plugins: ["prettier"], plugins: ['prettier'],
rules: { rules: {
"prettier/prettier": [ 'prettier/prettier': [
"error", 'error',
{ {
singleQuote: false, 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 '@expo/metro-runtime'
import { PortalProvider } from "@gorhom/portal"; import { PortalProvider } from '@gorhom/portal'
import { DefaultTheme, NavigationContainer } from "@react-navigation/native"; import { DefaultTheme, NavigationContainer } from '@react-navigation/native'
import { useFonts } from "expo-font"; import { useFonts } from 'expo-font'
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from 'expo-splash-screen'
import { StatusBar } from "expo-status-bar"; import { StatusBar } from 'expo-status-bar'
import moment from "moment"; import moment from 'moment'
import "moment/locale/fr"; import 'moment/locale/fr'
import { Platform, Text, TextInput } from "react-native"; import { Platform, Text, TextInput } from 'react-native'
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from 'react-native-gesture-handler'
import React, { import React, { setGlobal, useCallback, useEffect, useGlobal, useState } from 'reactn'
setGlobal,
useCallback,
useEffect,
useGlobal,
useState,
} from "reactn";
import { MainStack, Routes } from "./src/navigation"; import { MainStack, Routes } from './src/navigation'
import { Palette } from "./src/styles"; 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 { MenuProvider } from 'react-native-popup-menu'
import initialGlobalState from "./src/config/initialGlobalState"; import initialGlobalState from './src/config/initialGlobalState'
import { import {
Inter_400Regular, Inter_400Regular,
@@ -30,143 +24,143 @@ import {
Inter_500Medium, Inter_500Medium,
Inter_600SemiBold, Inter_600SemiBold,
Inter_700Bold, Inter_700Bold,
} from "@expo-google-fonts/inter"; } from '@expo-google-fonts/inter'
import firebase from "./src/config/firebase"; import firebase from './src/config/firebase'
import Providers from "./src/providers"; import Providers from './src/providers'
import { LogBox } from "react-native"; import { LogBox } from 'react-native'
import CommentsBottomSheet from "./src/components/bottomsheets/CommentsBottomSheet"; import CommentsBottomSheet from './src/components/bottomsheets/CommentsBottomSheet'
import ShareQrModalContainer from "./src/components/modal/ShareQrModalContainer"; import ShareQrModalContainer from './src/components/modal/ShareQrModalContainer'
import AppDownloadBanner from "./src/components/AppDownloadBanner"; import AppDownloadBanner from './src/components/AppDownloadBanner'
import MobileSplashVideo from "./src/components/MobileSplashVideo"; import MobileSplashVideo from './src/components/MobileSplashVideo'
import "./src/utils/Sheet"; import './src/utils/Sheet'
console.disableYellowBox = true; console.disableYellowBox = true
console.reportErrorsAsExceptions = false; console.reportErrorsAsExceptions = false
moment.locale("fr"); moment.locale('fr')
// Keep typography stable regardless of the user's system font scaling on mobile // 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) { if (Text.defaultProps == null) {
Text.defaultProps = {}; Text.defaultProps = {}
} }
if (TextInput.defaultProps == null) { if (TextInput.defaultProps == null) {
TextInput.defaultProps = {}; TextInput.defaultProps = {}
} }
Text.defaultProps.allowFontScaling = false; Text.defaultProps.allowFontScaling = false
TextInput.defaultProps.allowFontScaling = false; TextInput.defaultProps.allowFontScaling = false
} }
setGlobal(initialGlobalState); setGlobal(initialGlobalState)
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync()
const App = () => { const App = () => {
const [, setCurrentUID] = useGlobal("currentUID"); const [, setCurrentUID] = useGlobal('currentUID')
const [, setCurrentUserRoles] = useGlobal("currentUserRoles"); 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 syncActiveRoute = useCallback(() => {
const currentRoute = navigationRef.current?.getCurrentRoute(); const currentRoute = navigationRef.current?.getCurrentRoute()
const currentName = currentRoute?.name ?? null; const currentName = currentRoute?.name ?? null
if (routeNameRef.current !== currentName) { if (routeNameRef.current !== currentName) {
routeNameRef.current = currentName; routeNameRef.current = currentName
setGlobal({ activeRouteName: currentName }); setGlobal({ activeRouteName: currentName })
} }
}, [setGlobal]); }, [setGlobal])
const handleNavigationReady = useCallback(() => { const handleNavigationReady = useCallback(() => {
syncActiveRoute(); syncActiveRoute()
}, [syncActiveRoute]); }, [syncActiveRoute])
const handleNavigationStateChange = useCallback(() => { const handleNavigationStateChange = useCallback(() => {
syncActiveRoute(); syncActiveRoute()
}, [syncActiveRoute]); }, [syncActiveRoute])
const [loaded] = useFonts({ const [loaded] = useFonts({
NewYorkSemibold: require("./src/assets/fonts/NewYork-Semibold.ttf"), NewYorkSemibold: require('./src/assets/fonts/NewYork-Semibold.ttf'),
OpenSansRegular: require("./src/assets/fonts/OpenSans-Regular.ttf"), OpenSansRegular: require('./src/assets/fonts/OpenSans-Regular.ttf'),
InterRegular: Inter_400Regular, InterRegular: Inter_400Regular,
InterMedium: Inter_500Medium, InterMedium: Inter_500Medium,
InterSemiBold: Inter_600SemiBold, InterSemiBold: Inter_600SemiBold,
InterBold: Inter_700Bold, InterBold: Inter_700Bold,
InterRegularItalic: Inter_400Regular_Italic, InterRegularItalic: Inter_400Regular_Italic,
HelveticaNeueRegular: require("./src/assets/fonts/HelveticaNeueRegular.ttf"), HelveticaNeueRegular: require('./src/assets/fonts/HelveticaNeueRegular.ttf'),
HelveticaNeueMedium: require("./src/assets/fonts/HelveticaNeueMedium.ttf"), HelveticaNeueMedium: require('./src/assets/fonts/HelveticaNeueMedium.ttf'),
HelveticaNeueBold: require("./src/assets/fonts/HelveticaNeueBold.ttf"), HelveticaNeueBold: require('./src/assets/fonts/HelveticaNeueBold.ttf'),
OwnersRegular: require("./src/assets/fonts/OwnersRegular.ttf"), OwnersRegular: require('./src/assets/fonts/OwnersRegular.ttf'),
OwnersMedium: require("./src/assets/fonts/OwnersMedium.ttf"), OwnersMedium: require('./src/assets/fonts/OwnersMedium.ttf'),
OwnersBold: require("./src/assets/fonts/OwnersBold.ttf"), OwnersBold: require('./src/assets/fonts/OwnersBold.ttf'),
}); })
useEffect(() => { useEffect(() => {
if (loaded) { if (loaded) {
setAppIsReady(true); setAppIsReady(true)
LogBox.ignoreAllLogs(); LogBox.ignoreAllLogs()
} }
}, [loaded]); }, [loaded])
const onLayoutRootView = useCallback(async () => { const onLayoutRootView = useCallback(async () => {
if (appIsReady) { if (appIsReady) {
await SplashScreen.hideAsync(); await SplashScreen.hideAsync()
} }
}, [appIsReady, loaded]); }, [appIsReady, loaded])
useEffect(() => { useEffect(() => {
const subscriber = firebase.auth().onAuthStateChanged(onAuthStateChanged); const subscriber = firebase.auth().onAuthStateChanged(onAuthStateChanged)
return subscriber; return subscriber
}, []); }, [])
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") { if (Platform.OS === 'web') {
const s = document.createElement("script"); const s = document.createElement('script')
s.src = "https://minuit.app/embed/report-a-problem.js"; s.src = 'https://minuit.app/embed/report-a-problem.js'
s.async = true; s.async = true
s.setAttribute("data-project-id", "playbackproduction"); s.setAttribute('data-project-id', 'playbackproduction')
s.setAttribute("data-position", "right"); s.setAttribute('data-position', 'right')
s.setAttribute("data-offset", "16"); s.setAttribute('data-offset', '16')
s.setAttribute("data-primary-color", "#FB68A8"); s.setAttribute('data-primary-color', '#FB68A8')
s.setAttribute("data-bg-primary-color", "#0E0E12"); s.setAttribute('data-bg-primary-color', '#0E0E12')
s.setAttribute("data-bg-secondary-color", "#1D1825"); s.setAttribute('data-bg-secondary-color', '#1D1825')
s.setAttribute("data-text-color", "#F3F0F5"); s.setAttribute('data-text-color', '#F3F0F5')
document.body.appendChild(s); document.body.appendChild(s)
return () => { return () => {
try { try {
s.remove(); s.remove()
} catch (_) { } } catch (_) {}
}; }
} }
}, []); }, [])
const onAuthStateChanged = async (user) => { const onAuthStateChanged = async (user) => {
if (isInitializing) { if (isInitializing) {
setInitializing(false); setInitializing(false)
} }
if (user?.uid) { if (user?.uid) {
setCurrentUID(user.uid); setCurrentUID(user.uid)
const idTokenResult = await user.getIdTokenResult(); const idTokenResult = await user.getIdTokenResult()
setCurrentUserRoles(idTokenResult?.claims?.roles || []); setCurrentUserRoles(idTokenResult?.claims?.roles || [])
} else { } else {
setCurrentUID(null); setCurrentUID(null)
setGlobal(initialGlobalState); setGlobal(initialGlobalState)
reset({ reset({
index: 0, index: 0,
routes: [{ name: Routes.Splash }], routes: [{ name: Routes.Splash }],
}); })
} }
}; }
if (!loaded || isInitializing) { if (!loaded || isInitializing) {
return null; return null
} }
return ( return (
<> <>
@@ -177,8 +171,7 @@ const App = () => {
style={[ style={[
{ {
flex: 1, flex: 1,
backgroundColor: backgroundColor: Platform.OS === 'web' ? 'transparent' : Palette.darkPurple,
Platform.OS === "web" ? "transparent" : Palette.darkPurple,
}, },
]} ]}
> >
@@ -191,10 +184,7 @@ const App = () => {
...DefaultTheme, ...DefaultTheme,
colors: { colors: {
...DefaultTheme.colors, ...DefaultTheme.colors,
background: background: Platform.OS === 'web' ? 'transparent' : Palette.darkPurple,
Platform.OS === "web"
? "transparent"
: Palette.darkPurple,
}, },
}} }}
ref={navigationRef} ref={navigationRef}
@@ -202,12 +192,10 @@ const App = () => {
onStateChange={handleNavigationStateChange} onStateChange={handleNavigationStateChange}
documentTitle={{ documentTitle={{
formatter: (options) => formatter: (options) =>
options?.title options?.title ? `${options?.title} - MusicLand` : 'MusicLand',
? `${options?.title} - MusicLand`
: "MusicLand",
}} }}
linking={{ linking={{
prefixes: ["musicland://", "https://musicland-one.vercel.app"], prefixes: ['musicland://', 'https://musicland-one.vercel.app'],
config: { config: {
screens: { screens: {
// Add any deep link configurations here if needed // Add any deep link configurations here if needed
@@ -227,7 +215,7 @@ const App = () => {
</PortalProvider> </PortalProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
</> </>
); )
}; }
export default App; export default App
+5 -8
View File
@@ -1,10 +1,7 @@
module.exports = function (api) { module.exports = function (api) {
api.cache(true); api.cache(true)
return { return {
presets: ["babel-preset-expo"], presets: ['babel-preset-expo'],
plugins: [ plugins: ['@babel/plugin-proposal-export-namespace-from', 'react-native-reanimated/plugin'],
"@babel/plugin-proposal-export-namespace-from", }
"react-native-reanimated/plugin", }
],
};
};
+89 -131
View File
@@ -1,69 +1,69 @@
const fs = require("fs"); const fs = require('fs')
const path = require("path"); const path = require('path')
const parser = require("@babel/parser"); const parser = require('@babel/parser')
const traverse = require("@babel/traverse").default; const traverse = require('@babel/traverse').default
const entryFile = path.resolve(__dirname, "index.js"); const entryFile = path.resolve(__dirname, 'index.js')
const srcDir = path.resolve(__dirname, "src"); const srcDir = path.resolve(__dirname, 'src')
// Extensions de fichiers à analyser // Extensions de fichiers à analyser
const scriptExtensions = [".js", ".jsx", ".ts", ".tsx", ".web.js"]; const scriptExtensions = ['.js', '.jsx', '.ts', '.tsx', '.web.js']
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".svg"]; const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg']
// Fonction pour obtenir tous les fichiers dans un dossier avec des extensions spécifiques // Fonction pour obtenir tous les fichiers dans un dossier avec des extensions spécifiques
function getAllFiles(dir, extensions, fileList = []) { function getAllFiles(dir, extensions, fileList = []) {
const files = fs.readdirSync(dir); const files = fs.readdirSync(dir)
files.forEach(function (file) { files.forEach(function (file) {
const filePath = path.join(dir, file); const filePath = path.join(dir, file)
const stat = fs.statSync(filePath); const stat = fs.statSync(filePath)
if (stat.isDirectory()) { if (stat.isDirectory()) {
getAllFiles(filePath, extensions, fileList); getAllFiles(filePath, extensions, fileList)
} else if (extensions.includes(path.extname(file))) { } 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 // Vérifie si un chemin est un fichier valide
function isValidFile(filePath) { 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 // Résout le chemin de l'import en un fichier valide
function resolveImport(filePath, importPath) { 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 // 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 // Si le chemin n'a pas d'extension, essayer avec différentes extensions
if (!path.extname(importedFile)) { if (!path.extname(importedFile)) {
for (const ext of allExtensions) { for (const ext of allExtensions) {
if (isValidFile(importedFile + ext)) { if (isValidFile(importedFile + ext)) {
return importedFile + ext; return importedFile + ext
} }
} }
} }
// Si un fichier avec l'extension actuelle existe, le retourner // Si un fichier avec l'extension actuelle existe, le retourner
if (isValidFile(importedFile)) { if (isValidFile(importedFile)) {
return importedFile; return importedFile
} }
// Si le chemin est un répertoire, chercher un index avec les extensions // Si le chemin est un répertoire, chercher un index avec les extensions
if (fs.existsSync(importedFile) && fs.statSync(importedFile).isDirectory()) { 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) { for (const indexFile of indexFiles) {
const indexFilePath = path.join(importedFile, indexFile); const indexFilePath = path.join(importedFile, indexFile)
if (isValidFile(indexFilePath)) { if (isValidFile(indexFilePath)) {
return indexFilePath; return indexFilePath
} }
} }
} }
// Aucun fichier valide trouvé // Aucun fichier valide trouvé
return null; return null
} }
// Analyse des fichiers pour trouver les imports // Analyse des fichiers pour trouver les imports
@@ -74,178 +74,136 @@ function getDependencies(
usedFiles = new Set(), usedFiles = new Set(),
usedAssets = new Set() usedAssets = new Set()
) { ) {
if (visitedFiles.has(filePath)) if (visitedFiles.has(filePath)) return { usedDependencies, usedFiles, usedAssets }
return { usedDependencies, usedFiles, usedAssets }; visitedFiles.add(filePath)
visitedFiles.add(filePath); usedFiles.add(filePath)
usedFiles.add(filePath);
const content = fs.readFileSync(filePath, "utf-8"); const content = fs.readFileSync(filePath, 'utf-8')
let ast; let ast
try { try {
ast = parser.parse(content, { ast = parser.parse(content, {
sourceType: "module", sourceType: 'module',
plugins: ["jsx", "typescript", "classProperties", "dynamicImport"], plugins: ['jsx', 'typescript', 'classProperties', 'dynamicImport'],
}); })
} catch (error) { } catch (error) {
console.error(`Erreur lors de l'analyse du fichier ${filePath}:`, error); console.error(`Erreur lors de l'analyse du fichier ${filePath}:`, error)
return { usedDependencies, usedFiles, usedAssets }; return { usedDependencies, usedFiles, usedAssets }
} }
traverse(ast, { traverse(ast, {
ImportDeclaration({ node }) { ImportDeclaration({ node }) {
const importPath = node.source.value; const importPath = node.source.value
handleImport( handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets)
filePath,
importPath,
visitedFiles,
usedDependencies,
usedFiles,
usedAssets
);
}, },
CallExpression({ node }) { CallExpression({ node }) {
if ( if (
node.callee.type === "Import" || node.callee.type === 'Import' ||
(node.callee.name === "require" && node.arguments.length) (node.callee.name === 'require' && node.arguments.length)
) { ) {
const importArg = node.arguments[0]; const importArg = node.arguments[0]
if (importArg && importArg.type === "StringLiteral") { if (importArg && importArg.type === 'StringLiteral') {
const importPath = importArg.value; const importPath = importArg.value
handleImport( handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets)
filePath,
importPath,
visitedFiles,
usedDependencies,
usedFiles,
usedAssets
);
} }
} }
}, },
}); })
// Vérifier si un fichier .web.js correspondant existe // Vérifier si un fichier .web.js correspondant existe
if (filePath.endsWith(".js")) { if (filePath.endsWith('.js')) {
const webFilePath = filePath.replace(/\.js$/, ".web.js"); const webFilePath = filePath.replace(/\.js$/, '.web.js')
if (isValidFile(webFilePath)) { if (isValidFile(webFilePath)) {
getDependencies( getDependencies(webFilePath, visitedFiles, usedDependencies, usedFiles, usedAssets)
webFilePath,
visitedFiles,
usedDependencies,
usedFiles,
usedAssets
);
} }
} }
return { usedDependencies, usedFiles, usedAssets }; return { usedDependencies, usedFiles, usedAssets }
} }
// Fonction pour gérer les imports // Fonction pour gérer les imports
function handleImport( function handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets) {
filePath, if (importPath.startsWith('.')) {
importPath,
visitedFiles,
usedDependencies,
usedFiles,
usedAssets
) {
if (importPath.startsWith(".")) {
// Chemin relatif // Chemin relatif
const resolvedPath = resolveImport(filePath, importPath); const resolvedPath = resolveImport(filePath, importPath)
if (resolvedPath) { if (resolvedPath) {
const ext = path.extname(resolvedPath); const ext = path.extname(resolvedPath)
if (scriptExtensions.includes(ext)) { if (scriptExtensions.includes(ext)) {
getDependencies( getDependencies(resolvedPath, visitedFiles, usedDependencies, usedFiles, usedAssets)
resolvedPath,
visitedFiles,
usedDependencies,
usedFiles,
usedAssets
);
} else if (imageExtensions.includes(ext)) { } else if (imageExtensions.includes(ext)) {
usedAssets.add(resolvedPath); usedAssets.add(resolvedPath)
} }
} }
} else { } else {
// Dépendance de node_modules // Dépendance de node_modules
const dep = importPath.split("/")[0]; const dep = importPath.split('/')[0]
usedDependencies.add(dep); usedDependencies.add(dep)
} }
} }
(async () => { ;(async () => {
// Étape 1: Obtenir tous les fichiers utilisés // Étape 1: Obtenir tous les fichiers utilisés
const allScriptFiles = getAllFiles(srcDir, scriptExtensions); const allScriptFiles = getAllFiles(srcDir, scriptExtensions)
const allAssetFiles = getAllFiles(srcDir, imageExtensions); const allAssetFiles = getAllFiles(srcDir, imageExtensions)
const { usedDependencies, usedFiles, usedAssets } = const { usedDependencies, usedFiles, usedAssets } = getDependencies(entryFile)
getDependencies(entryFile);
// Étape 2: Supprimer les fichiers scripts inutilisés // Étape 2: Supprimer les fichiers scripts inutilisés
const unusedScriptFiles = allScriptFiles.filter( const unusedScriptFiles = allScriptFiles.filter((file) => !usedFiles.has(file))
(file) => !usedFiles.has(file)
);
unusedScriptFiles.forEach((file) => { unusedScriptFiles.forEach((file) => {
fs.unlinkSync(file); fs.unlinkSync(file)
console.log(`Fichier script supprimé: ${file}`); console.log(`Fichier script supprimé: ${file}`)
}); })
// Étape 3: Supprimer les images non utilisées // Étape 3: Supprimer les images non utilisées
const unusedAssetFiles = allAssetFiles.filter( const unusedAssetFiles = allAssetFiles.filter((file) => !usedAssets.has(file))
(file) => !usedAssets.has(file)
);
unusedAssetFiles.forEach((file) => { unusedAssetFiles.forEach((file) => {
fs.unlinkSync(file); fs.unlinkSync(file)
console.log(`Fichier image supprimé: ${file}`); console.log(`Fichier image supprimé: ${file}`)
}); })
// Étape 4: Optimiser les images utilisées sans perte de qualité // Étape 4: Optimiser les images utilisées sans perte de qualité
async function optimizeImages(usedAssets) { async function optimizeImages(usedAssets) {
// Import dynamique des modules ES // Import dynamique des modules ES
const imagemin = (await import("imagemin")).default; const imagemin = (await import('imagemin')).default
const imageminOptipng = (await import("imagemin-optipng")).default; const imageminOptipng = (await import('imagemin-optipng')).default
const imageminJpegtran = (await import("imagemin-jpegtran")).default; const imageminJpegtran = (await import('imagemin-jpegtran')).default
const imageminGifsicle = (await import("imagemin-gifsicle")).default; const imageminGifsicle = (await import('imagemin-gifsicle')).default
const imageminSvgo = (await import("imagemin-svgo")).default; const imageminSvgo = (await import('imagemin-svgo')).default
for (const file of usedAssets) { for (const file of usedAssets) {
const ext = path.extname(file).toLowerCase(); const ext = path.extname(file).toLowerCase()
const plugins = []; const plugins = []
if (ext === ".png") { if (ext === '.png') {
plugins.push(imageminOptipng({ optimizationLevel: 3 })); plugins.push(imageminOptipng({ optimizationLevel: 3 }))
} else if (ext === ".jpg" || ext === ".jpeg") { } else if (ext === '.jpg' || ext === '.jpeg') {
plugins.push(imageminJpegtran({ progressive: true })); plugins.push(imageminJpegtran({ progressive: true }))
} else if (ext === ".gif") { } else if (ext === '.gif') {
plugins.push(imageminGifsicle({ optimizationLevel: 3 })); plugins.push(imageminGifsicle({ optimizationLevel: 3 }))
} else if (ext === ".svg") { } else if (ext === '.svg') {
plugins.push(imageminSvgo()); plugins.push(imageminSvgo())
} else { } else {
continue; continue
} }
try { try {
const optimized = await imagemin([file], { const optimized = await imagemin([file], {
destination: path.dirname(file), destination: path.dirname(file),
plugins: plugins, plugins: plugins,
}); })
if (optimized && optimized.length > 0) { if (optimized && optimized.length > 0) {
console.log(`Image optimisée: ${file}`); console.log(`Image optimisée: ${file}`)
} else { } 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) { } catch (error) {
console.error( console.error(`Erreur lors de l'optimisation de l'image ${file}:`, 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 = exports.STRIPE_SECRET_KEY =
"sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu"; 'sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu'
exports.STRIPE_WEBHOOK_SECRET = "whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW"; exports.STRIPE_WEBHOOK_SECRET = 'whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW'
exports.STRIPE_RETURN_URL = ""; exports.STRIPE_RETURN_URL = ''
+6 -7
View File
@@ -1,8 +1,7 @@
exports.SUNO_API_BASE = "https://api.sunoapi.org"; exports.SUNO_API_BASE = 'https://api.sunoapi.org'
exports.SUNO_API_PATH = "/api/v1/generate"; exports.SUNO_API_PATH = '/api/v1/generate'
exports.SUNO_STATUS_PATH = "/api/v1/generate/record-info"; exports.SUNO_STATUS_PATH = '/api/v1/generate/record-info'
exports.SUNO_TIMESTAMPED_LYRICS_PATH = exports.SUNO_TIMESTAMPED_LYRICS_PATH = '/api/v1/generate/get-timestamped-lyrics'
"/api/v1/generate/get-timestamped-lyrics"; exports.SUNO_MODEL = 'V5'
exports.SUNO_MODEL = "V5";
exports.SUNO_CALLBACK_URL = 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 = { exports.BATCH_TYPE = {
ADD: "ADD", ADD: 'ADD',
UPDATE: "UPDATE", UPDATE: 'UPDATE',
DELETE: "DELETE", DELETE: 'DELETE',
}; }
+16 -17
View File
@@ -1,18 +1,17 @@
const fs = require("fs"); const fs = require('fs')
const path = require("path"); const path = require('path')
const musicLandLogoBase64 = fs.readFileSync( const musicLandLogoBase64 = fs.readFileSync(path.join(__dirname, '../assets/musicLandLogo.png'), {
path.join(__dirname, "../assets/musicLandLogo.png"), encoding: 'base64',
{ encoding: "base64" }, })
); const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`
const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`;
function basicTemplate({ title = "", content = "", button = null }) { function basicTemplate({ title = '', content = '', button = null }) {
const btn = button?.url 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">${ ? `<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>` }</a></p>`
: ""; : ''
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head> return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="color-scheme" content="dark" /> <meta name="color-scheme" content="dark" />
@@ -50,12 +49,12 @@ function basicTemplate({ title = "", content = "", button = null }) {
</td> </td>
</tr> </tr>
</table> </table>
</body></html>`; </body></html>`
} }
function welcomeTemplate({ firstName = "", lastName = "" }) { function welcomeTemplate({ firstName = '', lastName = '' }) {
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim(); const fullName = [firstName, lastName].filter(Boolean).join(' ').trim()
const greeting = fullName ? `Salut ${fullName},` : "Salut,"; const greeting = fullName ? `Salut ${fullName},` : 'Salut,'
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head> return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="color-scheme" content="dark" /> <meta name="color-scheme" content="dark" />
@@ -104,8 +103,8 @@ function welcomeTemplate({ firstName = "", lastName = "" }) {
</td> </td>
</tr> </tr>
</table> </table>
</body></html>`; </body></html>`
} }
exports.basicTemplate = basicTemplate; exports.basicTemplate = basicTemplate
exports.welcomeTemplate = welcomeTemplate; exports.welcomeTemplate = welcomeTemplate
+34 -40
View File
@@ -1,88 +1,82 @@
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const { BATCH_TYPE } = require("../config/types"); const { BATCH_TYPE } = require('../config/types')
async function deleteFolder(path) { async function deleteFolder(path) {
try { try {
console.log(`Deleting folder: ${path}`); console.log(`Deleting folder: ${path}`)
const bucket = admin.storage().bucket(); const bucket = admin.storage().bucket()
await bucket.deleteFiles({ prefix: path, force: true }); await bucket.deleteFiles({ prefix: path, force: true })
console.log(`Folder ${path} deleted successfully`); console.log(`Folder ${path} deleted successfully`)
} catch (e) { } catch (e) {
console.log(e); console.log(e)
} }
} }
async function batchFirestore({ async function batchFirestore({
path = "", // ADD only path = '', // ADD only
docs = [], // not for ADD docs = [], // not for ADD
data = {}, // not for DELETE data = {}, // not for DELETE
type = BATCH_TYPE.UPDATE, type = BATCH_TYPE.UPDATE,
}) { }) {
try { try {
if (docs?.length === 0 && type !== BATCH_TYPE.ADD) { if (docs?.length === 0 && type !== BATCH_TYPE.ADD) {
console.log(`Aucun document trouvé dans la collection ${path}.`); console.log(`Aucun document trouvé dans la collection ${path}.`)
return; return
} }
// Préparer des lots pour les opérations // Préparer des lots pour les opérations
const batches = []; const batches = []
let currentBatch = admin.firestore().batch(); let currentBatch = admin.firestore().batch()
let operationCounter = 0; let operationCounter = 0
docs.forEach((doc) => { docs.forEach((doc) => {
const ref = const ref =
doc?.ref || doc?.ref || (typeof doc?.path === 'string' ? admin.firestore().doc(doc.path) : null)
(typeof doc?.path === "string"
? admin.firestore().doc(doc.path)
: null);
const payload = 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 ? doc.data
: data; : data
if (type === BATCH_TYPE.ADD) { if (type === BATCH_TYPE.ADD) {
// Pour ADD, créer un nouveau document avec ID automatique // Pour ADD, créer un nouveau document avec ID automatique
const newDocRef = admin.firestore().collection(path).doc(); const newDocRef = admin.firestore().collection(path).doc()
currentBatch.set(newDocRef, data); currentBatch.set(newDocRef, data)
} else if (type === BATCH_TYPE.UPDATE) { } else if (type === BATCH_TYPE.UPDATE) {
if (!ref) { 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) { 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) { } else if (type === BATCH_TYPE.DELETE) {
if (!ref) { 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 { } 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 // Si le lot atteint la limite de 500 opérations, le sauvegarder et en créer un nouveau
if (operationCounter === 500) { if (operationCounter === 500) {
batches.push(currentBatch); batches.push(currentBatch)
currentBatch = admin.firestore().batch(); currentBatch = admin.firestore().batch()
operationCounter = 0; operationCounter = 0
} }
}); })
// Ajouter le dernier lot si des opérations y sont présentes // Ajouter le dernier lot si des opérations y sont présentes
if (operationCounter > 0) { if (operationCounter > 0) {
batches.push(currentBatch); batches.push(currentBatch)
} }
// Exécuter tous les lots en parallèle // 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) { } catch (error) {
console.error( console.error(`Erreur lors des opérations ${type} pour la collection ${path} :`, error)
`Erreur lors des opérations ${type} pour la collection ${path} :`,
error,
);
} }
} }
exports.batchFirestore = batchFirestore; exports.batchFirestore = batchFirestore
exports.deleteFolder = deleteFolder; exports.deleteFolder = deleteFolder
+99 -114
View File
@@ -1,44 +1,42 @@
const { googleAI } = require("@genkit-ai/googleai"); const { googleAI } = require('@genkit-ai/googleai')
const { genkit, z } = require("genkit"); const { genkit, z } = require('genkit')
const { GEMINI_API_KEY } = require("../config/keys"); const { GEMINI_API_KEY } = require('../config/keys')
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const { Buffer } = require("buffer"); const { Buffer } = require('buffer')
const { setTimeout } = require("timers/promises"); const { setTimeout } = require('timers/promises')
// --- CONFIGURATION --- // --- CONFIGURATION ---
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API. // Plus intelligente que le Flash original, ultra rapide, et stable sur l'API.
const TEXT_MODEL_NAME = "gemini-3-pro-preview"; const TEXT_MODEL_NAME = 'gemini-3-pro-preview'
const IMAGE_MODEL_NAME = "gemini-3-pro-image-preview"; const IMAGE_MODEL_NAME = 'gemini-3-pro-image-preview'
// --- SINGLETON PATTERN (WARM START) --- // --- SINGLETON PATTERN (WARM START) ---
// On stocke l'instance en dehors de la fonction pour la réutiliser // On stocke l'instance en dehors de la fonction pour la réutiliser
// entre les invocations si le conteneur est "chaud". // entre les invocations si le conteneur est "chaud".
let aiInstance = null; let aiInstance = null
const getAiInstance = () => { const getAiInstance = () => {
if (!aiInstance) { if (!aiInstance) {
console.log("⚡ [Gemini] Initialisation froide (Cold Start)"); console.log('⚡ [Gemini] Initialisation froide (Cold Start)')
aiInstance = genkit({ aiInstance = genkit({
plugins: [googleAI({ apiKey: GEMINI_API_KEY })], plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
}); })
} }
return aiInstance; return aiInstance
}; }
/** /**
* Génération de texte générique * Génération de texte générique
*/ */
exports.generateAI = async ({ system = "", prompt = "", schema }) => { exports.generateAI = async ({ system = '', prompt = '', schema }) => {
const ai = getAiInstance(); // Récupère l'instance singleton const ai = getAiInstance() // Récupère l'instance singleton
if (prompt?.length < 1) { if (prompt?.length < 1) {
throw new Error( throw new Error('Vous devez spécifier un prompt pour effectuer cette action.')
"Vous devez spécifier un prompt pour effectuer cette action.",
);
} }
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`); console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`)
const startedAt = Date.now(); const startedAt = Date.now()
try { try {
const { output } = await ai.generate({ const { output } = await ai.generate({
@@ -49,76 +47,68 @@ exports.generateAI = async ({ system = "", prompt = "", schema }) => {
config: { config: {
temperature: 0.7, // Créativité équilibrée temperature: 0.7, // Créativité équilibrée
}, },
}); })
return output; return output
} catch (error) { } catch (error) {
console.error("❌ [generateAI] Error:", error.message); console.error('❌ [generateAI] Error:', error.message)
throw error; throw error
} finally { } finally {
console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`); console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`)
} }
}; }
/** /**
* Analyse la toxicité des paroles. * Analyse la toxicité des paroles.
* Utilise gemini-1.5-flash-002 avec des réglages permissifs pour l'analyse. * Utilise gemini-1.5-flash-002 avec des réglages permissifs pour l'analyse.
*/ */
exports.analyseLyrics = async ({ title = "", lyrics }) => { exports.analyseLyrics = async ({ title = '', lyrics }) => {
const ai = getAiInstance(); const ai = getAiInstance()
// --- Normalisation --- // --- Normalisation ---
const normalizeLyrics = (raw) => { const normalizeLyrics = (raw) => {
if (!raw) return ""; if (!raw) return ''
if (typeof raw === "string") return raw; if (typeof raw === 'string') return raw
if (Array.isArray(raw)) { if (Array.isArray(raw)) {
return raw return raw
.map((s) => { .map((s) => {
if (!s) return ""; if (!s) return ''
const label = s.type ? String(s.type).toUpperCase() : "SECTION"; const label = s.type ? String(s.type).toUpperCase() : 'SECTION'
return `[${label}]\n${s.lyrics || ""}`; return `[${label}]\n${s.lyrics || ''}`
}) })
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join('\n\n')
} }
if (typeof raw === "object") { if (typeof raw === 'object') {
const parts = []; const parts = []
if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`); if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`)
if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`); if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`)
return parts.join("\n\n"); return parts.join('\n\n')
} }
return String(raw || ""); return String(raw || '')
}; }
const lyricsText = normalizeLyrics(lyrics).trim(); const lyricsText = normalizeLyrics(lyrics).trim()
if (!lyricsText) throw new Error("analyseLyrics: paroles requises."); if (!lyricsText) throw new Error('analyseLyrics: paroles requises.')
// --- Schéma --- // --- Schéma ---
const moderationSchema = z.object({ const moderationSchema = z.object({
title: z.string().describe("Titre analysé"), title: z.string().describe('Titre analysé'),
flagged: z flagged: z.boolean().describe('Vrai si le contenu nécessite un avertissement.'),
.boolean() blocked: z.boolean().describe('Vrai UNIQUEMENT si violation grave (Haine, Violence réelle).'),
.describe("Vrai si le contenu nécessite un avertissement."), score: z.number().min(0).max(1).describe('Score de risque (0=Sûr, 1=Dangereux).'),
blocked: z reasons: z.array(z.string()).describe('Liste concise des raisons.'),
.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 excerpts: z
.array( .array(
z.object({ z.object({
quote: z.string(), quote: z.string(),
category: z.string(), category: z.string(),
severity: z.string(), severity: z.string(),
}), })
) )
.max(10), .max(10),
success: z.boolean(), success: z.boolean(),
}); })
// --- Prompt --- // --- Prompt ---
const system = `Tu es un Expert en Modération de Contenu Musical (Trust & Safety). 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. 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. 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 = ` const userPrompt = `
ANALYSE CETTE CHANSON : ANALYSE CETTE CHANSON :
Titre : ${title || "Inconnu"} Titre : ${title || 'Inconnu'}
PAROLES : PAROLES :
""" """
${lyricsText} ${lyricsText}
""" """
`; `
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`); console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`)
const startedAt = Date.now(); const startedAt = Date.now()
try { try {
const { output } = await ai.generate({ const { output } = await ai.generate({
@@ -151,108 +141,103 @@ ${lyricsText}
config: { config: {
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu // Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
safetySettings: [ safetySettings: [
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" }, { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
{ {
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
threshold: "BLOCK_NONE", threshold: 'BLOCK_NONE',
}, },
{ {
category: "HARM_CATEGORY_DANGEROUS_CONTENT", category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
threshold: "BLOCK_NONE", 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 // Correction de cohérence
if (output.blocked) { if (output.blocked) {
output.flagged = true; output.flagged = true
if (output.score < 0.7) output.score = 0.85; if (output.score < 0.7) output.score = 0.85
} }
return output; return output
} finally { } 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 * Génération d'image via Imagen 3
*/ */
exports.generateImageV2 = async (prompt, size = 1024, path = "") => { exports.generateImageV2 = async (prompt, size = 1024, path = '') => {
const ai = getAiInstance(); const ai = getAiInstance()
if (typeof prompt !== "string" || prompt.trim().length < 1) { if (typeof prompt !== 'string' || prompt.trim().length < 1) {
throw new Error("Prompt requis."); throw new Error('Prompt requis.')
} }
console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`); console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`)
const startedAt = Date.now(); const startedAt = Date.now()
// Optimisation du prompt pour Imagen // Optimisation du prompt pour Imagen
let enhancedPrompt = prompt.trim(); let enhancedPrompt = prompt.trim()
if (!enhancedPrompt.toLowerCase().includes("high quality")) { if (!enhancedPrompt.toLowerCase().includes('high quality')) {
enhancedPrompt += ", high quality, detailed, 4k"; enhancedPrompt += ', high quality, detailed, 4k'
} }
// Aspect ratio 1:1 pour les pochettes // Aspect ratio 1:1 pour les pochettes
enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`; enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`
const maxAttempts = 3; const maxAttempts = 3
let lastError = null; let lastError = null
try { try {
for (let attempt = 1; attempt <= maxAttempts; attempt++) { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try { try {
console.log(`🔄 Tentative ${attempt}/${maxAttempts}`); console.log(`🔄 Tentative ${attempt}/${maxAttempts}`)
const response = await ai.generate({ const response = await ai.generate({
model: googleAI.model(IMAGE_MODEL_NAME), model: googleAI.model(IMAGE_MODEL_NAME),
prompt: enhancedPrompt, prompt: enhancedPrompt,
}); })
const media = response.media; const media = response.media
if (media && media.url) { if (media && media.url) {
console.log("✅ Image générée."); console.log('✅ Image générée.')
// --- Sauvegarde dans Firebase Storage --- // --- Sauvegarde dans Firebase Storage ---
const dataUrl = String(media.url); const dataUrl = String(media.url)
const commaIdx = dataUrl.indexOf(","); const commaIdx = dataUrl.indexOf(',')
const b64 = const b64 = commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl
commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl; const buffer = Buffer.from(b64, 'base64')
const buffer = Buffer.from(b64, "base64");
const bucket = admin.storage().bucket(); const bucket = admin.storage().bucket()
const token = require("crypto").randomUUID(); const token = require('crypto').randomUUID()
const file = bucket.file(path); const file = bucket.file(path)
await file.save(buffer, { await file.save(buffer, {
resumable: false, resumable: false,
metadata: { metadata: {
contentType: media.contentType || "image/png", contentType: media.contentType || 'image/png',
metadata: { firebaseStorageDownloadTokens: token }, 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) { } catch (err) {
console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message); console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message)
lastError = err; lastError = err
if (attempt < maxAttempts) await setTimeout(2000 * attempt); if (attempt < maxAttempts) await setTimeout(2000 * attempt)
} }
} }
throw new Error( throw new Error(`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`)
`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`,
);
} finally { } finally {
console.log( console.log(`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`)
`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`,
);
} }
}; }
+47 -69
View File
@@ -1,137 +1,115 @@
exports.generatePicturePrompt = (project = {}) => { exports.generatePicturePrompt = (project = {}) => {
const { const {
title = "", title = '',
lyrics: lyricsRaw, lyrics: lyricsRaw,
musicConfig = {}, musicConfig = {},
coverStyle: coverStyleRaw = "", coverStyle: coverStyleRaw = '',
artistName: artistNameRaw = "", artistName: artistNameRaw = '',
} = project || {}; } = project || {}
// --- 1. Nettoyage et Normalisation --- // --- 1. Nettoyage et Normalisation ---
const sanitizeInline = (value = "") => { const sanitizeInline = (value = '') => {
if (typeof value !== "string") return ""; if (typeof value !== 'string') return ''
return value return value
.replace(/[\r\n]+/g, " ") .replace(/[\r\n]+/g, ' ')
.replace(/[<>]/g, "") .replace(/[<>]/g, '')
.trim(); .trim()
}; }
const titleForPrompt = sanitizeInline(title) || "Sans titre"; const titleForPrompt = sanitizeInline(title) || 'Sans titre'
const artistName = const artistName = sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || '')
sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || ""); const hasArtistName = artistName.length > 0
const hasArtistName = artistName.length > 0;
const { const { genres = [], tempo = '', mood = '', instruments = [] } = musicConfig || {}
genres = [], const userStyle = sanitizeInline(coverStyleRaw)
tempo = "",
mood = "",
instruments = [],
} = musicConfig || {};
const userStyle = sanitizeInline(coverStyleRaw);
// --- 2. Intelligence Visuelle (Mapping) --- // --- 2. Intelligence Visuelle (Mapping) ---
// Détermination de l'énergie visuelle // Détermination de l'énergie visuelle
const isEnergetic = const isEnergetic =
tempo && tempo && /\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(String(tempo))
/\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(
String(tempo),
);
const isDark = const isDark =
mood && mood && /\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(String(mood))
/\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(
String(mood),
);
// Construction de la Palette & Lumière // Construction de la Palette & Lumière
let visualAtmosphere = ""; let visualAtmosphere = ''
if (isDark) { if (isDark) {
visualAtmosphere = 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) { } else if (isEnergetic) {
visualAtmosphere = 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 { } else {
visualAtmosphere = 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) // Définition du Style de Rendu (Si l'utilisateur est vague, on renforce)
let renderingStyle = userStyle let renderingStyle = userStyle ? `Art Style: ${userStyle}` : 'Art Style: Digital Art, Mixed Media'
? `Art Style: ${userStyle}`
: "Art Style: Digital Art, Mixed Media";
if ( if (userStyle.toLowerCase().includes('realist') || userStyle.toLowerCase().includes('photo')) {
userStyle.toLowerCase().includes("realist") ||
userStyle.toLowerCase().includes("photo")
) {
renderingStyle += 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 ( } else if (
userStyle.toLowerCase().includes("illu") || userStyle.toLowerCase().includes('illu') ||
userStyle.toLowerCase().includes("dessin") userStyle.toLowerCase().includes('dessin')
) { ) {
renderingStyle += renderingStyle +=
", vector art, clean lines, professional illustration, flat design or detailed painting."; ', vector art, clean lines, professional illustration, flat design or detailed painting.'
} else { } else {
// Style par défaut "Album Cover" qui marche bien // Style par défaut "Album Cover" qui marche bien
renderingStyle += renderingStyle += ', abstract surrealism, conceptual album art, high fidelity, masterpiece.'
", abstract surrealism, conceptual album art, high fidelity, masterpiece.";
} }
// --- 3. Extraction de l'Inspiration (Lyrics) --- // --- 3. Extraction de l'Inspiration (Lyrics) ---
// On cherche le REFRAIN en priorité pour l'image, car c'est le cœur visuel // On cherche le REFRAIN en priorité pour l'image, car c'est le cœur visuel
const sections = Array.isArray(lyricsRaw) ? lyricsRaw : []; const sections = Array.isArray(lyricsRaw) ? lyricsRaw : []
const chorus = sections.find( const chorus = sections.find((s) => s.type === 'refrain' || s.type === 'chorus')
(s) => s.type === "refrain" || s.type === "chorus", const verse = sections.find((s) => s.type === 'couplet' || s.type === 'verse')
);
const verse = sections.find(
(s) => s.type === "couplet" || s.type === "verse",
);
// On prend 2 lignes max du refrain, ou du premier couplet // On prend 2 lignes max du refrain, ou du premier couplet
const visualHook = (chorus?.lyrics || verse?.lyrics || "") const visualHook = (chorus?.lyrics || verse?.lyrics || '')
.split("\n") .split('\n')
.filter((l) => l.length > 10) // On évite les lignes trop courtes .filter((l) => l.length > 10) // On évite les lignes trop courtes
.slice(0, 2) .slice(0, 2)
.join(". "); .join('. ')
const imageryPrompt = visualHook const imageryPrompt = visualHook
? `Visual Inspiration: An interpretation of these lyrics: "${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) --- // --- 4. Construction du Prompt Final (Structure Optimisée Imagen 3) ---
const promptParts = [ const promptParts = [
// Rôle // 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) // 1. Le Texte (Crucial pour Imagen 3 - Doit être au début ou très clair)
`**Typography & Text:**`, `**Typography & Text:**`,
`The song title "${titleForPrompt}" must be the CENTERPIECE. Write it in a distinct font that matches the mood.`, `The song title "${titleForPrompt}" must be the CENTERPIECE. Write it in a distinct font that matches the mood.`,
hasArtistName hasArtistName
? `The artist name "${artistName}" must appear smaller, elegant, and legible near the bottom or top.` ? `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 // 2. Le Visuel
`**Visuals:**`, `**Visuals:**`,
renderingStyle, renderingStyle,
imageryPrompt, 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) // 3. L'Atmosphère (Context from music config)
`**Mood & Color:**`, `**Mood & Color:**`,
visualAtmosphere, 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) // 4. Contraintes Négatives (Phrasées positivement pour l'IA)
"**Constraints:**", '**Constraints:**',
"Use a square 1:1 aspect ratio.", '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.", '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.", '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) => 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 buildMonthKey = (timestamp) => {
const date = normalizeDate(timestamp); const date = normalizeDate(timestamp)
const year = date.getUTCFullYear(); const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, "0"); const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${year}-${month}`; return `${year}-${month}`
}; }
const buildPreviousMonthContext = (referenceDate) => { const buildPreviousMonthContext = (referenceDate) => {
const current = referenceDate ? new Date(referenceDate) : new Date(); const current = referenceDate ? new Date(referenceDate) : new Date()
current.setUTCHours(0, 0, 0, 0); current.setUTCHours(0, 0, 0, 0)
current.setUTCDate(1); current.setUTCDate(1)
const target = new Date(current); const target = new Date(current)
target.setUTCMonth(target.getUTCMonth() - 1); target.setUTCMonth(target.getUTCMonth() - 1)
const year = target.getUTCFullYear(); const year = target.getUTCFullYear()
const monthIndex = target.getUTCMonth(); const monthIndex = target.getUTCMonth()
const rangeStart = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0)); 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 rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999))
const monthKey = buildMonthKey(rangeStart); const monthKey = buildMonthKey(rangeStart)
return { return {
year, year,
@@ -28,10 +28,10 @@ const buildPreviousMonthContext = (referenceDate) => {
monthKey, monthKey,
rangeStart, rangeStart,
rangeEnd, rangeEnd,
}; }
}; }
module.exports = { module.exports = {
buildMonthKey, buildMonthKey,
buildPreviousMonthContext, buildPreviousMonthContext,
}; }
+195 -236
View File
@@ -1,281 +1,263 @@
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const { HttpsError } = require("firebase-functions/https"); const { HttpsError } = require('firebase-functions/https')
const Stripe = require("stripe"); const Stripe = require('stripe')
const { URL } = require("url"); const { URL } = require('url')
const { const {
STRIPE_SECRET_KEY = "", STRIPE_SECRET_KEY = '',
STRIPE_RETURN_URL = "", STRIPE_RETURN_URL = '',
STRIPE_PORTAL_CONFIGURATION = "", STRIPE_PORTAL_CONFIGURATION = '',
STRIPE_MODE: CONFIG_STRIPE_MODE, STRIPE_MODE: CONFIG_STRIPE_MODE,
} = require("../config/keys"); } = require('../config/keys')
const STRIPE_MODE = 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() ? CONFIG_STRIPE_MODE.trim()
: "test"; : 'test'
const requireEnv = (key) => { const requireEnv = (key) => {
const value = process.env?.[key]; const value = process.env?.[key]
if (typeof value === "string" && value.trim()) { if (typeof value === 'string' && value.trim()) {
return 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 STRIPE_API_VERSION = '2023-10-16'
const DEFAULT_TEST_RETURN_URL = "http://localhost:8081"; const DEFAULT_TEST_RETURN_URL = 'http://localhost:8081'
const ALLOWED_RETURN_SCHEMES = ["http", "https", "minuit"]; const ALLOWED_RETURN_SCHEMES = ['http', 'https', 'minuit']
let cachedStripeClient = null; let cachedStripeClient = null
let cachedPortalConfigurationId = null; let cachedPortalConfigurationId = null
const resolveStripeSecretKey = () => { const resolveStripeSecretKey = () => {
const inlineKey = const inlineKey = typeof STRIPE_SECRET_KEY === 'string' ? STRIPE_SECRET_KEY.trim() : ''
typeof STRIPE_SECRET_KEY === "string" ? STRIPE_SECRET_KEY.trim() : "";
if (inlineKey) { if (inlineKey) {
return inlineKey; return inlineKey
} }
const required = requireEnv("STRIPE_SECRET_KEY"); const required = requireEnv('STRIPE_SECRET_KEY')
if (typeof required === "string" && required.trim()) { if (typeof required === 'string' && required.trim()) {
return required.trim(); return required.trim()
} }
throw new Error("STRIPE_SECRET_KEY not configured"); throw new Error('STRIPE_SECRET_KEY not configured')
}; }
const getStripeClient = () => { const getStripeClient = () => {
if (cachedStripeClient) { if (cachedStripeClient) {
return cachedStripeClient; return cachedStripeClient
} }
let secretKey; let secretKey
try { try {
secretKey = resolveStripeSecretKey(); secretKey = resolveStripeSecretKey()
} catch (error) { } catch (error) {
console.error("[getStripeClient] Missing STRIPE_SECRET_KEY", error); console.error('[getStripeClient] Missing STRIPE_SECRET_KEY', error)
throw new HttpsError( throw new HttpsError(
"failed-precondition", 'failed-precondition',
"Stripe nest pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.", 'Stripe nest pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.'
); )
} }
cachedStripeClient = new Stripe(secretKey, { cachedStripeClient = new Stripe(secretKey, {
apiVersion: STRIPE_API_VERSION, apiVersion: STRIPE_API_VERSION,
}); })
return cachedStripeClient; return cachedStripeClient
}; }
const getReturnBaseUrl = () => { const getReturnBaseUrl = () => {
const resolveBase = () => { const resolveBase = () => {
if (STRIPE_RETURN_URL) { if (STRIPE_RETURN_URL) {
return STRIPE_RETURN_URL; return STRIPE_RETURN_URL
} }
if (STRIPE_MODE !== "prod") { if (STRIPE_MODE !== 'prod') {
return DEFAULT_TEST_RETURN_URL; return DEFAULT_TEST_RETURN_URL
} }
return requireEnv("STRIPE_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 sanitizedBase.endsWith("/") const rawBase = resolveBase()
? sanitizedBase.slice(0, -1) const sanitizedBase = typeof rawBase === 'string' ? rawBase.trim() : ''
: sanitizedBase; 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) => { const sanitizeReturnUrl = (value) => {
if (typeof value !== "string") { if (typeof value !== 'string') {
return null; return null
} }
const trimmed = value.trim(); const trimmed = value.trim()
if (!trimmed) { if (!trimmed) {
return null; return null
} }
const validateScheme = (scheme) => { const validateScheme = (scheme) => {
if (!ALLOWED_RETURN_SCHEMES.includes(scheme)) { if (!ALLOWED_RETURN_SCHEMES.includes(scheme)) {
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'invalid-argument',
`Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`, `Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`
); )
} }
}; }
try { try {
const parsedUrl = new URL(trimmed); const parsedUrl = new URL(trimmed)
const scheme = parsedUrl.protocol.replace(":", "").toLowerCase(); const scheme = parsedUrl.protocol.replace(':', '').toLowerCase()
validateScheme(scheme); validateScheme(scheme)
return trimmed; return trimmed
} catch (_error) { } 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]) { if (schemeMatch && schemeMatch[1]) {
const scheme = schemeMatch[1].toLowerCase(); const scheme = schemeMatch[1].toLowerCase()
validateScheme(scheme); validateScheme(scheme)
return trimmed; return trimmed
} }
throw new HttpsError( throw new HttpsError('invalid-argument', `URL de retour Stripe invalide: ${trimmed}`)
"invalid-argument",
`URL de retour Stripe invalide: ${trimmed}`,
);
} }
}; }
const getReturnUrls = (overrides) => { const getReturnUrls = (overrides) => {
if (overrides && typeof overrides === "object") { if (overrides && typeof overrides === 'object') {
const successOverride = sanitizeReturnUrl(overrides.successUrl); const successOverride = sanitizeReturnUrl(overrides.successUrl)
const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl); const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl)
if (!successOverride) { if (!successOverride) {
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'invalid-argument',
"successUrl est requis pour configurer les retours Stripe.", 'successUrl est requis pour configurer les retours Stripe.'
); )
} }
return { return {
successUrl: successOverride, successUrl: successOverride,
cancelUrl: cancelOverride || successOverride, cancelUrl: cancelOverride || successOverride,
}; }
} }
const baseUrl = getReturnBaseUrl(); const baseUrl = getReturnBaseUrl()
const joinPath = (url, path) => { const joinPath = (url, path) => {
const trimmedUrl = url.endsWith("/") ? url.slice(0, -1) : url; const trimmedUrl = url.endsWith('/') ? url.slice(0, -1) : url
const trimmedPath = path.startsWith("/") ? path.slice(1) : path; const trimmedPath = path.startsWith('/') ? path.slice(1) : path
return `${trimmedUrl}/${trimmedPath}`; return `${trimmedUrl}/${trimmedPath}`
}; }
const appendQuery = (url, query) => const appendQuery = (url, query) => (url.includes('?') ? `${url}&${query}` : `${url}?${query}`)
url.includes("?") ? `${url}&${query}` : `${url}?${query}`;
const successBase = joinPath(baseUrl, "payment-success"); const successBase = joinPath(baseUrl, 'payment-success')
const cancelBase = joinPath(baseUrl, "payment-error"); const cancelBase = joinPath(baseUrl, 'payment-error')
return { return {
successUrl: appendQuery(successBase, "session_id={CHECKOUT_SESSION_ID}"), successUrl: appendQuery(successBase, 'session_id={CHECKOUT_SESSION_ID}'),
cancelUrl: appendQuery(cancelBase, "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 } = {}) => { const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
if (!Array.isArray(productList) || productList.length === 0) { if (!Array.isArray(productList) || productList.length === 0) {
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'invalid-argument',
"Au moins un produit est requis pour créer une session de paiement.", 'Au moins un produit est requis pour créer une session de paiement.'
); )
} }
const lineItems = []; const lineItems = []
const summary = []; const summary = []
let hasSubscription = false; let hasSubscription = false
for (let index = 0; index < productList.length; index += 1) { for (let index = 0; index < productList.length; index += 1) {
const rawItem = productList[index]; const rawItem = productList[index]
const item = rawItem && typeof rawItem === "object" ? rawItem : {}; const item = rawItem && typeof rawItem === 'object' ? rawItem : {}
const isRenewable = normalizeBoolean(item.isRenewable); const isRenewable = normalizeBoolean(item.isRenewable)
const rawQuantity = const rawQuantity =
typeof item.quantity === "number" && Number.isFinite(item.quantity) typeof item.quantity === 'number' && Number.isFinite(item.quantity)
? item.quantity ? item.quantity
: parseInt(item.quantity, 10); : parseInt(item.quantity, 10)
const quantity = const quantity = Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1
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) { if (isRenewable && !priceId) {
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'invalid-argument',
`Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`, `Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`
); )
} }
if (priceId) { if (priceId) {
let stripePrice = null; let stripePrice = null
if (stripe) { if (stripe) {
try { try {
stripePrice = await stripe.prices.retrieve(priceId); stripePrice = await stripe.prices.retrieve(priceId)
} catch (error) { } catch (error) {
console.error( console.error('[buildCheckoutLineItems] Unable to retrieve price', priceId, error)
"[buildCheckoutLineItems] Unable to retrieve price",
priceId,
error,
);
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'invalid-argument',
`Le price ID "${priceId}" est introuvable (élément ${index + 1}).`, `Le price ID "${priceId}" est introuvable (élément ${index + 1}).`
); )
} }
} }
const priceIsRecurring = const priceIsRecurring = stripePrice?.type === 'recurring' || !!stripePrice?.recurring
stripePrice?.type === "recurring" || !!stripePrice?.recurring;
if (isRenewable && !priceIsRecurring) { if (isRenewable && !priceIsRecurring) {
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'invalid-argument',
`Le price ID "${priceId}" n'est pas compatible avec un abonnement.`, `Le price ID "${priceId}" n'est pas compatible avec un abonnement.`
); )
} }
const resolvedIsRenewable = priceIsRecurring ? true : isRenewable; const resolvedIsRenewable = priceIsRecurring ? true : isRenewable
if (resolvedIsRenewable) { if (resolvedIsRenewable) {
hasSubscription = true; hasSubscription = true
} }
lineItems.push({ lineItems.push({
price: priceId, price: priceId,
quantity, quantity,
}); })
summary.push({ summary.push({
type: "price", type: 'price',
priceID: priceId, priceID: priceId,
quantity, quantity,
isRenewable: resolvedIsRenewable, isRenewable: resolvedIsRenewable,
}); })
continue; continue
} }
const rawUnitAmount = Number(item.unitAmount); const rawUnitAmount = Number(item.unitAmount)
const unitAmount = Math.round(rawUnitAmount); const unitAmount = Math.round(rawUnitAmount)
if (!Number.isFinite(unitAmount) || unitAmount <= 0) { if (!Number.isFinite(unitAmount) || unitAmount <= 0) {
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'invalid-argument',
`Le montant indiqué pour l'élément ${index + 1} est invalide.`, `Le montant indiqué pour l'élément ${index + 1} est invalide.`
); )
} }
const currency = const currency = typeof item.currency === 'string' ? item.currency.trim().toLowerCase() : 'eur'
typeof item.currency === "string"
? item.currency.trim().toLowerCase()
: "eur";
if (!/^[a-z]{3}$/.test(currency)) { if (!/^[a-z]{3}$/.test(currency)) {
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'invalid-argument',
`La devise indiquée pour l'élément ${index + 1} est invalide.`, `La devise indiquée pour l'élément ${index + 1} est invalide.`
); )
} }
const label = const label =
typeof item.label === "string" && item.label.trim() typeof item.label === 'string' && item.label.trim() ? item.label.trim() : 'Paiement ponctuel'
? item.label.trim()
: "Paiement ponctuel";
lineItems.push({ lineItems.push({
price_data: { price_data: {
@@ -286,95 +268,84 @@ const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
unit_amount: unitAmount, unit_amount: unitAmount,
}, },
quantity, quantity,
}); })
summary.push({ summary.push({
type: "custom", type: 'custom',
currency, currency,
unitAmount, unitAmount,
quantity, quantity,
isRenewable: false, isRenewable: false,
}); })
} }
if (hasSubscription) { if (hasSubscription) {
const hasNonSubscription = summary.some((item) => !item.isRenewable); const hasNonSubscription = summary.some((item) => !item.isRenewable)
if (hasNonSubscription) { if (hasNonSubscription) {
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'invalid-argument',
"Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.", 'Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.'
); )
} }
} }
return { lineItems, summary, hasSubscription }; return { lineItems, summary, hasSubscription }
}; }
const ensureStripeCustomer = async ({ const ensureStripeCustomer = async ({ uid, stripe, refsList, createIfMissing = true }) => {
uid,
stripe,
refsList,
createIfMissing = true,
}) => {
if (!uid) { if (!uid) {
return { customerId: null, userData: null }; return { customerId: null, userData: null }
} }
const userRef = refsList?.users?.doc(uid); const userRef = refsList?.users?.doc(uid)
const snapshot = userRef ? await userRef.get() : null; const snapshot = userRef ? await userRef.get() : null
const userData = snapshot?.exists ? snapshot.data() : null; const userData = snapshot?.exists ? snapshot.data() : null
let customerId = userData?.stripeCustomerId; let customerId = userData?.stripeCustomerId
if (customerId) { if (customerId) {
return { customerId, userData }; return { customerId, userData }
} }
if (!createIfMissing) { if (!createIfMissing) {
return { customerId: null, userData }; return { customerId: null, userData }
} }
let authRecord = null; let authRecord = null
try { try {
authRecord = await admin.auth().getUser(uid); authRecord = await admin.auth().getUser(uid)
} catch (error) { } catch (error) {
console.warn( console.warn('[ensureStripeCustomer] Impossible de récupérer auth user', error)
"[ensureStripeCustomer] Impossible de récupérer auth user",
error,
);
} }
const email = userData?.email || authRecord?.email || undefined; const email = userData?.email || authRecord?.email || undefined
const nameFromProfile = [userData?.firstName, userData?.lastName] const nameFromProfile = [userData?.firstName, userData?.lastName].filter(Boolean).join(' ').trim()
.filter(Boolean) const name = nameFromProfile || authRecord?.displayName || undefined
.join(" ")
.trim();
const name = nameFromProfile || authRecord?.displayName || undefined;
const customer = await stripe.customers.create({ const customer = await stripe.customers.create({
email, email,
name, name,
metadata: { metadata: {
firebaseUID: uid, firebaseUID: uid,
appMode: STRIPE_MODE || "test", appMode: STRIPE_MODE || 'test',
}, },
}); })
customerId = customer.id; customerId = customer.id
if (userRef) { if (userRef) {
await userRef.set( await userRef.set(
{ {
stripeCustomerId: customerId, stripeCustomerId: customerId,
}, },
{ merge: true }, { merge: true }
); )
} }
return { return {
customerId, customerId,
userData: { ...userData, stripeCustomerId: customerId }, userData: { ...userData, stripeCustomerId: customerId },
}; }
}; }
const formatCheckoutSessionResponse = (session) => ({ const formatCheckoutSessionResponse = (session) => ({
id: session.id, id: session.id,
@@ -391,84 +362,72 @@ const formatCheckoutSessionResponse = (session) => ({
created: session.created, created: session.created,
expires_at: session.expires_at, expires_at: session.expires_at,
client_secret: session.client_secret || null, client_secret: session.client_secret || null,
}); })
const getPortalConfigurationId = async (stripe) => { const getPortalConfigurationId = async (stripe) => {
if (STRIPE_PORTAL_CONFIGURATION) { if (STRIPE_PORTAL_CONFIGURATION) {
return STRIPE_PORTAL_CONFIGURATION; return STRIPE_PORTAL_CONFIGURATION
} }
if (cachedPortalConfigurationId) { if (cachedPortalConfigurationId) {
return cachedPortalConfigurationId; return cachedPortalConfigurationId
} }
if ( if (!stripe || typeof stripe.billingPortal?.configurations?.list !== 'function') {
!stripe || throw new HttpsError('internal', 'Client Stripe indisponible pour la configuration du portail.')
typeof stripe.billingPortal?.configurations?.list !== "function"
) {
throw new HttpsError(
"internal",
"Client Stripe indisponible pour la configuration du portail.",
);
} }
try { try {
const configurations = await stripe.billingPortal.configurations.list({ const configurations = await stripe.billingPortal.configurations.list({
limit: 100, limit: 100,
}); })
const defaultConfiguration = const defaultConfiguration =
configurations.data.find((config) => config.is_default) || configurations.data.find((config) => config.is_default) ||
configurations.data.find((config) => config.active); configurations.data.find((config) => config.active)
if (defaultConfiguration?.id) { if (defaultConfiguration?.id) {
cachedPortalConfigurationId = defaultConfiguration.id; cachedPortalConfigurationId = defaultConfiguration.id
return cachedPortalConfigurationId; return cachedPortalConfigurationId
} }
} catch (error) { } catch (error) {
console.warn( console.warn(
"[getPortalConfigurationId] Impossible de lister les configurations de portail", '[getPortalConfigurationId] Impossible de lister les configurations de portail',
error?.message || error, error?.message || error
); )
} }
try { try {
const defaultReturnUrl = getReturnBaseUrl(); const defaultReturnUrl = getReturnBaseUrl()
const createdConfiguration = const createdConfiguration = await stripe.billingPortal.configurations.create({
await stripe.billingPortal.configurations.create({ default_return_url: defaultReturnUrl,
default_return_url: defaultReturnUrl, business_profile: {
business_profile: { headline: 'Minuit Starter',
headline: "Minuit Starter", },
}, })
});
if (createdConfiguration?.id) { if (createdConfiguration?.id) {
cachedPortalConfigurationId = createdConfiguration.id; cachedPortalConfigurationId = createdConfiguration.id
return cachedPortalConfigurationId; return cachedPortalConfigurationId
} }
} catch (error) { } catch (error) {
console.warn( console.warn(
"[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut", '[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut',
error?.message || error, error?.message || error
); )
} }
return null; return null
}; }
const mapStripeErrorToHttps = (error, fallbackMessage) => { const mapStripeErrorToHttps = (error, fallbackMessage) => {
const message = const message = error?.raw?.message || error?.message || fallbackMessage || 'Erreur Stripe.'
error?.raw?.message || const statusCode = error?.statusCode || error?.raw?.statusCode
error?.message || const isClientError = typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500
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"; const code = isClientError ? 'failed-precondition' : 'internal'
return new HttpsError(code, message); return new HttpsError(code, message)
}; }
module.exports = { module.exports = {
getStripeClient, getStripeClient,
@@ -479,4 +438,4 @@ module.exports = {
formatCheckoutSessionResponse, formatCheckoutSessionResponse,
getPortalConfigurationId, getPortalConfigurationId,
mapStripeErrorToHttps, 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. // Use default credentials/environment provided by Cloud Functions.
// Avoid bundling a service account key and hardcoding project/bucket. // Avoid bundling a service account key and hardcoding project/bucket.
admin.initializeApp(); admin.initializeApp()
const db = admin.firestore(); const db = admin.firestore()
const REGION = process.env.FIREBASE_REGION || "europe-west1"; const REGION = process.env.FIREBASE_REGION || 'europe-west1'
exports.db = db; exports.db = db
exports.REGION = REGION; exports.REGION = REGION
exports.refList = { exports.refList = {
projects: db.collection("projects"), projects: db.collection('projects'),
playlists: db.collection("playlists"), playlists: db.collection('playlists'),
tasks: db.collection("tasks"), tasks: db.collection('tasks'),
notifications: db.collection("notifications"), notifications: db.collection('notifications'),
users: db.collection("users"), users: db.collection('users'),
projectStreamStats: db.collection("projectStreamStats"), projectStreamStats: db.collection('projectStreamStats'),
projectStreamStatsMonthlyTotals: db.collection( projectStreamStatsMonthlyTotals: db.collection('projectStreamStatsMonthlyTotals'),
"projectStreamStatsMonthlyTotals", monthlyPayoutEntries: db.collection('monthlyPayoutEntries'),
), monthlyPayouts: db.collection('monthlyPayouts'),
monthlyPayoutEntries: db.collection("monthlyPayoutEntries"), }
monthlyPayouts: db.collection("monthlyPayouts"), exports.refsList = exports.refList
};
exports.refsList = exports.refList;
exports.ALERT_TYPE = { exports.ALERT_TYPE = {
NEW_LIKE: "NEW_LIKE", NEW_LIKE: 'NEW_LIKE',
NEW_COMMENT: "NEW_COMMENT", NEW_COMMENT: 'NEW_COMMENT',
NEW_FOLLOWER: "NEW_FOLLOWER", NEW_FOLLOWER: 'NEW_FOLLOWER',
MUSIC_GENERATION_SUCCESS: "MUSIC_GENERATION_SUCCESS", MUSIC_GENERATION_SUCCESS: 'MUSIC_GENERATION_SUCCESS',
MUSIC_GENERATION_FAILED: "MUSIC_GENERATION_FAILED", MUSIC_GENERATION_FAILED: 'MUSIC_GENERATION_FAILED',
COVER_GENERATION_SUCCESS: "COVER_GENERATION_SUCCESS", COVER_GENERATION_SUCCESS: 'COVER_GENERATION_SUCCESS',
COVER_GENERATION_FAILED: "COVER_GENERATION_FAILED", COVER_GENERATION_FAILED: 'COVER_GENERATION_FAILED',
CREDITS_UPDATED: "CREDITS_UPDATED", CREDITS_UPDATED: 'CREDITS_UPDATED',
PAYOUT_AVAILABLE: "PAYOUT_AVAILABLE", PAYOUT_AVAILABLE: 'PAYOUT_AVAILABLE',
}; }
// Exporter toutes les fonctions // Exporter toutes les fonctions
exports.users = require("./src/users"); exports.users = require('./src/users')
exports.music = require("./src/music"); exports.music = require('./src/music')
exports.lyrics = require("./src/lyrics"); exports.lyrics = require('./src/lyrics')
exports.cover = require("./src/cover"); exports.cover = require('./src/cover')
exports.projects = require("./src/project"); exports.projects = require('./src/project')
exports.thumbnail = require("./src/thumbnail"); exports.thumbnail = require('./src/thumbnail')
exports.upload = require("./src/upload"); exports.upload = require('./src/upload')
exports.algolia = require("./src/algolia"); exports.algolia = require('./src/algolia')
exports.notifications = require("./src/notifications"); exports.notifications = require('./src/notifications')
exports.rankings = require("./src/rankings"); exports.rankings = require('./src/rankings')
exports.payouts = require("./src/payouts"); exports.payouts = require('./src/payouts')
exports.subscription = require("./src/subscription"); exports.subscription = require('./src/subscription')
exports.youtube = require("./src/youtube"); exports.youtube = require('./src/youtube')
exports.orders = require("./src/orders"); 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( exports.algoliaTransformProjectData = onRequest({ region: 'europe-west1' }, (req, res) => {
{ region: "europe-west1" }, const payload = req.body.data
(req, res) => { const objectID = payload.objectID
const payload = req.body.data; try {
const objectID = payload.objectID; const flat = { ...payload }
try { delete flat['musicTimestamps']
const flat = { ...payload };
delete flat["musicTimestamps"];
console.log(`Change in ${payload.objectID}`); console.log(`Change in ${payload.objectID}`)
console.log(flat); console.log(flat)
// Ton object final doit contenir "objectID" // Ton object final doit contenir "objectID"
const result = { const result = {
objectID, objectID,
...flat, ...flat,
};
res.send({ result });
} catch (e) {
console.log(`Error ${objectID}:`, e.message);
res.status(500).end();
} }
},
); 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 { onDocumentCreated } = require('firebase-functions/v2/firestore')
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const { FieldValue } = require("firebase-admin/firestore"); const { FieldValue } = require('firebase-admin/firestore')
const logger = require("firebase-functions/logger"); const logger = require('firebase-functions/logger')
const path = require("path"); const path = require('path')
const fs = require("fs"); const fs = require('fs')
const axios = require("axios"); const axios = require('axios')
const sharp = require("sharp"); const sharp = require('sharp')
const crypto = require("crypto"); const crypto = require('crypto')
// Imports internes // Imports internes
const { generateImageV2 } = require("../helpers/gemini"); const { generateImageV2 } = require('../helpers/gemini')
const { generatePicturePrompt } = require("../helpers/prompts"); const { generatePicturePrompt } = require('../helpers/prompts')
const { ALERT_TYPE, refList } = require("../index"); const { ALERT_TYPE, refList } = require('../index')
const { sendNotification } = require("./notifications"); const { sendNotification } = require('./notifications')
// Configuration // Configuration
const bucket = admin.storage().bucket(); const bucket = admin.storage().bucket()
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png"); 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) // 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 * Récupère le buffer du logo depuis le cache ou le disque
*/ */
const getLogoBuffer = async () => { const getLogoBuffer = async () => {
if (_cachedLogoBuffer) return _cachedLogoBuffer; if (_cachedLogoBuffer) return _cachedLogoBuffer
try { try {
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH); _cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH)
return _cachedLogoBuffer; return _cachedLogoBuffer
} catch (error) { } catch (error) {
logger.error("❌ [Cover] Impossible de lire le fichier logo", error); logger.error('❌ [Cover] Impossible de lire le fichier logo', error)
throw new Error("Asset Logo manquant sur le serveur"); throw new Error('Asset Logo manquant sur le serveur')
} }
}; }
/** /**
* Utilitaires Strings * Utilitaires Strings
*/ */
const pickFirstNonEmpty = (...values) => { const pickFirstNonEmpty = (...values) => {
for (const value of values) { for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) { if (typeof value === 'string' && value.trim().length > 0) {
return value.trim(); return value.trim()
} }
} }
return ""; return ''
}; }
const combineNames = (...parts) => const combineNames = (...parts) =>
parts parts
.map((part) => (typeof part === "string" ? part.trim() : "")) .map((part) => (typeof part === 'string' ? part.trim() : ''))
.filter(Boolean) .filter(Boolean)
.join(" ") .join(' ')
.trim(); .trim()
/** /**
* Résolution intelligente du nom d'artiste * Résolution intelligente du nom d'artiste
*/ */
async function resolveArtistName(project = {}) { async function resolveArtistName(project = {}) {
// 1. Vérification directe sur le projet ou le snapshot "owner" // 1. Vérification directe sur le projet ou le snapshot "owner"
const owner = project?.owner || {}; const owner = project?.owner || {}
const direct = pickFirstNonEmpty( const direct = pickFirstNonEmpty(
project?.artistName, project?.artistName,
project?.userName, project?.userName,
owner?.artistName, owner?.artistName,
owner?.userName, owner?.userName,
owner?.displayName, owner?.displayName
); )
if (direct) return direct; if (direct) return direct
// 2. Fallback : Récupération depuis la collection Users // 2. Fallback : Récupération depuis la collection Users
const userId = const userId = typeof project?.userId === 'string' ? project.userId.trim() : ''
typeof project?.userId === "string" ? project.userId.trim() : ""; if (!userId) return ''
if (!userId) return "";
try { try {
const userSnapshot = await refList.users.doc(userId).get(); const userSnapshot = await refList.users.doc(userId).get()
if (!userSnapshot?.exists) return ""; if (!userSnapshot?.exists) return ''
const userData = userSnapshot.data() || {}; const userData = userSnapshot.data() || {}
return ( return (
pickFirstNonEmpty( pickFirstNonEmpty(
userData.artistName, userData.artistName,
userData.userName, userData.userName,
userData.displayName, userData.displayName,
combineNames(userData.firstName, userData.lastName), combineNames(userData.firstName, userData.lastName)
) || "" ) || ''
); )
} catch (error) { } catch (error) {
logger.warn("⚠️ [Cover] Artist name resolution failed", { logger.warn('⚠️ [Cover] Artist name resolution failed', {
projectId: project?.id, projectId: project?.id,
error: error.message, 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 * Ajoute le logo en filigrane sur l'image générée
*/ */
async function buildCoverWithLogo(backgroundUrl, targetPath) { async function buildCoverWithLogo(backgroundUrl, targetPath) {
logger.info("🖼️ [Cover] Compositing logo..."); logger.info('🖼️ [Cover] Compositing logo...')
try { try {
// Téléchargement background + Lecture Logo (parallèle) // Téléchargement background + Lecture Logo (parallèle)
const [bgResponse, logoBuffer] = await Promise.all([ const [bgResponse, logoBuffer] = await Promise.all([
axios.get(backgroundUrl, { responseType: "arraybuffer" }), axios.get(backgroundUrl, { responseType: 'arraybuffer' }),
getLogoBuffer(), getLogoBuffer(),
]); ])
const baseImage = sharp(bgResponse.data); const baseImage = sharp(bgResponse.data)
const metadata = await baseImage.metadata(); const metadata = await baseImage.metadata()
const width = metadata.width || 1024; const width = metadata.width || 1024
const height = metadata.height || 1024; const height = metadata.height || 1024
// Calcul dynamique de la taille du logo (32% de la largeur) // Calcul dynamique de la taille du logo (32% de la largeur)
const desiredWidth = Math.round(width * 0.32); const desiredWidth = Math.round(width * 0.32)
const margin = Math.round(width * 0.04); const margin = Math.round(width * 0.04)
// Redimensionnement du logo // Redimensionnement du logo
const resizedLogo = await sharp(logoBuffer) const resizedLogo = await sharp(logoBuffer).resize({ width: desiredWidth }).png().toBuffer()
.resize({ width: desiredWidth })
.png()
.toBuffer();
// Positionnement (Bas Droite) // Positionnement (Bas Droite)
const logoMetadata = await sharp(resizedLogo).metadata(); const logoMetadata = await sharp(resizedLogo).metadata()
const left = Math.max(width - logoMetadata.width - margin, 0); const left = Math.max(width - logoMetadata.width - margin, 0)
const top = Math.max(height - logoMetadata.height - margin, 0); const top = Math.max(height - logoMetadata.height - margin, 0)
// Composition // Composition
const stampedBuffer = await baseImage const stampedBuffer = await baseImage
.ensureAlpha() .ensureAlpha()
.composite([{ input: resizedLogo, left, top, blend: "over" }]) .composite([{ input: resizedLogo, left, top, blend: 'over' }])
.png() .png()
.toBuffer(); .toBuffer()
// Upload vers Storage // Upload vers Storage
const token = crypto.randomUUID(); const token = crypto.randomUUID()
const file = bucket.file(targetPath); const file = bucket.file(targetPath)
await file.save(stampedBuffer, { await file.save(stampedBuffer, {
resumable: false, resumable: false,
metadata: { metadata: {
contentType: "image/png", contentType: 'image/png',
cacheControl: "public, max-age=31536000", cacheControl: 'public, max-age=31536000',
metadata: { firebaseStorageDownloadTokens: token }, 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) { } 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 // 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 * Cœur de la logique de génération
*/ */
async function performCoverGeneration(project) { async function performCoverGeneration(project) {
const t0 = Date.now(); const t0 = Date.now()
const artistName = await resolveArtistName(project); const artistName = await resolveArtistName(project)
// Génération du Prompt optimisé // Génération du Prompt optimisé
const prompt = generatePicturePrompt({ const prompt = generatePicturePrompt({
...project, ...project,
artistName, artistName,
}); })
logger.info("🎨 [Cover] Prompt generated", { logger.info('🎨 [Cover] Prompt generated', {
projectId: project.id, projectId: project.id,
artistName, artistName,
promptPreview: prompt.slice(0, 100) + "...", promptPreview: prompt.slice(0, 100) + '...',
}); })
const baseTimestamp = Date.now(); const baseTimestamp = Date.now()
const GENERATION_COUNT = 2; // Nombre de variantes simultanées 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 // Création d'un tableau de promesses pour exécuter les tâches en parallèle
const generationPromises = Array.from({ length: GENERATION_COUNT }).map( const generationPromises = Array.from({ length: GENERATION_COUNT }).map(async (_, index) => {
async (_, index) => { const uniqueSuffix = `${baseTimestamp}-${index}`
const uniqueSuffix = `${baseTimestamp}-${index}`; const storageBasePath = `users/${project.userId}/projects/${project.id}`
const storageBasePath = `users/${project.userId}/projects/${project.id}`; const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`; const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`;
try { try {
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres // 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath); 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 // 2. Ajout du Logo
const finalCoverUrl = await buildCoverWithLogo( const finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath)
generatedUrl,
stampedPath,
);
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`); logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`)
return { return {
id: uniqueSuffix, id: uniqueSuffix,
generatedUrl, generatedUrl,
finalUrl: finalCoverUrl, finalUrl: finalCoverUrl,
promptUsed: prompt, 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
} }
}, } 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 // 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) // On garde uniquement les tentatives réussies (non null)
const options = results.filter(Boolean); const options = results.filter(Boolean)
if (options.length === 0) { 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 // Sauvegarde dans Firestore
const [firstOption] = options; const [firstOption] = options
await refList.projects.doc(project.id).set( await refList.projects.doc(project.id).set(
{ {
@@ -238,19 +229,19 @@ async function performCoverGeneration(project) {
// selectedOptionId: firstOption.id, // disable default selection // selectedOptionId: firstOption.id, // disable default selection
options, // Sauvegarde de toutes les variantes réussies options, // Sauvegarde de toutes les variantes réussies
}, },
coverStatus: "GENERATED", coverStatus: 'GENERATED',
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
{ merge: true }, { merge: true }
); )
logger.info("🏁 [Cover] Process complete", { logger.info('🏁 [Cover] Process complete', {
projectId: project.id, projectId: project.id,
successCount: options.length, successCount: options.length,
duration: Date.now() - t0, duration: Date.now() - t0,
}); })
return firstOption.finalUrl; return firstOption.finalUrl
} }
/** /**
@@ -260,77 +251,72 @@ async function performCoverGeneration(project) {
exports.onTaskCreateGenerateCover = onDocumentCreated( exports.onTaskCreateGenerateCover = onDocumentCreated(
{ {
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent) timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
memory: "1GiB", memory: '1GiB',
document: "tasks/{taskId}", document: 'tasks/{taskId}',
}, },
async (event) => { async (event) => {
const data = event.data?.data() || {}; const data = event.data?.data() || {}
const { type, projectId } = data; const { type, projectId } = data
const taskId = event.params.taskId; const taskId = event.params.taskId
if (!projectId) return; // Ignorer les tâches mal formées if (!projectId) return // Ignorer les tâches mal formées
if (!["cover", "combine"].includes(type)) return; // Ignorer les autres types de tâches 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 { try {
// 1. Validation & Setup // 1. Validation & Setup
if (type === "combine") { if (type === 'combine') {
// Feature désactivée pour le moment // Feature désactivée pour le moment
await event.data.ref.update({ await event.data.ref.update({
status: "CANCELLED", status: 'CANCELLED',
error: "La personnalisation photo n'est plus disponible.", error: "La personnalisation photo n'est plus disponible.",
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}); })
return; return
} }
// Mise à jour statut projet // Mise à jour statut projet
await refList.projects.doc(projectId).update({ await refList.projects.doc(projectId).update({
coverStatus: "GENERATING", coverStatus: 'GENERATING',
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}); })
// 2. Chargement Projet // 2. Chargement Projet
const projectSnap = await refList.projects.doc(projectId).get(); const projectSnap = await refList.projects.doc(projectId).get()
if (!projectSnap.exists) throw new Error("Projet introuvable"); 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) // Idempotency check (si déjà généré, on ne refait pas)
if ( if (Array.isArray(project?.cover?.options) && project.cover.options.length > 0) {
Array.isArray(project?.cover?.options) && logger.warn('⚠️ [Task] Cover already exists. Skipping.')
project.cover.options.length > 0 await refList.projects.doc(projectId).update({ coverStatus: 'GENERATED' })
) {
logger.warn("⚠️ [Task] Cover already exists. Skipping.");
await refList.projects
.doc(projectId)
.update({ coverStatus: "GENERATED" });
await event.data.ref.update({ await event.data.ref.update({
status: "DONE", status: 'DONE',
info: "Already generated", info: 'Already generated',
}); })
return; return
} }
// 3. Exécution Génération // 3. Exécution Génération
const coverUrl = await performCoverGeneration(project); const coverUrl = await performCoverGeneration(project)
// 4. Finalisation Tâche // 4. Finalisation Tâche
await event.data.ref.update({ await event.data.ref.update({
status: "DONE", status: 'DONE',
coverUrl, coverUrl,
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}); })
// 5. Notification // 5. Notification
if (project.userId) { if (project.userId) {
const projectTitle = project.title || "ton projet"; const projectTitle = project.title || 'ton projet'
await sendNotification({ await sendNotification({
sender: "SYSTEM", sender: 'SYSTEM',
receiver: project.userId, receiver: project.userId,
receiverCollection: "users", receiverCollection: 'users',
title: "Pochette prête !", title: 'Pochette prête !',
message: `La pochette pour "${projectTitle}" a été générée avec succès.`, message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
data: { data: {
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS, type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
@@ -338,39 +324,36 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
projectTitle, projectTitle,
coverUrl, coverUrl,
}, },
}).catch((err) => logger.warn("Notification failed", err)); }).catch((err) => logger.warn('Notification failed', err))
} }
} catch (error) { } catch (error) {
logger.error(`🔥 [Task ${taskId}] Failed`, error); logger.error(`🔥 [Task ${taskId}] Failed`, error)
// Mise à jour erreur Tâche // Mise à jour erreur Tâche
await event.data.ref.set( await event.data.ref.set({ status: 'ERROR', error: error.message }, { merge: true })
{ status: "ERROR", error: error.message },
{ merge: true },
);
// Mise à jour erreur Projet // Mise à jour erreur Projet
await refList.projects.doc(projectId).update({ await refList.projects.doc(projectId).update({
coverStatus: "ERROR", coverStatus: 'ERROR',
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}); })
// Notification Erreur // Notification Erreur
const projectData = (await refList.projects.doc(projectId).get()).data(); const projectData = (await refList.projects.doc(projectId).get()).data()
if (projectData?.userId) { if (projectData?.userId) {
await sendNotification({ await sendNotification({
sender: "SYSTEM", sender: 'SYSTEM',
receiver: projectData.userId, receiver: projectData.userId,
receiverCollection: "users", receiverCollection: 'users',
title: "Échec pochette", title: 'Échec pochette',
message: `Impossible de générer la pochette pour "${projectData.title || "ton projet"}".`, message: `Impossible de générer la pochette pour "${projectData.title || 'ton projet'}".`,
data: { data: {
type: ALERT_TYPE?.COVER_GENERATION_FAILED, type: ALERT_TYPE?.COVER_GENERATION_FAILED,
projectId, projectId,
error: error.message, error: error.message,
}, },
}).catch(() => { }); }).catch(() => {})
} }
} }
}, }
); )
+38 -38
View File
@@ -1,60 +1,60 @@
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const { FieldValue } = require("firebase-admin/firestore"); const { FieldValue } = require('firebase-admin/firestore')
const ORDER_TYPES = { const ORDER_TYPES = {
GIFT: "GIFT", GIFT: 'GIFT',
SONG: "SONG", SONG: 'SONG',
COINS: "COINS", COINS: 'COINS',
SUBSCRIPTION: "SUBSCRIPTION", SUBSCRIPTION: 'SUBSCRIPTION',
}; }
const ORDER_STATUS = { const ORDER_STATUS = {
PENDING: "PENDING", PENDING: 'PENDING',
APPLIED: "APPLIED", APPLIED: 'APPLIED',
REJECTED: "REJECTED", REJECTED: 'REJECTED',
}; }
const ORDERS_COLLECTION = "orders"; const ORDERS_COLLECTION = 'orders'
const isFiniteNumber = (value) => { const isFiniteNumber = (value) => {
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
return true; return true
} }
if (typeof value === "string") { if (typeof value === 'string') {
const parsed = Number(value); const parsed = Number(value)
return Number.isFinite(parsed); return Number.isFinite(parsed)
} }
return false; return false
}; }
const normalizeAmount = (amount) => { const normalizeAmount = (amount) => {
if (!isFiniteNumber(amount)) { if (!isFiniteNumber(amount)) {
return null; return null
} }
return Number(amount); return Number(amount)
}; }
const createOrderDocument = async ({ const createOrderDocument = async ({
userId, userId,
type, type,
amount, amount,
songId = null, songId = null,
createdBy = "system", createdBy = 'system',
metadata = {}, metadata = {},
orderId = null, orderId = null,
}) => { }) => {
if (!userId || typeof userId !== "string") { if (!userId || typeof userId !== 'string') {
throw new Error("[orders] Missing userId when creating order"); throw new Error('[orders] Missing userId when creating order')
} }
if (!Object.values(ORDER_TYPES).includes(type)) { 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) { if (normalizedAmount === null || normalizedAmount === 0) {
throw new Error("[orders] Invalid order amount"); throw new Error('[orders] Invalid order amount')
} }
const payload = { const payload = {
@@ -63,25 +63,25 @@ const createOrderDocument = async ({
amount: normalizedAmount, amount: normalizedAmount,
songId: type === ORDER_TYPES.SONG ? songId || null : null, songId: type === ORDER_TYPES.SONG ? songId || null : null,
createdAt: FieldValue.serverTimestamp(), createdAt: FieldValue.serverTimestamp(),
createdBy: createdBy || "system", createdBy: createdBy || 'system',
status: ORDER_STATUS.PENDING, status: ORDER_STATUS.PENDING,
metadata: metadata || {}, metadata: metadata || {},
}; }
const collectionRef = admin.firestore().collection(ORDERS_COLLECTION); const collectionRef = admin.firestore().collection(ORDERS_COLLECTION)
const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc(); const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc()
if (orderId) { if (orderId) {
const existingSnapshot = await orderRef.get(); const existingSnapshot = await orderRef.get()
if (existingSnapshot.exists) { 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 = { module.exports = {
ORDER_TYPES, ORDER_TYPES,
@@ -89,4 +89,4 @@ module.exports = {
ORDERS_COLLECTION, ORDERS_COLLECTION,
createOrderDocument, createOrderDocument,
normalizeAmount, normalizeAmount,
}; }
+191 -220
View File
@@ -1,93 +1,86 @@
const { onCall, HttpsError } = require("firebase-functions/v2/https"); const { onCall, HttpsError } = require('firebase-functions/v2/https')
const { z } = require("genkit"); const { z } = require('genkit')
const { generateAI, analyseLyrics } = require("../helpers/gemini"); const { generateAI, analyseLyrics } = require('../helpers/gemini')
const { const { SUNO_API_BASE, SUNO_TIMESTAMPED_LYRICS_PATH } = require('../config/suno')
SUNO_API_BASE, const { SUNO_API_KEY } = require('../config/keys')
SUNO_TIMESTAMPED_LYRICS_PATH, const axios = require('axios')
} = require("../config/suno"); const { FieldValue } = require('firebase-admin/firestore')
const { SUNO_API_KEY } = require("../config/keys"); const { refList } = require('../index')
const axios = require("axios");
const { FieldValue } = require("firebase-admin/firestore");
const { refList } = require("../index");
// --- CONSTANTES DE STRUCTURE --- // --- CONSTANTES DE STRUCTURE ---
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur // On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
const STRUCTURE_PROMPT_LABELS = { const STRUCTURE_PROMPT_LABELS = {
couplet: "couplet", couplet: 'couplet',
refrain: "refrain", refrain: 'refrain',
short_intro: "introduction instrumentale courte", short_intro: 'introduction instrumentale courte',
long_intro: "introduction instrumentale longue", long_intro: 'introduction instrumentale longue',
pre_refrain_instrumental: "pré-refrain instrumental", pre_refrain_instrumental: 'pré-refrain instrumental',
pont: "pont", pont: 'pont',
solo_de_guitare: "solo de guitare", solo_de_guitare: 'solo de guitare',
solo_de_guitare_electrique: "solo de guitare électrique", solo_de_guitare_electrique: 'solo de guitare électrique',
solo_de_batterie: "solo de batterie", solo_de_batterie: 'solo de batterie',
solo_de_saxophone: "solo de saxophone", solo_de_saxophone: 'solo de saxophone',
solo_de_violon: "solo de violon", solo_de_violon: 'solo de violon',
break: "break", break: 'break',
interlude: "interlude", interlude: 'interlude',
interlude_melodique: "interlude mélodique", interlude_melodique: 'interlude mélodique',
final_apogee: "final apogée", final_apogee: 'final apogée',
arret_net: "arrêt net", arret_net: 'arrêt net',
fade_out: "fade out", fade_out: 'fade out',
transition_douce: "transition douce vers le silence", transition_douce: 'transition douce vers le silence',
}; }
const STRUCTURE_ALIASES = { const STRUCTURE_ALIASES = {
"short intro": "short_intro", 'short intro': 'short_intro',
"introduction instrumentale courte": "short_intro", 'introduction instrumentale courte': 'short_intro',
"intro instrumentale courte": "short_intro", 'intro instrumentale courte': 'short_intro',
"long intro": "long_intro", 'long intro': 'long_intro',
"introduction instrumentale longue": "long_intro", 'introduction instrumentale longue': 'long_intro',
"intro instrumentale longue": "long_intro", 'intro instrumentale longue': 'long_intro',
"pré-refrain": "pre_refrain_instrumental", 'pré-refrain': 'pre_refrain_instrumental',
"pre-refrain": "pre_refrain_instrumental", 'pre-refrain': 'pre_refrain_instrumental',
pre_refrain: "pre_refrain_instrumental", pre_refrain: 'pre_refrain_instrumental',
"pre chorus": "pre_refrain_instrumental", 'pre chorus': 'pre_refrain_instrumental',
"pre-chorus": "pre_refrain_instrumental", 'pre-chorus': 'pre_refrain_instrumental',
prechorus: "pre_refrain_instrumental", prechorus: 'pre_refrain_instrumental',
"pré-refrain instrumental": "pre_refrain_instrumental", 'pré-refrain instrumental': 'pre_refrain_instrumental',
"pre-refrain instrumental": "pre_refrain_instrumental", 'pre-refrain instrumental': 'pre_refrain_instrumental',
"instrumental pre-chorus": "pre_refrain_instrumental", 'instrumental pre-chorus': 'pre_refrain_instrumental',
"instrumental pre chorus": "pre_refrain_instrumental", 'instrumental pre chorus': 'pre_refrain_instrumental',
bridge: "pont", bridge: 'pont',
guitar_solo: "solo_de_guitare", guitar_solo: 'solo_de_guitare',
electric_guitar_solo: "solo_de_guitare_electrique", electric_guitar_solo: 'solo_de_guitare_electrique',
drum_solo: "solo_de_batterie", drum_solo: 'solo_de_batterie',
sax_solo: "solo_de_saxophone", sax_solo: 'solo_de_saxophone',
violin_solo: "solo_de_violon", violin_solo: 'solo_de_violon',
melodic_interlude: "interlude_melodique", melodic_interlude: 'interlude_melodique',
grand_finale: "final_apogee", grand_finale: 'final_apogee',
sudden_stop: "arret_net", sudden_stop: 'arret_net',
soft_transition: "transition_douce", soft_transition: 'transition_douce',
}; }
// --- UTILITAIRES --- // --- UTILITAIRES ---
const normalizeStructureValue = (value) => { const normalizeStructureValue = (value) => {
const raw = String(value || "") const raw = String(value || '')
.trim() .trim()
.toLowerCase(); .toLowerCase()
if (!raw) return ""; if (!raw) return ''
if (STRUCTURE_PROMPT_LABELS[raw]) return raw; if (STRUCTURE_PROMPT_LABELS[raw]) return raw
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw]; if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw]
const sanitized = raw.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); const sanitized = raw.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
return STRUCTURE_PROMPT_LABELS[sanitized] return STRUCTURE_PROMPT_LABELS[sanitized] ? sanitized : STRUCTURE_ALIASES[sanitized] || sanitized
? sanitized }
: STRUCTURE_ALIASES[sanitized] || sanitized;
};
// Nettoyage simplifié : on laisse l'IA gérer la logique musicale plutôt que de supprimer brutalement. // 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. // On s'assure juste que les clés sont propres.
const sanitizeStructureEntries = (structure = []) => { const sanitizeStructureEntries = (structure = []) => {
if (!Array.isArray(structure)) return []; if (!Array.isArray(structure)) return []
return structure.map(normalizeStructureValue).filter(Boolean); return structure.map(normalizeStructureValue).filter(Boolean)
}; }
const mapStructureToPrompt = (structure = []) => const mapStructureToPrompt = (structure = []) =>
sanitizeStructureEntries(structure).map( sanitizeStructureEntries(structure).map((entry) => STRUCTURE_PROMPT_LABELS[entry] || entry)
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
);
// --- MODERATION --- // --- MODERATION ---
@@ -101,40 +94,37 @@ const buildModerationBrief = ({
rhymes, rhymes,
}) => { }) => {
const sections = [ const sections = [
`<OBJECTIF>${objective || ""}</OBJECTIF>`, `<OBJECTIF>${objective || ''}</OBJECTIF>`,
`<CONTEXTE>${context || ""}</CONTEXTE>`, `<CONTEXTE>${context || ''}</CONTEXTE>`,
`<EMOTION>${emotion || ""}</EMOTION>`, `<EMOTION>${emotion || ''}</EMOTION>`,
`<STYLE>${style || ""}</STYLE>`, `<STYLE>${style || ''}</STYLE>`,
`<AUDIENCE>${audience || ""}</AUDIENCE>`, `<AUDIENCE>${audience || ''}</AUDIENCE>`,
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`, `<RIMES>${Array.isArray(rhymes) ? rhymes.join(', ') : rhymes || ''}</RIMES>`,
]; ]
const promptStructure = mapStructureToPrompt(structure); const promptStructure = mapStructureToPrompt(structure)
if (promptStructure.length) if (promptStructure.length) sections.push(`<STRUCTURE>${promptStructure.join(' | ')}</STRUCTURE>`)
sections.push(`<STRUCTURE>${promptStructure.join(" | ")}</STRUCTURE>`); return `<BRIEF_UTILISATEUR>\n${sections.join('\n')}\n</BRIEF_UTILISATEUR>`
return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`; }
};
// --- GENERATION DE PAROLES (MAIN) --- // --- GENERATION DE PAROLES (MAIN) ---
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => { exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
try { try {
const { const {
objective = "", objective = '',
context = "", context = '',
style = "", style = '',
audience = "", audience = '',
emotion = "", emotion = '',
structure: rawStructure = ["couplet", "refrain", "couplet", "refrain"], structure: rawStructure = ['couplet', 'refrain', 'couplet', 'refrain'],
rhymes = "", rhymes = '',
} = data; } = data
// 1. Préparation Structure // 1. Préparation Structure
const sanitizedStructure = sanitizeStructureEntries(rawStructure); const sanitizedStructure = sanitizeStructureEntries(rawStructure)
const promptStructure = sanitizedStructure.length const promptStructure = sanitizedStructure.length
? sanitizedStructure.map( ? sanitizedStructure.map((entry) => STRUCTURE_PROMPT_LABELS[entry] || entry)
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry, : []
)
: [];
// 2. Modération "Pro" (Via Gemini 1.5 Pro) // 2. Modération "Pro" (Via Gemini 1.5 Pro)
// On supprime les regex manuelles obsolètes. // On supprime les regex manuelles obsolètes.
@@ -146,40 +136,34 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
emotion, emotion,
structure: promptStructure, structure: promptStructure,
rhymes, rhymes,
}; }
const moderationInput = buildModerationBrief(moderationPayload); const moderationInput = buildModerationBrief(moderationPayload)
try { try {
const moderation = await analyseLyrics({ const moderation = await analyseLyrics({
title: "Brief utilisateur (Pré-génération)", title: 'Brief utilisateur (Pré-génération)',
lyrics: moderationInput, lyrics: moderationInput,
}); })
// Si Gemini dit "Blocked", on bloque. C'est la seule autorité. // Si Gemini dit "Blocked", on bloque. C'est la seule autorité.
if (moderation?.blocked === true) { if (moderation?.blocked === true) {
console.warn("⛔ generateLyrics blocked by Gemini Pro", { console.warn('⛔ generateLyrics blocked by Gemini Pro', {
reasons: moderation.reasons, reasons: moderation.reasons,
}); })
const summary = Array.isArray(moderation.reasons) const summary = Array.isArray(moderation.reasons)
? moderation.reasons.slice(0, 3).join(", ") ? moderation.reasons.slice(0, 3).join(', ')
: "Contenu non conforme"; : 'Contenu non conforme'
throw new HttpsError( throw new HttpsError('invalid-argument', `Demande refusée par la modération : ${summary}.`)
"invalid-argument",
`Demande refusée par la modération : ${summary}.`,
);
} }
} catch (moderationError) { } catch (moderationError) {
if (moderationError instanceof HttpsError) throw moderationError; if (moderationError instanceof HttpsError) throw moderationError
console.error("⚠️ Moderation check error (fail open)", 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. // 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. // Ici je fail closed par sécurité pour une app publique.
throw new HttpsError( throw new HttpsError('internal', 'Vérification de sécurité indisponible.')
"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" // 3. Le Prompt "Hit Maker"
const system = ` 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). 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. Retourne UNIQUEMENT un JSON valide.
`.trim(); `.trim()
const structureTags = ( const structureTags = (Array.isArray(promptStructure) ? promptStructure : [])
Array.isArray(promptStructure) ? promptStructure : []
)
.map((part, index) => ` <SECTION ordre="${index + 1}">${part}</SECTION>`) .map((part, index) => ` <SECTION ordre="${index + 1}">${part}</SECTION>`)
.join("\n"); .join('\n')
const fallbackStructureTags = [ const fallbackStructureTags = [
' <SECTION ordre="1">couplet</SECTION>', ' <SECTION ordre="1">couplet</SECTION>',
' <SECTION ordre="2">refrain</SECTION>', ' <SECTION ordre="2">refrain</SECTION>',
].join("\n"); ].join('\n')
const prompt = ` const prompt = `
<BRIEF_CREATIF> <BRIEF_CREATIF>
<OBJECTIF>${objective || "Créer une chanson mémorable"}</OBJECTIF> <OBJECTIF>${objective || 'Créer une chanson mémorable'}</OBJECTIF>
<CONTEXTE>${context || "Libre interprétation"}</CONTEXTE> <CONTEXTE>${context || 'Libre interprétation'}</CONTEXTE>
<EMOTION_DOMINANTE>${emotion || "Intense"}</EMOTION_DOMINANTE> <EMOTION_DOMINANTE>${emotion || 'Intense'}</EMOTION_DOMINANTE>
<STYLE_MUSICAL>${style || "Pop Moderne"}</STYLE_MUSICAL> <STYLE_MUSICAL>${style || 'Pop Moderne'}</STYLE_MUSICAL>
<CIBLE>${audience || "Tout public"}</CIBLE> <CIBLE>${audience || 'Tout public'}</CIBLE>
<TYPE_DE_RIMES>${rhymes || "Rimes croisées et riches"}</TYPE_DE_RIMES> <TYPE_DE_RIMES>${rhymes || 'Rimes croisées et riches'}</TYPE_DE_RIMES>
</BRIEF_CREATIF> </BRIEF_CREATIF>
<STRUCTURE_IMPOSEE> <STRUCTURE_IMPOSEE>
@@ -231,106 +213,97 @@ ${structureTags || fallbackStructureTags}
- Si c'est "Couplet/Refrain" : Écris 4 à 12 vers. - 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. 3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre.
</CONSIGNES_GENERATION> </CONSIGNES_GENERATION>
`.trim(); `.trim()
const lyricsSchema = z.object({ 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 lyrics: z
.array( .array(
z.object({ z.object({
type: z type: z.string().describe('Type de section (copier exactement la demande structure)'),
.string()
.describe(
"Type de section (copier exactement la demande structure)",
),
lyrics: z lyrics: z
.string() .string()
.describe( .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 lyricsDescription: z
.string() .string()
.describe( .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(), success: z.boolean(),
}); })
// Appel Gemini avec le nouveau modèle Pro configuré dans helpers/gemini // Appel Gemini avec le nouveau modèle Pro configuré dans helpers/gemini
return await generateAI({ return await generateAI({
system, system,
prompt, prompt,
schema: lyricsSchema, schema: lyricsSchema,
}); })
} catch (e) { } catch (e) {
console.error("❌ generateLyrics Error:", e); console.error('❌ generateLyrics Error:', e)
// Remontée d'erreur propre // Remontée d'erreur propre
if (e instanceof HttpsError) throw e; if (e instanceof HttpsError) throw e
throw new HttpsError( throw new HttpsError('internal', 'Erreur lors de la génération des paroles.')
"internal",
"Erreur lors de la génération des paroles.",
);
} }
}); })
// --- ANALYSE TOXICITÉ (EXISTANTE, NETTOYÉE) --- // --- ANALYSE TOXICITÉ (EXISTANTE, NETTOYÉE) ---
// Cette fonction reste utile pour l'analyse POST-création ou pour l'interface UI // Cette fonction reste utile pour l'analyse POST-création ou pour l'interface UI
const severityScore = (severity = "") => { const severityScore = (severity = '') => {
const normalized = String(severity || "").toLowerCase(); const normalized = String(severity || '').toLowerCase()
if (normalized === "critical") return 4; if (normalized === 'critical') return 4
if (normalized === "high") return 3; if (normalized === 'high') return 3
if (normalized === "medium") return 2; if (normalized === 'medium') return 2
if (normalized === "low") return 1; if (normalized === 'low') return 1
return 0; return 0
}; }
const softenModerationDecision = (rawResult = {}) => { const softenModerationDecision = (rawResult = {}) => {
const result = { ...rawResult }; const result = { ...rawResult }
const excerpts = Array.isArray(result.excerpts) ? result.excerpts : []; const excerpts = Array.isArray(result.excerpts) ? result.excerpts : []
const reasons = Array.isArray(result.reasons) ? result.reasons : []; const reasons = Array.isArray(result.reasons) ? result.reasons : []
const highestSeverity = excerpts.reduce( const highestSeverity = excerpts.reduce(
(max, excerpt) => Math.max(max, severityScore(excerpt?.severity)), (max, excerpt) => Math.max(max, severityScore(excerpt?.severity)),
0, 0
); )
const score = 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) ? Math.min(Math.max(result.score, 0), 1)
: 0; : 0
// Détection de contexte narratif pour être plus indulgent // Détection de contexte narratif pour être plus indulgent
const narrativeHint = reasons.some((reason) => const narrativeHint = reasons.some((reason) =>
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test( /narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(reason || '')
reason || "", )
),
);
const adjustments = []; const adjustments = []
// Logique d'adoucissement : Si c'est "High" severity mais narratif, on peut parfois débloquer (selon ta politique). // 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. // Ici on reste prudent sur le block, mais on adoucit le flag.
if (result.blocked) { if (result.blocked) {
// Débloque uniquement les erreurs manifestes (score bas mais blocked true par erreur) // Débloque uniquement les erreurs manifestes (score bas mais blocked true par erreur)
if (highestSeverity <= 1 && score < 0.65) { if (highestSeverity <= 1 && score < 0.65) {
result.blocked = false; result.blocked = false
adjustments.push("auto-unblock-low-severity"); adjustments.push('auto-unblock-low-severity')
} }
} }
if (!result.blocked && result.flagged) { if (!result.blocked && result.flagged) {
const lowSignal = highestSeverity <= 1 && score < 0.4; const lowSignal = highestSeverity <= 1 && score < 0.4
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55; const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55
if (lowSignal) { if (lowSignal) {
result.flagged = false; result.flagged = false
adjustments.push("drop-flag-low-signal"); adjustments.push('drop-flag-low-signal')
} else if (contextual) { } else if (contextual) {
result.flagged = false; result.flagged = false
adjustments.push("drop-flag-contextual"); adjustments.push('drop-flag-contextual')
} }
} }
@@ -338,8 +311,8 @@ const softenModerationDecision = (rawResult = {}) => {
...result, ...result,
moderationAdjustments: adjustments, moderationAdjustments: adjustments,
moderationCalibration: { highestSeverity, score }, moderationCalibration: { highestSeverity, score },
}; }
}; }
exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => { exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
try { try {
@@ -356,93 +329,91 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
z.object({ z.object({
type: z.string().optional(), type: z.string().optional(),
lyrics: 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({ const aiResult = await analyseLyrics({
title: parsed.title || "", title: parsed.title || '',
lyrics: parsed.lyrics, 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 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) .map(([k]) => k)
.slice(0, 5); .slice(0, 5)
let errorCode = "OK"; let errorCode = 'OK'
let message = "Analyse effectuée: aucun blocage."; let message = 'Analyse effectuée: aucun blocage.'
if (result.blocked) { if (result.blocked) {
errorCode = "TOXIC_CONTENT_BLOCKED"; errorCode = 'TOXIC_CONTENT_BLOCKED'
message = `Contenu bloqué par sécurité.`; message = `Contenu bloqué par sécurité.`
} else if (result.flagged) { } else if (result.flagged) {
errorCode = "TOXIC_CONTENT_FLAGGED"; errorCode = 'TOXIC_CONTENT_FLAGGED'
message = `Attention: contenu sensible détecté.`; message = `Attention: contenu sensible détecté.`
} }
return { success: !result.blocked, errorCode, message, result }; return { success: !result.blocked, errorCode, message, result }
} catch (err) { } catch (err) {
console.error("analyseLyricsToxicity failed", err?.message || err); console.error('analyseLyricsToxicity failed', err?.message || err)
return { return {
success: false, success: false,
errorCode: "ANALYSE_FAILED", errorCode: 'ANALYSE_FAILED',
message: "Erreur analyse toxicité.", message: 'Erreur analyse toxicité.',
}; }
} }
}); })
// --- SUNO TIMESTAMPS (EXISTANT) --- // --- SUNO TIMESTAMPS (EXISTANT) ---
async function getSunoTimestamps(projectId) { async function getSunoTimestamps(projectId) {
try { try {
if (!projectId || typeof projectId !== "string") if (!projectId || typeof projectId !== 'string') throw new Error('projectId invalide')
throw new Error("projectId invalide");
const docSnap = await refList.projects.doc(projectId).get(); const docSnap = await refList.projects.doc(projectId).get()
if (!docSnap.exists) throw new Error("Projet introuvable"); if (!docSnap.exists) throw new Error('Projet introuvable')
const { sunoTaskId, songIndex } = docSnap.data(); const { sunoTaskId, songIndex } = docSnap.data()
if (!sunoTaskId) throw new Error("TaskId manquant"); if (!sunoTaskId) throw new Error('TaskId manquant')
if (songIndex === undefined || songIndex < 0) if (songIndex === undefined || songIndex < 0) throw new Error('musicIndex invalide')
throw new Error("musicIndex invalide");
console.log("🔎 getSunoTimestamps", { sunoTaskId, songIndex }); console.log('🔎 getSunoTimestamps', { sunoTaskId, songIndex })
const response = await axios.post( const response = await axios.post(
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`, `${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
{ taskId: sunoTaskId, musicIndex: songIndex }, { taskId: sunoTaskId, musicIndex: songIndex },
{ {
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
Authorization: `Bearer ${SUNO_API_KEY}`, Authorization: `Bearer ${SUNO_API_KEY}`,
}, },
timeout: 30000, timeout: 30000,
}, }
); )
const dataToReturn = response.data?.data || response.data || {}; const dataToReturn = response.data?.data || response.data || {}
await refList.projects.doc(projectId).set( await refList.projects.doc(projectId).set(
{ {
musicTimestamps: { [songIndex]: dataToReturn }, musicTimestamps: { [songIndex]: dataToReturn },
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
{ merge: true }, { merge: true }
); )
return { success: true }; return { success: true }
} catch (error) { } catch (error) {
console.error("❌ getSunoTimestamps Error:", error.message); console.error('❌ getSunoTimestamps Error:', error.message)
return { return {
success: false, 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 { const { onDocumentCreated, onDocumentWritten } = require('firebase-functions/v2/firestore')
onDocumentCreated, const { FieldValue } = require('firebase-admin/firestore')
onDocumentWritten, const { refList, ALERT_TYPE } = require('../index')
} = require("firebase-functions/v2/firestore"); const { Expo } = require('expo-server-sdk')
const { FieldValue } = require("firebase-admin/firestore"); const { Resend } = require('resend')
const { refList, ALERT_TYPE } = require("../index"); const { basicTemplate } = require('../helpers/email')
const { Expo } = require("expo-server-sdk"); const { RESEND_API_KEY } = require('../config/keys')
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 // Initialisation de Expo SDK
let expo = new Expo(); let expo = new Expo()
const EMAIL_FROM = "MusicLand <musicland@musicland.ai>"; const EMAIL_FROM = 'MusicLand <musicland@musicland.ai>'
const DEFAULT_EMAIL_TITLE = "MusicLand"; const DEFAULT_EMAIL_TITLE = 'MusicLand'
function getCollectionRef(collectionName = "") { function getCollectionRef(collectionName = '') {
const ref = refList?.[collectionName]; const ref = refList?.[collectionName]
if (!ref) { if (!ref) {
throw new Error(`Unknown collection "${collectionName}"`); throw new Error(`Unknown collection "${collectionName}"`)
} }
return ref; return ref
} }
function cleanString(value) { function cleanString(value) {
return typeof value === "string" && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null
} }
function buildNotificationEmailPayload({ function buildNotificationEmailPayload({ title = '', message = '', template = {} } = {}) {
title = "", const fallbackTitle = cleanString(title) || DEFAULT_EMAIL_TITLE
message = "", const fallbackContent = cleanString(message) || ''
template = {}, const overrides = template && typeof template === 'object' ? 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 subject = cleanString(overrides.subject) || fallbackTitle
const emailTitle = cleanString(overrides.title) || fallbackTitle; const emailTitle = cleanString(overrides.title) || fallbackTitle
const content = cleanString(overrides.content) || fallbackContent; const content = cleanString(overrides.content) || fallbackContent
let button = null; let button = null
if (overrides.button && typeof overrides.button === "object") { if (overrides.button && typeof overrides.button === 'object') {
const buttonUrl = const buttonUrl = cleanString(overrides.button.url) || cleanString(overrides.button.href)
cleanString(overrides.button.url) || cleanString(overrides.button.href);
if (buttonUrl) { if (buttonUrl) {
button = { button = {
url: buttonUrl, url: buttonUrl,
label: label:
cleanString(overrides.button.label) || cleanString(overrides.button.label) || cleanString(overrides.button.text) || undefined,
cleanString(overrides.button.text) || }
undefined,
};
} }
} }
const templatePayload = { title: emailTitle, content }; const templatePayload = { title: emailTitle, content }
if (button) { if (button) {
templatePayload.button = button; templatePayload.button = button
} }
return { return {
subject, subject,
html: basicTemplate(templatePayload), html: basicTemplate(templatePayload),
}; }
} }
exports.sendNotificationWhenDocIsCreated = onDocumentCreated( exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
{ region: "europe-west1", document: "notifications/{notificationId}" }, { region: 'europe-west1', document: 'notifications/{notificationId}' },
async (event) => { async (event) => {
try { try {
const { const {
receiver = null, receiver = null,
title = "MusicLand", title = 'MusicLand',
receiverCollection = "users", receiverCollection = 'users',
message = "", message = '',
data: notifData = {}, data: notifData = {},
mailOnly = false, mailOnly = false,
} = event.data.data(); } = event.data.data()
if (!receiver || !message) { if (!receiver || !message) {
throw new Error("Receiver and message are required"); throw new Error('Receiver and message are required')
} }
const receiverSnap = await getCollectionRef(receiverCollection) const receiverSnap = await getCollectionRef(receiverCollection).doc(receiver).get()
.doc(receiver) const receiverData = receiverSnap.exists ? receiverSnap.data() : {}
.get();
const receiverData = receiverSnap.exists ? receiverSnap.data() : {};
const { const {
pushToken = null, pushToken = null,
pushTokens = [], pushTokens = [],
email: receiverEmail = "", email: receiverEmail = '',
emailNotifications = false, emailNotifications = false,
} = receiverData; } = receiverData
if (!mailOnly) { if (!mailOnly) {
try { try {
@@ -102,129 +90,124 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
[] []
.concat(Array.isArray(pushTokens) ? pushTokens : []) .concat(Array.isArray(pushTokens) ? pushTokens : [])
.concat(pushToken ? [pushToken] : []) .concat(pushToken ? [pushToken] : [])
.filter(Boolean), .filter(Boolean)
); )
const tokens = Array.from(tokensSet); const tokens = Array.from(tokensSet)
if (!tokens?.length) { if (!tokens?.length) {
await sendExpoNotification({ await sendExpoNotification({
tokens, tokens,
receiverId: receiver, receiverId: receiver,
receiverCollection, receiverCollection,
title: title || "MusicLand", title: title || 'MusicLand',
message: message, message: message,
data: notifData || {}, data: notifData || {},
}); })
} else { } else {
console.log("User push token not found"); console.log('User push token not found')
} }
} catch (e) { } catch (e) {
console.log("Error sending notif:", e); console.log('Error sending notif:', e)
} }
} }
if ((emailNotifications || mailOnly) && !!receiverEmail) { if ((emailNotifications || mailOnly) && !!receiverEmail) {
if (!resendInstance) { if (!resendInstance) {
console.warn( console.warn('Resend client not configured; unable to send notification email.')
"Resend client not configured; unable to send notification email.",
);
} else { } else {
try { try {
const { subject, html } = buildNotificationEmailPayload({ const { subject, html } = buildNotificationEmailPayload({
title, title,
message, message,
template: notifData?.email, template: notifData?.email,
}); })
await resendInstance.emails.send({ await resendInstance.emails.send({
from: EMAIL_FROM, from: EMAIL_FROM,
to: [receiverEmail], to: [receiverEmail],
subject, subject,
html, html,
}); })
} catch (e) { } catch (e) {
console.log("Error sending email:", e); console.log('Error sending email:', e)
} }
} }
} else { } else {
console.log( 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) { } catch (e) {
console.log(e); console.log(e)
return e; return e
} }
}, }
); )
// Fonction pour envoyer la notification via Expo SDK // Fonction pour envoyer la notification via Expo SDK
async function sendExpoNotification({ async function sendExpoNotification({
tokens = [], tokens = [],
title = "", title = '',
message = "", message = '',
data = {}, data = {},
receiverId = null, receiverId = null,
receiverCollection = "users", receiverCollection = 'users',
}) { }) {
try { try {
const candidateTokens = Array.isArray(tokens) ? tokens : [tokens]; const candidateTokens = Array.isArray(tokens) ? tokens : [tokens]
const validTokens = []; const validTokens = []
const invalidTokens = []; const invalidTokens = []
candidateTokens.forEach((token) => { candidateTokens.forEach((token) => {
if (Expo.isExpoPushToken(token)) { if (Expo.isExpoPushToken(token)) {
validTokens.push(token); validTokens.push(token)
} else if (token) { } else if (token) {
invalidTokens.push(token); invalidTokens.push(token)
} }
}); })
if (invalidTokens.length && receiverId) { if (invalidTokens.length && receiverId) {
await removeInvalidTokens({ await removeInvalidTokens({
tokens: invalidTokens, tokens: invalidTokens,
receiverId, receiverId,
receiverCollection, receiverCollection,
}); })
} }
if (!validTokens.length) { if (!validTokens.length) {
console.warn("No valid Expo push tokens to send notification"); console.warn('No valid Expo push tokens to send notification')
return { sent: false }; return { sent: false }
} }
const messages = validTokens.map((token) => ({ const messages = validTokens.map((token) => ({
to: token, to: token,
sound: "default", sound: 'default',
title: title, title: title,
body: message, body: message,
data: data || {}, data: data || {},
priority: "high", priority: 'high',
badge: 1, badge: 1,
channelId: "default", channelId: 'default',
})); }))
const chunks = expo.chunkPushNotifications(messages); const chunks = expo.chunkPushNotifications(messages)
const receipts = []; const receipts = []
const tokensToPrune = new Set(); const tokensToPrune = new Set()
for (const chunk of chunks) { for (const chunk of chunks) {
const chunkReceipts = await expo.sendPushNotificationsAsync(chunk); const chunkReceipts = await expo.sendPushNotificationsAsync(chunk)
chunkReceipts.forEach((receipt, index) => { chunkReceipts.forEach((receipt, index) => {
if (receipt?.status === "error") { if (receipt?.status === 'error') {
const errorCode = receipt?.details?.error || receipt?.details?.code; const errorCode = receipt?.details?.error || receipt?.details?.code
console.log("Error sending notification:", receipt); console.log('Error sending notification:', receipt)
if ( if (errorCode === 'DeviceNotRegistered' || errorCode === 'PushTokenNotRegistered') {
errorCode === "DeviceNotRegistered" || const token = chunk[index]?.to
errorCode === "PushTokenNotRegistered"
) {
const token = chunk[index]?.to;
if (token) { if (token) {
tokensToPrune.add(token); tokensToPrune.add(token)
} }
} }
} }
}); })
receipts.push(...chunkReceipts); receipts.push(...chunkReceipts)
} }
if (tokensToPrune.size && receiverId) { if (tokensToPrune.size && receiverId) {
@@ -232,68 +215,64 @@ async function sendExpoNotification({
tokens: Array.from(tokensToPrune), tokens: Array.from(tokensToPrune),
receiverId, receiverId,
receiverCollection, receiverCollection,
}); })
} }
console.log("Sent push notifications:", receipts); console.log('Sent push notifications:', receipts)
return { sent: true }; return { sent: true }
} catch (e) { } catch (e) {
console.log("Error sending notification:", e); console.log('Error sending notification:', e)
throw e; throw e
} }
} }
async function removeInvalidTokens({ async function removeInvalidTokens({ tokens = [], receiverId, receiverCollection }) {
tokens = [],
receiverId,
receiverCollection,
}) {
try { try {
if (!receiverId || !tokens.length) { 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) { if (!uniqueTokens.length) {
return; return
} }
const docRef = getCollectionRef(receiverCollection).doc(receiverId); const docRef = getCollectionRef(receiverCollection).doc(receiverId)
const userSnap = await docRef.get(); const userSnap = await docRef.get()
const userData = userSnap?.data() || {}; const userData = userSnap?.data() || {}
const updates = { const updates = {
pushTokens: FieldValue.arrayRemove(...uniqueTokens), 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( console.log(
"Pruned invalid push tokens", 'Pruned invalid push tokens',
JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2), JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2)
); )
} catch (error) { } 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 // Fonction pour ajouter une notification à la base de données
const sendNotification = async ({ const sendNotification = async ({
sender = "SYSTEM", sender = 'SYSTEM',
receiver = null, receiver = null,
receiverCollection = "users", receiverCollection = 'users',
title = "", title = '',
message = null, message = null,
mailOnly = false, mailOnly = false,
data = {}, data = {},
}) => { }) => {
try { try {
if (!receiver || !message) { if (!receiver || !message) {
throw new Error("Receiver and message are required"); throw new Error('Receiver and message are required')
} }
const payload = { const payload = {
sender, sender,
@@ -306,81 +285,78 @@ const sendNotification = async ({
readAt: null, readAt: null,
mailOnly, mailOnly,
data, data,
}; }
const { id } = await refList.notifications.add(payload); const { id } = await refList.notifications.add(payload)
console.log( console.log(
"[sendNotification] Notification created", '[sendNotification] Notification created',
JSON.stringify({ id, receiver, receiverCollection }, null, 2), JSON.stringify({ id, receiver, receiverCollection }, null, 2)
); )
return id; return id
} catch (e) { } 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( exports.createProjectCommentNotification = onDocumentCreated(
{ {
region: "europe-west1", region: 'europe-west1',
document: "projects/{projectId}/comments/{commentId}", document: 'projects/{projectId}/comments/{commentId}',
}, },
async (event) => { async (event) => {
try { try {
console.log( console.log(
"[createProjectCommentNotification] Trigger received", '[createProjectCommentNotification] Trigger received',
JSON.stringify(event.params || {}, null, 2), JSON.stringify(event.params || {}, null, 2)
); )
const { data: snap } = event; const { data: snap } = event
const { projectId, commentId } = event.params || {}; const { projectId, commentId } = event.params || {}
const comment = snap?.data(); const comment = snap?.data()
if (!projectId || !comment) { if (!projectId || !comment) {
console.log( console.log('[createProjectCommentNotification] Missing project/comment data', {
"[createProjectCommentNotification] Missing project/comment data", hasProjectId: !!projectId,
{ hasProjectId: !!projectId, hasComment: !!comment }, hasComment: !!comment,
); })
return null; return null
} }
console.log( console.log(
"[createProjectCommentNotification] Comment payload", '[createProjectCommentNotification] Comment payload',
JSON.stringify(comment, null, 2), JSON.stringify(comment, null, 2)
); )
const projectSnap = await refList.projects.doc(projectId).get(); const projectSnap = await refList.projects.doc(projectId).get()
if (!projectSnap.exists) { if (!projectSnap.exists) {
console.log( console.log('[createProjectCommentNotification] Project not found', projectId)
"[createProjectCommentNotification] Project not found", return null
projectId,
);
return null;
} }
const project = projectSnap.data() || {}; const project = projectSnap.data() || {}
const receiver = project.userId || null; const receiver = project.userId || null
if (!receiver || receiver === comment.userId) { if (!receiver || receiver === comment.userId) {
console.log( console.log(
"[createProjectCommentNotification] Invalid receiver", '[createProjectCommentNotification] Invalid receiver',
JSON.stringify({ receiver, commentUserId: comment.userId }), JSON.stringify({ receiver, commentUserId: comment.userId })
); )
return null; return null
} }
const commenterName = const commenterName =
typeof comment?.userName === "string" && comment.userName.trim() typeof comment?.userName === 'string' && comment.userName.trim()
? comment.userName.trim() ? comment.userName.trim()
: "Un utilisateur"; : 'Un utilisateur'
const projectTitle = const projectTitle =
typeof project.title === "string" && project.title.trim() typeof project.title === 'string' && project.title.trim()
? project.title.trim() ? project.title.trim()
: "ton projet"; : 'ton projet'
const message = `${commenterName} a commenté ton projet "${projectTitle}"`; const message = `${commenterName} a commenté ton projet "${projectTitle}"`
console.log( console.log(
"[createProjectCommentNotification] Creating notification", '[createProjectCommentNotification] Creating notification',
JSON.stringify( JSON.stringify(
{ {
receiver, receiver,
@@ -389,15 +365,15 @@ exports.createProjectCommentNotification = onDocumentCreated(
projectId, projectId,
}, },
null, null,
2, 2
), )
); )
await sendNotification({ await sendNotification({
sender: comment.userId || "SYSTEM", sender: comment.userId || 'SYSTEM',
receiver, receiver,
receiverCollection: "users", receiverCollection: 'users',
title: "Nouveau commentaire", title: 'Nouveau commentaire',
message, message,
data: { data: {
type: ALERT_TYPE?.NEW_COMMENT, type: ALERT_TYPE?.NEW_COMMENT,
@@ -405,109 +381,94 @@ exports.createProjectCommentNotification = onDocumentCreated(
commentId, commentId,
commenterId: comment.userId || null, commenterId: comment.userId || null,
commenterName: commenterName, commenterName: commenterName,
commenterProfilePicture: comment?.profilePicture || "", commenterProfilePicture: comment?.profilePicture || '',
text: text: typeof comment?.text === 'string' && comment.text.trim() ? comment.text.trim() : '',
typeof comment?.text === "string" && comment.text.trim()
? comment.text.trim()
: "",
}, },
}); })
console.log( console.log('[createProjectCommentNotification] Notification creation complete')
"[createProjectCommentNotification] Notification creation complete",
);
return null; return null
} catch (error) { } catch (error) {
console.log("createProjectCommentNotification error:", error); console.log('createProjectCommentNotification error:', error)
return error; return error
} }
}, }
); )
exports.createProjectLikeNotification = onDocumentWritten( exports.createProjectLikeNotification = onDocumentWritten(
{ {
region: "europe-west1", region: 'europe-west1',
document: "projects/{projectId}", document: 'projects/{projectId}',
}, },
async (event) => { async (event) => {
try { try {
const { projectId } = event.params || {}; const { projectId } = event.params || {}
const before = event?.data?.before?.data() || {}; const before = event?.data?.before?.data() || {}
const after = event?.data?.after?.data() || {}; const after = event?.data?.after?.data() || {}
if (!projectId || !after) { if (!projectId || !after) {
return null; return null
} }
const ownerId = after.userId || null; const ownerId = after.userId || null
if (!ownerId) { if (!ownerId) {
return null; return null
} }
const beforeSongLikes = Array.isArray(before?.likes?.song) const beforeSongLikes = Array.isArray(before?.likes?.song) ? before.likes.song : []
? before.likes.song const afterSongLikes = Array.isArray(after?.likes?.song) ? after.likes.song : []
: [];
const afterSongLikes = Array.isArray(after?.likes?.song)
? after.likes.song
: [];
const beforePlaybackLikes = Array.isArray(before?.likes?.playback) const beforePlaybackLikes = Array.isArray(before?.likes?.playback)
? before.likes.playback ? before.likes.playback
: []; : []
const afterPlaybackLikes = Array.isArray(after?.likes?.playback) const afterPlaybackLikes = Array.isArray(after?.likes?.playback) ? after.likes.playback : []
? after.likes.playback
: [];
const beforeSongSet = new Set(beforeSongLikes); const beforeSongSet = new Set(beforeSongLikes)
const beforePlaybackSet = new Set(beforePlaybackLikes); const beforePlaybackSet = new Set(beforePlaybackLikes)
const newSongLikers = afterSongLikes.filter( const newSongLikers = afterSongLikes.filter((uid) => uid && !beforeSongSet.has(uid))
(uid) => uid && !beforeSongSet.has(uid),
);
const newPlaybackLikers = afterPlaybackLikes.filter( const newPlaybackLikers = afterPlaybackLikes.filter(
(uid) => uid && !beforePlaybackSet.has(uid), (uid) => uid && !beforePlaybackSet.has(uid)
); )
const newLikers = []; const newLikers = []
newSongLikers.forEach((uid) => { newSongLikers.forEach((uid) => {
newLikers.push({ likerId: uid, likeType: "song" }); newLikers.push({ likerId: uid, likeType: 'song' })
}); })
newPlaybackLikers.forEach((uid) => { newPlaybackLikers.forEach((uid) => {
newLikers.push({ likerId: uid, likeType: "playback" }); newLikers.push({ likerId: uid, likeType: 'playback' })
}); })
if (!newLikers.length) { if (!newLikers.length) {
return null; return null
} }
const projectTitle = const projectTitle =
typeof after.title === "string" && after.title.trim() typeof after.title === 'string' && after.title.trim() ? after.title.trim() : 'ton projet'
? after.title.trim()
: "ton projet";
await Promise.all( await Promise.all(
newLikers.map(async ({ likerId, likeType }) => { newLikers.map(async ({ likerId, likeType }) => {
if (!likerId || likerId === ownerId) { if (!likerId || likerId === ownerId) {
return null; return null
} }
const likerSnap = await refList.users.doc(likerId).get(); const likerSnap = await refList.users.doc(likerId).get()
const liker = likerSnap?.data() || {}; const liker = likerSnap?.data() || {}
const likerName = const likerName =
typeof liker?.userName === "string" && liker.userName.trim() typeof liker?.userName === 'string' && liker.userName.trim()
? liker.userName.trim() ? liker.userName.trim()
: "Un utilisateur"; : 'Un utilisateur'
const isPlaybackLike = likeType === "playback"; const isPlaybackLike = likeType === 'playback'
const assetLabel = isPlaybackLike ? "ton playback" : "ta musique"; const assetLabel = isPlaybackLike ? 'ton playback' : 'ta musique'
const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"`; const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"`
await sendNotification({ await sendNotification({
sender: likerId, sender: likerId,
receiver: ownerId, receiver: ownerId,
receiverCollection: "users", receiverCollection: 'users',
title: "Nouveau like", title: 'Nouveau like',
message, message,
data: { data: {
type: ALERT_TYPE?.NEW_LIKE, type: ALERT_TYPE?.NEW_LIKE,
@@ -516,92 +477,86 @@ exports.createProjectLikeNotification = onDocumentWritten(
likerName, likerName,
likeType, likeType,
}, },
}); })
return null; return null
}), })
); )
return null; return null
} catch (error) { } catch (error) {
console.log("[createProjectLikeNotification] error:", error); console.log('[createProjectLikeNotification] error:', error)
return error; return error
} }
}, }
); )
exports.createNewFollowerNotification = onDocumentWritten( exports.createNewFollowerNotification = onDocumentWritten(
{ {
region: "europe-west1", region: 'europe-west1',
document: "users/{userId}", document: 'users/{userId}',
}, },
async (event) => { async (event) => {
try { try {
const { userId } = event.params || {}; const { userId } = event.params || {}
const before = event?.data?.before?.data() || {}; const before = event?.data?.before?.data() || {}
const after = event?.data?.after?.data() || {}; const after = event?.data?.after?.data() || {}
if (!userId || !after) { if (!userId || !after) {
return null; return null
} }
const beforeFollowers = Array.isArray(before?.followedBy) const beforeFollowers = Array.isArray(before?.followedBy) ? before.followedBy : []
? before.followedBy const afterFollowers = Array.isArray(after?.followedBy) ? after.followedBy : []
: [];
const afterFollowers = Array.isArray(after?.followedBy)
? after.followedBy
: [];
if (afterFollowers.length <= beforeFollowers.length) { if (afterFollowers.length <= beforeFollowers.length) {
return null; return null
} }
const previousSet = new Set(beforeFollowers); const previousSet = new Set(beforeFollowers)
const newFollowers = afterFollowers.filter( const newFollowers = afterFollowers.filter((uid) => !previousSet.has(uid))
(uid) => !previousSet.has(uid),
);
if (!newFollowers.length) { if (!newFollowers.length) {
return null; return null
} }
await Promise.all( await Promise.all(
newFollowers.map(async (followerId) => { newFollowers.map(async (followerId) => {
if (!followerId || followerId === userId) { if (!followerId || followerId === userId) {
return null; return null
} }
const followerSnap = await refList.users.doc(followerId).get(); const followerSnap = await refList.users.doc(followerId).get()
const follower = followerSnap?.data() || {}; const follower = followerSnap?.data() || {}
const followerName = const followerName =
typeof follower?.userName === "string" && follower.userName.trim() typeof follower?.userName === 'string' && follower.userName.trim()
? follower.userName.trim() ? follower.userName.trim()
: "Un utilisateur"; : 'Un utilisateur'
const message = `${followerName} te suit maintenant`; const message = `${followerName} te suit maintenant`
await sendNotification({ await sendNotification({
sender: followerId, sender: followerId,
receiver: userId, receiver: userId,
receiverCollection: "users", receiverCollection: 'users',
title: "Nouvel abonné", title: 'Nouvel abonné',
message, message,
data: { data: {
type: ALERT_TYPE?.NEW_FOLLOWER, type: ALERT_TYPE?.NEW_FOLLOWER,
followerId, followerId,
followerName, followerName,
followerProfilePicture: follower?.profilePicture || "", followerProfilePicture: follower?.profilePicture || '',
}, },
}); })
return null; return null
}), })
); )
return null; return null
} catch (error) { } catch (error) {
console.log("[createNewFollowerNotification] error:", error); console.log('[createNewFollowerNotification] error:', error)
return error; return error
} }
}, }
); )
+174 -205
View File
@@ -1,86 +1,81 @@
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const { FieldValue } = require("firebase-admin/firestore"); const { FieldValue } = require('firebase-admin/firestore')
const { onDocumentCreated } = require("firebase-functions/firestore"); const { onDocumentCreated } = require('firebase-functions/firestore')
const { HttpsError, onCall } = require("firebase-functions/https"); const { HttpsError, onCall } = require('firebase-functions/https')
const { REGION, ALERT_TYPE } = require("../index"); const { REGION, ALERT_TYPE } = require('../index')
const { sendNotification } = require("./notifications"); const { sendNotification } = require('./notifications')
const { const {
ORDER_TYPES, ORDER_TYPES,
ORDER_STATUS, ORDER_STATUS,
ORDERS_COLLECTION, ORDERS_COLLECTION,
createOrderDocument, createOrderDocument,
normalizeAmount, normalizeAmount,
} = require("./helpers/orders"); } = require('./helpers/orders')
const USERS_COLLECTION = "users"; const USERS_COLLECTION = 'users'
const formatCoinsText = (value) => { const formatCoinsText = (value) => {
if (typeof value !== "number" || Number.isNaN(value)) { if (typeof value !== 'number' || Number.isNaN(value)) {
return null; return null
} }
const absoluteValue = Math.abs(value); const absoluteValue = Math.abs(value)
if (!Number.isFinite(absoluteValue)) { if (!Number.isFinite(absoluteValue)) {
return null; return null
} }
const formatted = Number.isInteger(absoluteValue) const formatted = Number.isInteger(absoluteValue) ? `${absoluteValue}` : absoluteValue.toFixed(2)
? `${absoluteValue}` const suffix = absoluteValue === 1 ? 'crédit' : 'crédits'
: absoluteValue.toFixed(2);
const suffix = absoluteValue === 1 ? "crédit" : "crédits";
return `${formatted} ${suffix}`; return `${formatted} ${suffix}`
}; }
const buildOrderNotificationContent = ({ amount, orderType, balanceAfter }) => { const buildOrderNotificationContent = ({ amount, orderType, balanceAfter }) => {
if (typeof amount !== "number" || Number.isNaN(amount) || amount === 0) { if (typeof amount !== 'number' || Number.isNaN(amount) || amount === 0) {
return null; return null
} }
const coinsText = formatCoinsText(amount); const coinsText = formatCoinsText(amount)
if (!coinsText) { if (!coinsText) {
return null; return null
} }
const balanceText = formatCoinsText(balanceAfter); const balanceText = formatCoinsText(balanceAfter)
const balanceSentence = balanceText const balanceSentence = balanceText ? ` Ton solde est maintenant de ${balanceText}.` : ''
? ` Ton solde est maintenant de ${balanceText}.`
: "";
if (amount > 0) { if (amount > 0) {
if (orderType === ORDER_TYPES.COINS) { if (orderType === ORDER_TYPES.COINS) {
return { return {
title: "Crédits achetés", title: 'Crédits achetés',
message: `Ton achat de ${coinsText} est confirmé.${balanceSentence}`, message: `Ton achat de ${coinsText} est confirmé.${balanceSentence}`,
action: "PURCHASED", action: 'PURCHASED',
}; }
} }
if (orderType === ORDER_TYPES.GIFT) { if (orderType === ORDER_TYPES.GIFT) {
return { return {
title: "Crédits reçus", title: 'Crédits reçus',
message: `Tu as reçu ${coinsText}.${balanceSentence}`, message: `Tu as reçu ${coinsText}.${balanceSentence}`,
action: "EARNED", action: 'EARNED',
}; }
} }
return { return {
title: "Crédits ajoutés", title: 'Crédits ajoutés',
message: `Ton solde augmente de ${coinsText}.${balanceSentence}`, message: `Ton solde augmente de ${coinsText}.${balanceSentence}`,
action: "CREDITED", action: 'CREDITED',
}; }
} }
const reason = const reason = orderType === ORDER_TYPES.SONG ? ' pour générer un nouveau son' : ''
orderType === ORDER_TYPES.SONG ? " pour générer un nouveau son" : "";
return { return {
title: "Crédits dépensés", title: 'Crédits dépensés',
message: `Tu as dépensé ${coinsText}${reason}.${balanceSentence}`, message: `Tu as dépensé ${coinsText}${reason}.${balanceSentence}`,
action: "SPENT", action: 'SPENT',
}; }
}; }
const notifyOrderApplied = async ({ const notifyOrderApplied = async ({
userId, userId,
@@ -95,226 +90,200 @@ const notifyOrderApplied = async ({
amount, amount,
orderType, orderType,
balanceAfter, balanceAfter,
}); })
if (!content || !userId) { if (!content || !userId) {
return; return
} }
try { try {
await sendNotification({ await sendNotification({
sender: "SYSTEM", sender: 'SYSTEM',
receiver: userId, receiver: userId,
receiverCollection: USERS_COLLECTION, receiverCollection: USERS_COLLECTION,
title: content.title, title: content.title,
message: content.message, message: content.message,
data: { data: {
type: ALERT_TYPE?.CREDITS_UPDATED || "CREDITS_UPDATED", type: ALERT_TYPE?.CREDITS_UPDATED || 'CREDITS_UPDATED',
orderId, orderId,
orderType: orderType || null, orderType: orderType || null,
amount, amount,
balanceBefore, balanceBefore,
balanceAfter, balanceAfter,
action: content.action, action: content.action,
source: source: typeof metadata?.source === 'string' ? metadata.source : null,
typeof metadata?.source === "string" ? metadata.source : null,
metadata: metadata || {}, metadata: metadata || {},
}, },
}); })
} catch (error) { } catch (error) {
console.error( console.error('[orders-onOrderCreated] Failed to send notification', orderId, error)
"[orders-onOrderCreated] Failed to send notification",
orderId,
error,
);
} }
}; }
const onOrderCreated = onDocumentCreated( const onOrderCreated = onDocumentCreated(`${ORDERS_COLLECTION}/{orderId}`, async (event) => {
`${ORDERS_COLLECTION}/{orderId}`, const orderRef = event?.data?.ref
async (event) => { const orderData = event?.data?.data()
const orderRef = event?.data?.ref;
const orderData = event?.data?.data();
if (!orderRef || !orderData) { if (!orderRef || !orderData) {
return; return
} }
if (orderData?.processedAt) { if (orderData?.processedAt) {
return; return
} }
const userId = const userId = typeof orderData.userId === 'string' ? orderData.userId.trim() : ''
typeof orderData.userId === "string" ? orderData.userId.trim() : ""; const amount = normalizeAmount(orderData.amount)
const amount = normalizeAmount(orderData.amount);
if (!userId) { if (!userId) {
await orderRef.set( await orderRef.set(
{ {
status: ORDER_STATUS.REJECTED, status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(), processedAt: FieldValue.serverTimestamp(),
failureReason: "USER_NOT_FOUND", failureReason: 'USER_NOT_FOUND',
}, },
{ merge: true }, { merge: true }
); )
return; return
} }
if (amount === null || amount === 0) { if (amount === null || amount === 0) {
await orderRef.set( await orderRef.set(
{ {
status: ORDER_STATUS.REJECTED, status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(), processedAt: FieldValue.serverTimestamp(),
failureReason: "INVALID_AMOUNT", failureReason: 'INVALID_AMOUNT',
}, },
{ merge: true }, { merge: true }
); )
return; 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 { try {
await admin.firestore().runTransaction(async (transaction) => { await admin.firestore().runTransaction(async (transaction) => {
const userSnapshot = await transaction.get(userRef); const userSnapshot = await transaction.get(userRef)
const userData = userSnapshot?.data() || {}; const userData = userSnapshot?.data() || {}
const currentBalanceValue = normalizeAmount(userData?.coins); const currentBalanceValue = normalizeAmount(userData?.coins)
const currentBalance = const currentBalance = currentBalanceValue !== null ? currentBalanceValue : 0
currentBalanceValue !== null ? currentBalanceValue : 0;
const nextBalance = currentBalance + amount; 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 },
);
}
if (amount < 0 && nextBalance < 0 && orderData?.type === ORDER_TYPES.SONG) {
transaction.set( transaction.set(
orderRef, orderRef,
{ {
status: ORDER_STATUS.APPLIED, status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(), processedAt: FieldValue.serverTimestamp(),
failureReason: 'INSUFFICIENT_FUNDS',
balanceBefore: currentBalance, 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, balanceBefore: currentBalance,
balanceAfter: nextBalance, 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 }, { merge: true }
); )
return;
}
if (notificationContext) { notificationContext = {
await notifyOrderApplied({ balanceBefore: currentBalance,
userId, balanceAfter: nextBalance,
orderId: orderRef.id, }
amount, })
orderType: } catch (error) {
typeof orderData?.type === "string" ? orderData.type : null, console.error('[orders-onOrderCreated] Failed to process order', orderRef.id, error)
balanceBefore: notificationContext.balanceBefore,
balanceAfter: notificationContext.balanceAfter,
metadata: orderData?.metadata || {},
});
}
},
);
const createSongOrder = onCall({ region: REGION }, async (request) => { await orderRef.set(
const { auth, data } = request || {}; {
status: ORDER_STATUS.REJECTED,
if (!auth?.uid) { processedAt: FieldValue.serverTimestamp(),
throw new HttpsError("unauthenticated", "Authentification requise."); 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) { if (amount === null || amount >= 0) {
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'invalid-argument',
"Le montant doit être négatif pour un achat de musique.", 'Le montant doit être négatif pour un achat de musique.'
); )
} }
const songId = const songId = typeof data?.songId === 'string' && data.songId.trim() ? data.songId.trim() : null
typeof data?.songId === "string" && data.songId.trim()
? data.songId.trim()
: null;
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid); const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid)
const userSnapshot = await userRef.get(); const userSnapshot = await userRef.get()
const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins); const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins)
const currentCoins = const currentCoins = currentCoinsValue !== null ? currentCoinsValue : 0
currentCoinsValue !== null ? currentCoinsValue : 0;
if (currentCoins + amount < 0) { if (currentCoins + amount < 0) {
throw new HttpsError( throw new HttpsError('failed-precondition', "Crédits insuffisants pour finaliser l'opération.")
"failed-precondition",
"Crédits insuffisants pour finaliser l'opération.",
);
} }
const metadata = { const metadata = {
source: source:
typeof data?.source === "string" && data.source.trim() typeof data?.source === 'string' && data.source.trim()
? data.source.trim() ? data.source.trim()
: "music_generation", : 'music_generation',
}; }
if (typeof data?.requestId === "string" && data.requestId.trim()) { if (typeof data?.requestId === 'string' && data.requestId.trim()) {
metadata.requestId = data.requestId.trim(); metadata.requestId = data.requestId.trim()
} }
const { orderId } = await createOrderDocument({ const { orderId } = await createOrderDocument({
@@ -324,12 +293,12 @@ const createSongOrder = onCall({ region: REGION }, async (request) => {
songId, songId,
createdBy: auth.uid, createdBy: auth.uid,
metadata, metadata,
}); })
return { orderId }; return { orderId }
}); })
module.exports = { module.exports = {
onOrderCreated, onOrderCreated,
createSongOrder, createSongOrder,
}; }
+119 -145
View File
@@ -1,151 +1,134 @@
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const { FieldValue } = require("firebase-admin/firestore"); const { FieldValue } = require('firebase-admin/firestore')
const { onSchedule } = require("firebase-functions/v2/scheduler"); const { onSchedule } = require('firebase-functions/v2/scheduler')
const _ = require("lodash"); const _ = require('lodash')
const { refList, db, ALERT_TYPE } = require("../index"); const { refList, db, ALERT_TYPE } = require('../index')
const { batchFirestore } = require("../helpers/firebase"); const { batchFirestore } = require('../helpers/firebase')
const { BATCH_TYPE } = require("../config/types"); const { BATCH_TYPE } = require('../config/types')
const { const { buildMonthKey, buildPreviousMonthContext } = require('../helpers/stats')
buildMonthKey, const { sendNotification } = require('./notifications')
buildPreviousMonthContext,
} = require("../helpers/stats");
const { sendNotification } = require("./notifications");
const DISTRIBUTION_REVENUE_BASELINE = 1000; const DISTRIBUTION_REVENUE_BASELINE = 1000
const DISTRIBUTION_RATIO = 0.3; const DISTRIBUTION_RATIO = 0.3
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(["active"]); const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['active'])
const hasActiveSubscription = (userData) => { const hasActiveSubscription = (userData) => {
if (!userData || typeof userData !== "object") { if (!userData || typeof userData !== 'object') {
return false; return false
} }
const isPremium = userData.isPremium === true; const isPremium = userData.isPremium === true
if (!isPremium) { if (!isPremium) {
return false; return false
} }
const status = const status =
typeof userData.stripeSubscriptionStatus === "string" typeof userData.stripeSubscriptionStatus === 'string'
? userData.stripeSubscriptionStatus.trim().toLowerCase() ? userData.stripeSubscriptionStatus.trim().toLowerCase()
: null; : null
if (status && ACTIVE_SUBSCRIPTION_STATUSES.has(status)) { if (status && ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
return true; return true
} }
const billingPeriod = const billingPeriod =
typeof userData.premiumBillingPeriod === "string" typeof userData.premiumBillingPeriod === 'string'
? userData.premiumBillingPeriod.trim().toLowerCase() ? userData.premiumBillingPeriod.trim().toLowerCase()
: null; : null
if (billingPeriod === "monthly" || billingPeriod === "annual") { if (billingPeriod === 'monthly' || billingPeriod === 'annual') {
// Fallback: billing period is set only for active subscribers. // Fallback: billing period is set only for active subscribers.
return true; return true
} }
return false; return false
}; }
exports.distributeMonthlyPayouts = onSchedule( exports.distributeMonthlyPayouts = onSchedule(
{ {
schedule: "0 1 1 * *", schedule: '0 1 1 * *',
timeZone: "Europe/Paris", timeZone: 'Europe/Paris',
}, },
async (event) => { async (event) => {
const { scheduleTime } = event; const { scheduleTime } = event
const context = buildPreviousMonthContext( const context = buildPreviousMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
scheduleTime ? new Date(scheduleTime) : new Date(), const monthKey = buildMonthKey(context.rangeStart)
); const now = admin.firestore.Timestamp.now()
const monthKey = buildMonthKey(context.rangeStart);
const now = admin.firestore.Timestamp.now();
const statsSnapshot = await db const statsSnapshot = await db
.collectionGroup("monthlyListens") .collectionGroup('monthlyListens')
.where("monthKey", "==", monthKey) .where('monthKey', '==', monthKey)
.orderBy("streams", "desc") .orderBy('streams', 'desc')
.get(); .get()
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey); const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey)
const totalsSnapshot = await totalsDocRef.get(); const totalsSnapshot = await totalsDocRef.get()
const entries = _.chain(statsSnapshot.docs) const entries = _.chain(statsSnapshot.docs)
.map((doc) => { .map((doc) => {
const data = doc.data() || {}; const data = doc.data() || {}
return { return {
projectId: projectId: _.get(data, 'projectId') || doc.ref.parent.parent?.id || null,
_.get(data, "projectId") || doc.ref.parent.parent?.id || null, userId: _.get(data, 'userId', null),
userId: _.get(data, "userId", null), streams: _.toFinite(_.get(data, 'streams', 0)),
streams: _.toFinite(_.get(data, "streams", 0)),
statsDocPath: doc.ref.path, statsDocPath: doc.ref.path,
}; }
}) })
.filter((entry) => entry.projectId && entry.streams > 0) .filter((entry) => entry.projectId && entry.streams > 0)
.orderBy(["streams"], ["desc"]) .orderBy(['streams'], ['desc'])
.value(); .value()
const userEligibilityMap = {}; const userEligibilityMap = {}
const userIds = _.uniq( const userIds = _.uniq(entries.map((entry) => entry.userId).filter((userId) => !!userId))
entries.map((entry) => entry.userId).filter((userId) => !!userId),
);
if (userIds.length) { if (userIds.length) {
const chunkSize = 300; const chunkSize = 300
for (let index = 0; index < userIds.length; index += chunkSize) { 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( const snapshots = await Promise.all(
chunk.map(async (userId) => { chunk.map(async (userId) => {
try { try {
return await refList.users.doc(userId).get(); return await refList.users.doc(userId).get()
} catch (error) { } catch (error) {
console.warn( console.warn('[distributeMonthlyPayouts] Unable to load user profile', {
"[distributeMonthlyPayouts] Unable to load user profile", userId,
{ error: error?.message || String(error),
userId, })
error: error?.message || String(error), return null
},
);
return null;
} }
}), })
); )
snapshots.forEach((snapshot, snapshotIndex) => { snapshots.forEach((snapshot, snapshotIndex) => {
const userId = chunk[snapshotIndex]; const userId = chunk[snapshotIndex]
if (snapshot?.exists) { if (snapshot?.exists) {
userEligibilityMap[userId] = hasActiveSubscription( userEligibilityMap[userId] = hasActiveSubscription(snapshot.data())
snapshot.data(),
);
} else { } else {
userEligibilityMap[userId] = false; userEligibilityMap[userId] = false
} }
}); })
} }
} }
const eligibleEntries = entries.filter( const eligibleEntries = entries.filter(
(entry) => (entry) => !!entry.userId && userEligibilityMap[entry.userId] === true
!!entry.userId && userEligibilityMap[entry.userId] === true, )
); const eligibleTotalStreams = _.sumBy(eligibleEntries, 'streams')
const eligibleTotalStreams = _.sumBy(eligibleEntries, "streams");
const payoutsTotalStreamsFromDocs = _.sumBy(entries, "streams"); const payoutsTotalStreamsFromDocs = _.sumBy(entries, 'streams')
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null; const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null
let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0)); let totalStreams = _.toFinite(_.get(totalsData, 'totalStreams', 0))
if (!totalStreams || totalStreams < payoutsTotalStreamsFromDocs) { if (!totalStreams || totalStreams < payoutsTotalStreamsFromDocs) {
totalStreams = payoutsTotalStreamsFromDocs; totalStreams = payoutsTotalStreamsFromDocs
} }
const payoutPool = _.round( const payoutPool = _.round(DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO, 2)
DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO,
2,
);
let allocations = _.map(eligibleEntries, (entry) => { let allocations = _.map(eligibleEntries, (entry) => {
if (!eligibleTotalStreams) return 0; if (!eligibleTotalStreams) return 0
const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams; const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams
return _.round(rawAmount, 2); return _.round(rawAmount, 2)
}); })
if (allocations.length > 0) { if (allocations.length > 0) {
const allocatedTotal = _.round(_.sum(allocations), 2); const allocatedTotal = _.round(_.sum(allocations), 2)
const remainder = _.round(payoutPool - allocatedTotal, 2); const remainder = _.round(payoutPool - allocatedTotal, 2)
if (remainder !== 0) { 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, projectId: entry.projectId,
userId: entry.userId, userId: entry.userId,
streams: entry.streams, streams: entry.streams,
share: eligibleTotalStreams share: eligibleTotalStreams ? _.round(entry.streams / eligibleTotalStreams, 6) : 0,
? _.round(entry.streams / eligibleTotalStreams, 6)
: 0,
amount: allocations[idx], amount: allocations[idx],
statsDocPath: entry.statsDocPath, statsDocPath: entry.statsDocPath,
})); }))
const userDocs = _(payouts) const userDocs = _(payouts)
.filter((payout) => !!payout.userId) .filter((payout) => !!payout.userId)
.groupBy("userId") .groupBy('userId')
.map((projects, userId) => { .map((projects, userId) => {
const sortedProjects = _.orderBy(projects, ["amount"], ["desc"]).map( const sortedProjects = _.orderBy(projects, ['amount'], ['desc']).map((project) => ({
(project) => ({ projectId: project.projectId,
projectId: project.projectId, rank: project.rank,
rank: project.rank, amount: project.amount,
amount: project.amount, streams: project.streams,
streams: project.streams, share: project.share,
share: project.share, statsDocPath: project.statsDocPath,
statsDocPath: project.statsDocPath, }))
}),
);
return { return {
userId, userId,
totalAmount: _.round(_.sumBy(projects, "amount"), 2), totalAmount: _.round(_.sumBy(projects, 'amount'), 2),
totalStreams: _.sumBy(projects, "streams"), totalStreams: _.sumBy(projects, 'streams'),
projects: sortedProjects, projects: sortedProjects,
}; }
}) })
.value(); .value()
if (userDocs.length) { if (userDocs.length) {
const docs = userDocs.map((userData) => { const docs = userDocs.map((userData) => {
const docId = `${monthKey}_${userData.userId}`; const docId = `${monthKey}_${userData.userId}`
return { return {
ref: refList.monthlyPayoutEntries.doc(docId), ref: refList.monthlyPayoutEntries.doc(docId),
data: { data: {
@@ -200,35 +179,33 @@ exports.distributeMonthlyPayouts = onSchedule(
computedAt: now, computedAt: now,
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
}; }
}); })
await batchFirestore({ await batchFirestore({
docs, docs,
type: BATCH_TYPE.UPDATE, type: BATCH_TYPE.UPDATE,
}); })
const payoutLabel = `${String(context.month).padStart(2, "0")}/${ const payoutLabel = `${String(context.month).padStart(2, '0')}/${context.year}`
context.year
}`;
await Promise.all( await Promise.all(
userDocs.map(async (userData) => { userDocs.map(async (userData) => {
const receiverId = const receiverId =
typeof userData.userId === "string" && userData.userId.trim() typeof userData.userId === 'string' && userData.userId.trim()
? userData.userId.trim() ? userData.userId.trim()
: null; : null
const amount = Number(userData.totalAmount) || 0; const amount = Number(userData.totalAmount) || 0
if (!receiverId || amount <= 0) { if (!receiverId || amount <= 0) {
return null; return null
} }
const amountLabel = amount.toFixed(2); const amountLabel = amount.toFixed(2)
const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`; const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`
try { try {
await sendNotification({ await sendNotification({
sender: "SYSTEM", sender: 'SYSTEM',
receiver: receiverId, receiver: receiverId,
receiverCollection: "users", receiverCollection: 'users',
title: "Revenus disponibles", title: 'Revenus disponibles',
message, message,
data: { data: {
type: ALERT_TYPE?.PAYOUT_AVAILABLE, type: ALERT_TYPE?.PAYOUT_AVAILABLE,
@@ -239,19 +216,16 @@ exports.distributeMonthlyPayouts = onSchedule(
totalStreams: userData.totalStreams, totalStreams: userData.totalStreams,
projects: userData.projects, projects: userData.projects,
}, },
}); })
} catch (notifError) { } catch (notifError) {
console.log( console.log('[distributeMonthlyPayouts] Failed to send payout notification:', {
"[distributeMonthlyPayouts] Failed to send payout notification:", userId: receiverId,
{ error: notifError?.message || String(notifError),
userId: receiverId, })
error: notifError?.message || String(notifError),
},
);
} }
return null; return null
}), })
); )
} }
const summary = { const summary = {
@@ -270,14 +244,14 @@ exports.distributeMonthlyPayouts = onSchedule(
totalRecipients: payouts.length, totalRecipients: payouts.length,
totalEntries: entries.length, totalEntries: entries.length,
eligibleEntries: eligibleEntries.length, eligibleEntries: eligibleEntries.length,
totalAllocated: _.round(_.sumBy(payouts, "amount"), 2), totalAllocated: _.round(_.sumBy(payouts, 'amount'), 2),
payouts, payouts,
status: payouts.length ? "computed" : "no-data", status: payouts.length ? 'computed' : 'no-data',
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
computedAt: now, computedAt: now,
}; }
const docRef = refList.monthlyPayouts.doc(monthKey); const docRef = refList.monthlyPayouts.doc(monthKey)
await docRef.set(summary, { merge: true }); await docRef.set(summary, { merge: true })
}, }
); )
+96 -107
View File
@@ -1,115 +1,104 @@
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const { FieldValue } = require("firebase-admin/firestore"); const { FieldValue } = require('firebase-admin/firestore')
const { onDocumentWritten } = require("firebase-functions/firestore"); const { onDocumentWritten } = require('firebase-functions/firestore')
const _ = require("lodash"); const _ = require('lodash')
const { refList, db } = require("../index"); const { refList, db } = require('../index')
const { getSunoTimestamps } = require("./lyrics"); const { getSunoTimestamps } = require('./lyrics')
const { deleteFolder } = require("../helpers/firebase"); const { deleteFolder } = require('../helpers/firebase')
const { buildMonthKey } = require("../helpers/stats"); const { buildMonthKey } = require('../helpers/stats')
exports.onProjectWritten = onDocumentWritten( exports.onProjectWritten = onDocumentWritten('projects/{projectId}', async (projectSnap) => {
"projects/{projectId}", try {
async (projectSnap) => { const { projectId } = projectSnap.params
try { const beforeData = projectSnap?.data?.before?.data() || null
const { projectId } = projectSnap.params; const afterData = projectSnap?.data?.after?.data() || null
const beforeData = projectSnap?.data?.before?.data() || null;
const afterData = projectSnap?.data?.after?.data() || null;
if (!afterData) { if (!afterData) {
const { userId } = beforeData || {}; const { userId } = beforeData || {}
if (userId) { if (userId) {
await deleteFolder(`users/${userId}/projects/${projectId}/`); 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 });
}
} }
const beforeViews = _.toFinite(_.get(beforeData, "views", 0)); const snapshot = await refList.tasks.where('projectId', '==', projectId).get()
const afterViews = _.toFinite(_.get(afterData, "views", 0)); if (!snapshot.empty) {
const delta = afterViews - beforeViews; const batch = db.batch()
snapshot.forEach((doc) => {
if (delta > 0) { batch.delete(doc.ref)
const userIdRaw = _.get(afterData, "userId", null); })
const userId = await batch.commit()
_.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; return null
} catch (error) { } else if (!beforeData) {
console.log("onProjectViewsIncrement error", { //song create
message: error?.message || String(error || ""), } else {
}); if (!beforeData?.songUrl && afterData?.songUrl) {
return null; 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 admin = require('firebase-admin')
const { FieldValue } = require("firebase-admin/firestore"); const { FieldValue } = require('firebase-admin/firestore')
const { onSchedule } = require("firebase-functions/v2/scheduler"); const { onSchedule } = require('firebase-functions/v2/scheduler')
const { refList } = require("../index"); const { refList } = require('../index')
const firestore = refList.projects.firestore; const firestore = refList.projects.firestore
function buildMonthContext(referenceDate) { function buildMonthContext(referenceDate) {
const current = referenceDate ? new Date(referenceDate) : new Date(); const current = referenceDate ? new Date(referenceDate) : new Date()
current.setHours(0, 0, 0, 0); current.setHours(0, 0, 0, 0)
current.setDate(1); current.setDate(1)
const target = new Date(current); const target = new Date(current)
target.setMonth(target.getMonth() - 1); target.setMonth(target.getMonth() - 1)
const month = target.getMonth(); const month = target.getMonth()
const year = target.getFullYear(); const year = target.getFullYear()
const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`; const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`
const rangeStart = new Date(year, month, 1, 0, 0, 0, 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 rangeEnd = new Date(year, month + 1, 0, 23, 59, 59, 999)
return { return {
year, year,
@@ -24,27 +24,22 @@ function buildMonthContext(referenceDate) {
monthKey, monthKey,
rangeStart, rangeStart,
rangeEnd, rangeEnd,
}; }
} }
exports.snapshotMonthlyTopSongs = onSchedule( exports.snapshotMonthlyTopSongs = onSchedule(
{ {
schedule: "5 0 1 * *", schedule: '5 0 1 * *',
timeZone: "Europe/Paris", timeZone: 'Europe/Paris',
}, },
async (event) => { async (event) => {
const { scheduleTime } = event; const { scheduleTime } = event
const context = buildMonthContext( const context = buildMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
scheduleTime ? new Date(scheduleTime) : new Date()
);
const topProjectsSnap = await refList.projects const topProjectsSnap = await refList.projects.orderBy('views', 'desc').limit(3).get()
.orderBy("views", "desc")
.limit(3)
.get();
const topProjects = topProjectsSnap.docs.map((doc, index) => { const topProjects = topProjectsSnap.docs.map((doc, index) => {
const data = doc.data() || {}; const data = doc.data() || {}
return { return {
rank: index + 1, rank: index + 1,
projectId: doc.id, projectId: doc.id,
@@ -54,14 +49,12 @@ exports.snapshotMonthlyTopSongs = onSchedule(
coverUrl: data.coverUrl || null, coverUrl: data.coverUrl || null,
songUrl: data.songUrl || null, songUrl: data.songUrl || null,
views: data.views || 0, views: data.views || 0,
}; }
}); })
const docRef = firestore const docRef = firestore.collection('monthlyTopSongs').doc(context.monthKey)
.collection("monthlyTopSongs")
.doc(context.monthKey);
const existingSnapshot = await docRef.get(); const existingSnapshot = await docRef.get()
const payload = { const payload = {
monthKey: context.monthKey, monthKey: context.monthKey,
month: context.month, month: context.month,
@@ -73,12 +66,12 @@ exports.snapshotMonthlyTopSongs = onSchedule(
topProjects, topProjects,
totalProjects: topProjects.length, totalProjects: topProjects.length,
updatedAt: FieldValue.serverTimestamp(), 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 admin = require('firebase-admin')
const { HttpsError, onCall } = require("firebase-functions/https"); const { HttpsError, onCall } = require('firebase-functions/https')
let FieldValue = null; let FieldValue = null
try { try {
({ FieldValue } = require("firebase-admin/firestore")); ;({ FieldValue } = require('firebase-admin/firestore'))
} catch (error) { } catch (error) {
console.warn("[stripe] FieldValue import failed", error?.message); console.warn('[stripe] FieldValue import failed', error?.message)
} }
const { REGION, refsList } = require("../index"); const { REGION, refsList } = require('../index')
const STRIPE_MODE = "test"; const STRIPE_MODE = 'test'
const { const {
getStripeClient, getStripeClient,
getReturnUrls, getReturnUrls,
@@ -19,23 +19,20 @@ const {
formatCheckoutSessionResponse, formatCheckoutSessionResponse,
getPortalConfigurationId, getPortalConfigurationId,
mapStripeErrorToHttps, mapStripeErrorToHttps,
} = require("../helpers/stripe"); } = require('../helpers/stripe')
const paymentsCollection = admin.firestore().collection("payments"); const paymentsCollection = admin.firestore().collection('payments')
const getServerTimestamp = () => { const getServerTimestamp = () => {
if (FieldValue?.serverTimestamp) { if (FieldValue?.serverTimestamp) {
return FieldValue.serverTimestamp(); return FieldValue.serverTimestamp()
} }
const fallback = admin.firestore?.FieldValue; const fallback = admin.firestore?.FieldValue
if (fallback?.serverTimestamp) { if (fallback?.serverTimestamp) {
return fallback.serverTimestamp(); return fallback.serverTimestamp()
} }
throw new HttpsError( throw new HttpsError('failed-precondition', 'Firestore FieldValue.serverTimestamp indisponible.')
"failed-precondition", }
"Firestore FieldValue.serverTimestamp indisponible.",
);
};
const normalizePaymentIntent = (paymentIntent) => ({ const normalizePaymentIntent = (paymentIntent) => ({
id: paymentIntent.id, id: paymentIntent.id,
@@ -48,53 +45,44 @@ const normalizePaymentIntent = (paymentIntent) => ({
created: paymentIntent.created, created: paymentIntent.created,
latest_charge: paymentIntent.latest_charge, latest_charge: paymentIntent.latest_charge,
metadata: paymentIntent.metadata, metadata: paymentIntent.metadata,
}); })
const createCheckoutSession = onCall({ region: REGION }, async (request) => { const createCheckoutSession = onCall({ region: REGION }, async (request) => {
try { try {
const uid = request?.auth?.uid; const uid = request?.auth?.uid
if (!uid) { if (!uid) {
throw new HttpsError( throw new HttpsError('unauthenticated', 'Connecte-toi pour créer une session Stripe.')
"unauthenticated",
"Connecte-toi pour créer une session Stripe.",
);
} }
const requestedUserId = const requestedUserId =
typeof request?.data?.userID === "string" typeof request?.data?.userID === 'string' ? request.data.userID.trim() : null
? request.data.userID.trim()
: null;
if (requestedUserId && requestedUserId !== uid) { if (requestedUserId && requestedUserId !== uid) {
throw new HttpsError( throw new HttpsError(
"permission-denied", 'permission-denied',
"Tu ne peux créer une session que pour ton propre compte.", 'Tu ne peux créer une session que pour ton propre compte.'
); )
} }
const productList = Array.isArray(request?.data?.productList) const productList = Array.isArray(request?.data?.productList) ? request.data.productList : []
? request.data.productList
: [];
const stripe = getStripeClient(); const stripe = getStripeClient()
const { lineItems, summary, hasSubscription } = const { lineItems, summary, hasSubscription } = await buildCheckoutLineItems(productList, {
await buildCheckoutLineItems(productList, { stripe }); stripe,
})
const mode = hasSubscription ? "subscription" : "payment"; const mode = hasSubscription ? 'subscription' : 'payment'
const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls); const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls)
const { customerId } = await ensureStripeCustomer({ const { customerId } = await ensureStripeCustomer({
uid, uid,
stripe, stripe,
refsList, refsList,
createIfMissing: true, createIfMissing: true,
}); })
if (!customerId) { if (!customerId) {
throw new HttpsError( throw new HttpsError('internal', 'Impossible de retrouver le client Stripe associé.')
"internal",
"Impossible de retrouver le client Stripe associé.",
);
} }
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
@@ -107,12 +95,12 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => {
metadata: { metadata: {
firebaseUID: uid, firebaseUID: uid,
}, },
}); })
await paymentsCollection.doc(session.id).set({ await paymentsCollection.doc(session.id).set({
userId: uid, userId: uid,
customerId, customerId,
status: session.status || "created", status: session.status || 'created',
mode, mode,
createdAt: getServerTimestamp(), createdAt: getServerTimestamp(),
updatedAt: getServerTimestamp(), updatedAt: getServerTimestamp(),
@@ -123,59 +111,53 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => {
amountTotal: session.amount_total, amountTotal: session.amount_total,
currency: session.currency, currency: session.currency,
paymentStatus: session.payment_status, paymentStatus: session.payment_status,
}); })
return formatCheckoutSessionResponse(session); return formatCheckoutSessionResponse(session)
} catch (error) { } catch (error) {
console.error("[createCheckoutSession] error", error); console.error('[createCheckoutSession] error', error)
if (error instanceof HttpsError) { if (error instanceof HttpsError) {
throw error; throw error
} }
throw mapStripeErrorToHttps( throw mapStripeErrorToHttps(error, 'Création de la session Stripe impossible.')
error,
"Création de la session Stripe impossible.",
);
} }
}); })
const getPremiumStatus = onCall({ region: REGION }, async (request) => { const getPremiumStatus = onCall({ region: REGION }, async (request) => {
try { try {
const uid = request?.auth?.uid; const uid = request?.auth?.uid
if (!uid) { if (!uid) {
throw new HttpsError( throw new HttpsError('unauthenticated', 'Connecte-toi pour consulter ton statut Stripe.')
"unauthenticated",
"Connecte-toi pour consulter ton statut Stripe.",
);
} }
const stripe = getStripeClient(); const stripe = getStripeClient()
const { customerId } = await ensureStripeCustomer({ const { customerId } = await ensureStripeCustomer({
uid, uid,
stripe, stripe,
refsList, refsList,
createIfMissing: false, createIfMissing: false,
}); })
if (!customerId) { if (!customerId) {
return { return {
customerId: null, customerId: null,
subscriptions: [], subscriptions: [],
invoices: [], invoices: [],
}; }
} }
const [subscriptions, invoices] = await Promise.all([ const [subscriptions, invoices] = await Promise.all([
stripe.subscriptions.list({ stripe.subscriptions.list({
customer: customerId, customer: customerId,
status: "all", status: 'all',
expand: ["data.items.data.price"], expand: ['data.items.data.price'],
limit: 20, limit: 20,
}), }),
stripe.invoices.list({ stripe.invoices.list({
customer: customerId, customer: customerId,
limit: 20, limit: 20,
}), }),
]); ])
const formattedSubscriptions = subscriptions.data.map((subscription) => ({ const formattedSubscriptions = subscriptions.data.map((subscription) => ({
id: subscription.id, id: subscription.id,
@@ -193,7 +175,7 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => {
currency: item.price?.currency, currency: item.price?.currency,
recurring: item.price?.recurring, recurring: item.price?.recurring,
})), })),
})); }))
const formattedInvoices = invoices.data.map((invoice) => ({ const formattedInvoices = invoices.data.map((invoice) => ({
id: invoice.id, id: invoice.id,
@@ -205,179 +187,153 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => {
hosted_invoice_url: invoice.hosted_invoice_url, hosted_invoice_url: invoice.hosted_invoice_url,
invoice_pdf: invoice.invoice_pdf, invoice_pdf: invoice.invoice_pdf,
created: invoice.created, created: invoice.created,
})); }))
return { return {
customerId, customerId,
subscriptions: formattedSubscriptions, subscriptions: formattedSubscriptions,
invoices: formattedInvoices, invoices: formattedInvoices,
}; }
} catch (error) { } catch (error) {
console.error("[getPremiumStatus] error", error); console.error('[getPremiumStatus] error', error)
if (error instanceof HttpsError) { if (error instanceof HttpsError) {
throw error; throw error
} }
throw mapStripeErrorToHttps( throw mapStripeErrorToHttps(error, 'Impossible de récupérer le statut Stripe.')
error,
"Impossible de récupérer le statut Stripe.",
);
} }
}); })
const createStripeCustomerPortalSession = onCall( const createStripeCustomerPortalSession = onCall({ region: REGION }, async (request) => {
{ region: REGION }, try {
async (request) => { const uid = request?.auth?.uid
try { if (!uid) {
const uid = request?.auth?.uid; throw new HttpsError('unauthenticated', 'Connecte-toi pour ouvrir le portail client Stripe.')
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 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) => { const resolveStripeConnectAccountId = (userData) => {
if (!userData || typeof userData !== "object") { if (!userData || typeof userData !== 'object') {
return null; return null
} }
const candidatePaths = [ const candidatePaths = [
["stripeConnectAccountId"], ['stripeConnectAccountId'],
["stripeConnectId"], ['stripeConnectId'],
["stripeAccountId"], ['stripeAccountId'],
["stripeConnectAccount"], ['stripeConnectAccount'],
["stripeAccount"], ['stripeAccount'],
["stripe", "connectAccountId"], ['stripe', 'connectAccountId'],
["stripe", "accountId"], ['stripe', 'accountId'],
["providers", "stripeConnect", "accountId"], ['providers', 'stripeConnect', 'accountId'],
]; ]
for (const path of candidatePaths) { for (const path of candidatePaths) {
let current = userData; let current = userData
for (const key of path) { for (const key of path) {
if (!current || typeof current !== "object") { if (!current || typeof current !== 'object') {
current = null; current = null
break; break
} }
current = current[key]; current = current[key]
} }
if (typeof current === "string" && current.trim()) { if (typeof current === 'string' && current.trim()) {
return current.trim(); return current.trim()
} }
} }
return null; return null
}; }
const ensureStripeConnectAccount = async ({ const ensureStripeConnectAccount = async ({ uid, stripe, userRef, userData }) => {
uid,
stripe,
userRef,
userData,
}) => {
if (!uid || !stripe) { 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) { if (connectAccountId) {
return { connectAccountId, userData, createdAccount: false }; return { connectAccountId, userData, createdAccount: false }
} }
let authRecord = null; let authRecord = null
try { try {
authRecord = await admin.auth().getUser(uid); authRecord = await admin.auth().getUser(uid)
} catch (error) { } catch (error) {
console.warn( console.warn('[ensureStripeConnectAccount] Impossible de récupérer auth user', error)
"[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 = { const accountParams = {
type: "express", type: 'express',
capabilities: { capabilities: {
card_payments: { requested: true }, card_payments: { requested: true },
transfers: { requested: true }, transfers: { requested: true },
}, },
metadata: { metadata: {
firebaseUID: uid, firebaseUID: uid,
appMode: STRIPE_MODE || "test", appMode: STRIPE_MODE || 'test',
}, },
};
if (email) {
accountParams.email = email;
} }
const account = await stripe.accounts.create(accountParams); if (email) {
connectAccountId = account?.id; accountParams.email = email
}
const account = await stripe.accounts.create(accountParams)
connectAccountId = account?.id
if (!connectAccountId) { if (!connectAccountId) {
throw new HttpsError( throw new HttpsError('internal', 'Stripe na pas renvoyé didentifiant de compte Connect.')
"internal",
"Stripe na pas renvoyé didentifiant de compte Connect.",
);
} }
const providersConnect = { const providersConnect = {
...(userData?.providers?.stripeConnect || {}), ...(userData?.providers?.stripeConnect || {}),
accountId: connectAccountId, accountId: connectAccountId,
}; }
if (userRef) { if (userRef) {
await userRef.set( await userRef.set(
@@ -391,8 +347,8 @@ const ensureStripeConnectAccount = async ({
}, },
}, },
}, },
{ merge: true }, { merge: true }
); )
} }
return { return {
@@ -406,170 +362,140 @@ const ensureStripeConnectAccount = async ({
}, },
}, },
createdAccount: true, createdAccount: true,
}; }
}; }
const createStripeConnectLoginLink = onCall( const createStripeConnectLoginLink = onCall({ region: REGION }, async (request) => {
{ region: REGION }, try {
async (request) => { const uid = request?.auth?.uid
try { if (!uid) {
const uid = request?.auth?.uid; throw new HttpsError('unauthenticated', 'Connecte-toi pour ouvrir Stripe Connect.')
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 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) => { const verifyStripePayment = onCall({ region: REGION }, async (request) => {
try { try {
const uid = request?.auth?.uid; const uid = request?.auth?.uid
if (!uid) { if (!uid) {
throw new HttpsError( throw new HttpsError('unauthenticated', 'Connecte-toi pour vérifier un paiement Stripe.')
"unauthenticated",
"Connecte-toi pour vérifier un paiement Stripe.",
);
} }
const rawPaymentId = const rawPaymentId =
typeof request?.data?.paymentId === "string" typeof request?.data?.paymentId === 'string' ? request.data.paymentId.trim() : ''
? request.data.paymentId.trim()
: "";
if (!rawPaymentId) { if (!rawPaymentId) {
throw new HttpsError( throw new HttpsError('invalid-argument', 'Fournis un identifiant de paiement Stripe.')
"invalid-argument",
"Fournis un identifiant de paiement Stripe.",
);
} }
const stripe = getStripeClient(); const stripe = getStripeClient()
const fetchPaymentIntent = async (paymentIntentId) => const fetchPaymentIntent = async (paymentIntentId) =>
stripe.paymentIntents.retrieve(paymentIntentId, { stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ["latest_charge"], expand: ['latest_charge'],
}); })
const fetchCheckoutSession = async (sessionId) => const fetchCheckoutSession = async (sessionId) =>
stripe.checkout.sessions.retrieve(sessionId, { stripe.checkout.sessions.retrieve(sessionId, {
expand: ["payment_intent"], expand: ['payment_intent'],
}); })
let paymentIntent = null; let paymentIntent = null
let checkoutSession = null; let checkoutSession = null
let paymentType = null; let paymentType = null
if (rawPaymentId.startsWith("pi_")) { if (rawPaymentId.startsWith('pi_')) {
paymentIntent = await fetchPaymentIntent(rawPaymentId); paymentIntent = await fetchPaymentIntent(rawPaymentId)
paymentType = "payment_intent"; paymentType = 'payment_intent'
} else if (rawPaymentId.startsWith("cs_")) { } else if (rawPaymentId.startsWith('cs_')) {
checkoutSession = await fetchCheckoutSession(rawPaymentId); checkoutSession = await fetchCheckoutSession(rawPaymentId)
paymentIntent = paymentIntent =
checkoutSession?.payment_intent && checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object'
typeof checkoutSession.payment_intent === "object"
? checkoutSession.payment_intent ? checkoutSession.payment_intent
: checkoutSession?.payment_intent : checkoutSession?.payment_intent
? await fetchPaymentIntent(checkoutSession.payment_intent) ? await fetchPaymentIntent(checkoutSession.payment_intent)
: null; : null
paymentType = "checkout_session"; paymentType = 'checkout_session'
} else { } else {
try { try {
paymentIntent = await fetchPaymentIntent(rawPaymentId); paymentIntent = await fetchPaymentIntent(rawPaymentId)
paymentType = "payment_intent"; paymentType = 'payment_intent'
} catch (intentError) { } catch (intentError) {
try { try {
checkoutSession = await fetchCheckoutSession(rawPaymentId); checkoutSession = await fetchCheckoutSession(rawPaymentId)
paymentType = "checkout_session"; paymentType = 'checkout_session'
paymentIntent = paymentIntent =
checkoutSession?.payment_intent && checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object'
typeof checkoutSession.payment_intent === "object"
? checkoutSession.payment_intent ? checkoutSession.payment_intent
: checkoutSession?.payment_intent : checkoutSession?.payment_intent
? await fetchPaymentIntent(checkoutSession.payment_intent) ? await fetchPaymentIntent(checkoutSession.payment_intent)
: null; : null
} catch (sessionError) { } catch (sessionError) {
console.error("[verifyStripePayment] lookup failure", { console.error('[verifyStripePayment] lookup failure', {
intentError, intentError,
sessionError, sessionError,
}); })
throw new HttpsError( throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.')
"not-found",
"Aucun paiement Stripe trouvé avec cet identifiant.",
);
} }
} }
} }
if (!paymentIntent && !checkoutSession) { if (!paymentIntent && !checkoutSession) {
throw new HttpsError( throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.')
"not-found",
"Aucun paiement Stripe trouvé avec cet identifiant.",
);
} }
const normalizedIntent = paymentIntent const normalizedIntent = paymentIntent ? normalizePaymentIntent(paymentIntent) : null
? normalizePaymentIntent(paymentIntent)
: null;
const response = { const response = {
type: paymentType, type: paymentType,
@@ -594,41 +520,38 @@ const verifyStripePayment = onCall({ region: REGION }, async (request) => {
checkoutSession?.status ?? checkoutSession?.status ??
null, null,
currency: normalizedIntent?.currency ?? checkoutSession?.currency ?? null, currency: normalizedIntent?.currency ?? checkoutSession?.currency ?? null,
}; }
const paymentDocId = const paymentDocId =
paymentType === "checkout_session" paymentType === 'checkout_session' ? checkoutSession?.id : normalizedIntent?.id
? checkoutSession?.id
: normalizedIntent?.id;
if (paymentDocId) { if (paymentDocId) {
const paymentDocRef = paymentsCollection.doc(paymentDocId); const paymentDocRef = paymentsCollection.doc(paymentDocId)
const existing = await paymentDocRef.get(); const existing = await paymentDocRef.get()
if (existing.exists) { if (existing.exists) {
await paymentDocRef.set( await paymentDocRef.set(
{ {
status: response.status, status: response.status,
paymentStatus: checkoutSession?.payment_status, paymentStatus: checkoutSession?.payment_status,
amountTotal: amountTotal: checkoutSession?.amount_total ?? normalizedIntent?.amount,
checkoutSession?.amount_total ?? normalizedIntent?.amount,
amountReceived: normalizedIntent?.amount_received, amountReceived: normalizedIntent?.amount_received,
currency: response.currency, currency: response.currency,
updatedAt: getServerTimestamp(), updatedAt: getServerTimestamp(),
}, },
{ merge: true }, { merge: true }
); )
} }
} }
return response; return response
} catch (error) { } catch (error) {
console.error("[verifyStripePayment] error", error); console.error('[verifyStripePayment] error', error)
if (error instanceof HttpsError) { 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 = { module.exports = {
createCheckoutSession, createCheckoutSession,
@@ -636,4 +559,4 @@ module.exports = {
createStripeCustomerPortalSession, createStripeCustomerPortalSession,
createStripeConnectLoginLink, createStripeConnectLoginLink,
verifyStripePayment, 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 { getStripeClient, mapStripeErrorToHttps } = require('../../helpers/stripe')
const { REGION } = require("./config"); const { REGION } = require('./config')
const { const {
SUBSCRIPTION_PRICE_IDS, SUBSCRIPTION_PRICE_IDS,
SUBSCRIPTION_PRICE_METADATA, SUBSCRIPTION_PRICE_METADATA,
COIN_PACK_PRODUCTS, COIN_PACK_PRODUCTS,
} = require("./constants"); } = require('./constants')
const { parseCoinsPerMonth, formatCoinPack } = require("./shared"); const { parseCoinsPerMonth, formatCoinPack } = require('./shared')
const formatPlan = (price, priceId) => { const formatPlan = (price, priceId) => {
if (!price || typeof price !== "object") { if (!price || typeof price !== 'object') {
return null; return null
} }
const product = const product = typeof price.product === 'object' && price.product !== null ? price.product : {}
typeof price.product === "object" && price.product !== null
? price.product
: {};
return { return {
id: price.id || priceId, id: price.id || priceId,
priceId: price.id || priceId, priceId: price.id || priceId,
active: price.active !== false, active: price.active !== false,
currency: price.currency || "eur", currency: price.currency || 'eur',
unitAmount: price.unit_amount, unitAmount: price.unit_amount,
unitAmountDecimal: price.unit_amount_decimal, unitAmountDecimal: price.unit_amount_decimal,
transformQuantity: price.transform_quantity || null, transformQuantity: price.transform_quantity || null,
@@ -36,16 +33,16 @@ const formatPlan = (price, priceId) => {
metadata: price.metadata || {}, metadata: price.metadata || {},
product: { product: {
id: product.id || null, id: product.id || null,
name: product.name || "", name: product.name || '',
description: product.description || "", description: product.description || '',
metadata: product.metadata || {}, metadata: product.metadata || {},
}, },
}; }
}; }
const listSubscriptionPlans = onCall({ region: REGION }, async () => { const listSubscriptionPlans = onCall({ region: REGION }, async () => {
try { try {
const stripe = getStripeClient(); const stripe = getStripeClient()
const entries = await Promise.all( const entries = await Promise.all(
Object.entries(SUBSCRIPTION_PRICE_IDS).map(async ([period, priceIds]) => { Object.entries(SUBSCRIPTION_PRICE_IDS).map(async ([period, priceIds]) => {
@@ -53,88 +50,79 @@ const listSubscriptionPlans = onCall({ region: REGION }, async () => {
priceIds.map(async (priceId) => { priceIds.map(async (priceId) => {
try { try {
const price = await stripe.prices.retrieve(priceId, { const price = await stripe.prices.retrieve(priceId, {
expand: ["product"], expand: ['product'],
}); })
return formatPlan(price, priceId); return formatPlan(price, priceId)
} catch (error) { } catch (error) {
console.error( console.error(
`[subscription-listSubscriptionPlans] Impossible de récupérer ${priceId}`, `[subscription-listSubscriptionPlans] Impossible de récupérer ${priceId}`,
error?.message || error, error?.message || error
); )
return null; return null
} }
}), })
); )
return [period, periodPlans.filter(Boolean)]; return [period, periodPlans.filter(Boolean)]
}), })
); )
return { return {
plans: Object.fromEntries(entries), plans: Object.fromEntries(entries),
}; }
} catch (error) { } catch (error) {
console.error("[subscription-listSubscriptionPlans] error", error); console.error('[subscription-listSubscriptionPlans] error', error)
throw mapStripeErrorToHttps( throw mapStripeErrorToHttps(error, 'Impossible de récupérer les abonnements Stripe.')
error,
"Impossible de récupérer les abonnements Stripe.",
);
} }
}); })
const listCoinPacks = onCall({ region: REGION }, async () => { const listCoinPacks = onCall({ region: REGION }, async () => {
try { try {
const stripe = getStripeClient(); const stripe = getStripeClient()
const packs = await Promise.all( const packs = await Promise.all(
COIN_PACK_PRODUCTS.map(async (pack) => { COIN_PACK_PRODUCTS.map(async (pack) => {
try { try {
const product = await stripe.products.retrieve(pack.productId, { const product = await stripe.products.retrieve(pack.productId, {
expand: ["default_price"], expand: ['default_price'],
}); })
let resolvedPrice = null; let resolvedPrice = null
if (typeof product?.default_price === "string") { if (typeof product?.default_price === 'string') {
resolvedPrice = await stripe.prices.retrieve(product.default_price); resolvedPrice = await stripe.prices.retrieve(product.default_price)
} else if ( } else if (product?.default_price && typeof product.default_price === 'object') {
product?.default_price && resolvedPrice = product.default_price
typeof product.default_price === "object"
) {
resolvedPrice = product.default_price;
} }
const formatted = formatCoinPack({ const formatted = formatCoinPack({
product, product,
price: resolvedPrice, price: resolvedPrice,
}); })
return { return {
...formatted, ...formatted,
coinPackKey: pack.key || null, coinPackKey: pack.key || null,
}; }
} catch (error) { } catch (error) {
console.error( console.error(
"[subscription-listCoinPacks] Unable to retrieve product", '[subscription-listCoinPacks] Unable to retrieve product',
pack.productId, pack.productId,
error?.message || error, error?.message || error
); )
return null; return null
} }
}), })
); )
return { return {
packs: packs.filter(Boolean), packs: packs.filter(Boolean),
}; }
} catch (error) { } catch (error) {
console.error("[subscription-listCoinPacks] error", error); console.error('[subscription-listCoinPacks] error', error)
throw mapStripeErrorToHttps( throw mapStripeErrorToHttps(error, 'Impossible de récupérer les packs de pièces.')
error,
"Impossible de récupérer les packs de pièces.",
);
} }
}); })
module.exports = { module.exports = {
listSubscriptionPlans, listSubscriptionPlans,
listCoinPacks, listCoinPacks,
}; }
+221 -262
View File
@@ -1,4 +1,4 @@
const { HttpsError, onCall } = require("firebase-functions/https"); const { HttpsError, onCall } = require('firebase-functions/https')
const { const {
getStripeClient, getStripeClient,
@@ -7,312 +7,271 @@ const {
getReturnUrls, getReturnUrls,
formatCheckoutSessionResponse, formatCheckoutSessionResponse,
mapStripeErrorToHttps, mapStripeErrorToHttps,
} = require("../../helpers/stripe"); } = require('../../helpers/stripe')
const { REGION } = require("./config"); const { REGION } = require('./config')
const { const {
ALL_SUBSCRIPTION_PRICE_IDS, ALL_SUBSCRIPTION_PRICE_IDS,
COIN_PACK_PRODUCT_IDS, COIN_PACK_PRODUCT_IDS,
COIN_PACK_PRODUCT_MAP, COIN_PACK_PRODUCT_MAP,
} = require("./constants"); } = require('./constants')
const { const { refsList, formatCoinPack, getSubscriptionMetaFromPrice } = require('./shared')
refsList,
formatCoinPack,
getSubscriptionMetaFromPrice,
} = require("./shared");
const CHECKOUT_UI_MODES = new Set(["hosted", "embedded"]); const CHECKOUT_UI_MODES = new Set(['hosted', 'embedded'])
const sanitizePriceId = (value) => { const sanitizePriceId = (value) => {
if (typeof value !== "string") { if (typeof value !== 'string') {
return ""; return ''
} }
return value.trim(); return value.trim()
}; }
const resolveCheckoutUiMode = (request) => { const resolveCheckoutUiMode = (request) => {
if (!request || !request.data || typeof request.data.uiMode === "undefined") { if (!request || !request.data || typeof request.data.uiMode === 'undefined') {
return "hosted"; return 'hosted'
} }
if (typeof request.data.uiMode !== "string") { if (typeof request.data.uiMode !== 'string') {
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'invalid-argument',
'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").', '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)) { if (!CHECKOUT_UI_MODES.has(normalizedUiMode)) {
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'invalid-argument',
`uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`, `uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`
); )
} }
return normalizedUiMode; return normalizedUiMode
}; }
const withCheckoutNavigationParams = ( const withCheckoutNavigationParams = (baseParams, { uiMode, successUrl, cancelUrl }) => {
baseParams, if (uiMode === 'embedded') {
{ uiMode, successUrl, cancelUrl },
) => {
if (uiMode === "embedded") {
return { return {
...baseParams, ...baseParams,
ui_mode: "embedded", ui_mode: 'embedded',
return_url: undefined, return_url: undefined,
redirect_on_completion: "never", redirect_on_completion: 'never',
}; }
} }
return { return {
...baseParams, ...baseParams,
success_url: successUrl, success_url: successUrl,
cancel_url: cancelUrl, cancel_url: cancelUrl,
}; }
}; }
const createSubscriptionCheckoutSession = onCall( const createSubscriptionCheckoutSession = onCall({ region: REGION }, async (request) => {
{ region: REGION }, try {
async (request) => { 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 { try {
const uid = request?.auth?.uid; coinPack = formatCoinPack({
if (!uid) { product,
throw new HttpsError( price: resolvedPrice,
"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) { } catch (error) {
console.error( console.error(
"[subscription-createSubscriptionCheckoutSession] error", '[subscription-createCoinPackCheckoutSession] invalid coin pack metadata',
error, productId,
); error?.message || error
if (error instanceof HttpsError) { )
throw error; throw new HttpsError(
} 'failed-precondition',
throw mapStripeErrorToHttps( 'Le pack Stripe est mal configuré (metadata.coins manquant).'
error, )
"Impossible de créer la session d'abonnement Stripe.",
);
} }
},
);
const createCoinPackCheckoutSession = onCall( if (!coinPack?.priceId) {
{ region: REGION }, throw new HttpsError(
async (request) => { 'failed-precondition',
try { 'Impossible de déterminer le prix Stripe pour ce pack.'
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 uiMode = resolveCheckoutUiMode(request)
const productId = const shouldProvideReturnUrls = uiMode !== 'embedded'
typeof rawProductId === "string" ? rawProductId.trim() : ""; const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null }
if (!productId) { const { customerId } = await ensureStripeCustomer({
throw new HttpsError( uid,
"invalid-argument", stripe,
"Un identifiant de produit Stripe est requis.", refsList,
); createIfMissing: true,
} })
if (!COIN_PACK_PRODUCT_IDS.includes(productId)) { if (!customerId) {
throw new HttpsError( throw new HttpsError(
"invalid-argument", 'failed-precondition',
`Le produit ${productId} n'est pas un pack de pièces autorisé`, 'Impossible de retrouver le client Stripe associé.'
); )
} }
const stripe = getStripeClient(); const session = await stripe.checkout.sessions.create(
withCheckoutNavigationParams(
const product = await stripe.products.retrieve(productId, { {
expand: ["default_price"], mode: 'payment',
}); customer: customerId,
line_items: [
let resolvedPrice = null; {
if (typeof product?.default_price === "string") { price: coinPack.priceId,
resolvedPrice = await stripe.prices.retrieve(product.default_price); quantity: 1,
} 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,
}, },
],
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, uiMode,
cancelUrl, successUrl,
}, cancelUrl,
), }
); )
)
return formatCheckoutSessionResponse(session); return formatCheckoutSessionResponse(session)
} catch (error) { } catch (error) {
console.error( console.error('[subscription-createCoinPackCheckoutSession] error', error)
"[subscription-createCoinPackCheckoutSession] error", if (error instanceof HttpsError) {
error, throw error
);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible de créer la session d'achat de pièces.",
);
} }
}, throw mapStripeErrorToHttps(error, "Impossible de créer la session d'achat de pièces.")
); }
})
module.exports = { module.exports = {
createSubscriptionCheckoutSession, createSubscriptionCheckoutSession,
createCoinPackCheckoutSession, createCoinPackCheckoutSession,
resolveCheckoutUiMode, 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 = { module.exports = {
REGION, REGION,
}; }
+33 -43
View File
@@ -1,86 +1,76 @@
const SUBSCRIPTION_PRICE_IDS = { const SUBSCRIPTION_PRICE_IDS = {
monthly: [ monthly: [
"price_1SPgitCzf2o5bDRdbnhLFx6f", 'price_1SPgitCzf2o5bDRdbnhLFx6f',
"price_1SPgjCCzf2o5bDRdr08Xzp8u", 'price_1SPgjCCzf2o5bDRdr08Xzp8u',
"price_1SPgjaCzf2o5bDRdd9Xo2u26", 'price_1SPgjaCzf2o5bDRdd9Xo2u26',
], ],
annual: [ annual: [
"price_1SPgkDCzf2o5bDRdNGLVNeQ3", 'price_1SPgkDCzf2o5bDRdNGLVNeQ3',
"price_1SPgkXCzf2o5bDRdejBVxEBY", 'price_1SPgkXCzf2o5bDRdejBVxEBY',
"price_1SPgkqCzf2o5bDRdIcUwTDrm", '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 = { const SUBSCRIPTION_LEVEL_ALLOWANCES = {
starter: 10, starter: 10,
pro: 40, pro: 40,
premium: 60, premium: 60,
}; }
const SUBSCRIPTION_PRICE_METADATA = { const SUBSCRIPTION_PRICE_METADATA = {
price_1SPgitCzf2o5bDRdbnhLFx6f: { price_1SPgitCzf2o5bDRdbnhLFx6f: {
level: "starter", level: 'starter',
billingPeriod: "monthly", billingPeriod: 'monthly',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter, coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
}, },
price_1SPgjCCzf2o5bDRdr08Xzp8u: { price_1SPgjCCzf2o5bDRdr08Xzp8u: {
level: "pro", level: 'pro',
billingPeriod: "monthly", billingPeriod: 'monthly',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro, coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
}, },
price_1SPgjaCzf2o5bDRdd9Xo2u26: { price_1SPgjaCzf2o5bDRdd9Xo2u26: {
level: "premium", level: 'premium',
billingPeriod: "monthly", billingPeriod: 'monthly',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium, coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
}, },
price_1SPgkDCzf2o5bDRdNGLVNeQ3: { price_1SPgkDCzf2o5bDRdNGLVNeQ3: {
level: "starter", level: 'starter',
billingPeriod: "annual", billingPeriod: 'annual',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter, coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
}, },
price_1SPgkXCzf2o5bDRdejBVxEBY: { price_1SPgkXCzf2o5bDRdejBVxEBY: {
level: "pro", level: 'pro',
billingPeriod: "annual", billingPeriod: 'annual',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro, coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
}, },
price_1SPgkqCzf2o5bDRdIcUwTDrm: { price_1SPgkqCzf2o5bDRdIcUwTDrm: {
level: "premium", level: 'premium',
billingPeriod: "annual", billingPeriod: 'annual',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium, coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
}, },
}; }
const COIN_PACK_PRODUCTS = [ const COIN_PACK_PRODUCTS = [
{ productId: "prod_TMPPEXZ1wGk2cS", key: "starter" }, { productId: 'prod_TMPPEXZ1wGk2cS', key: 'starter' },
{ productId: "prod_TMPQ5SNdS47gY6", key: "pro" }, { productId: 'prod_TMPQ5SNdS47gY6', key: 'pro' },
{ productId: "prod_TMPRpA0qQSHXj5", key: "premium" }, { 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( const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce(
(acc, pack) => ({ (acc, pack) => ({
...acc, ...acc,
[pack.productId]: pack, [pack.productId]: pack,
}), }),
{}, {}
); )
const PREMIUM_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]); const PREMIUM_SUBSCRIPTION_STATUSES = new Set(['active', 'trialing'])
const CANCELABLE_SUBSCRIPTION_STATUSES = new Set([ const CANCELABLE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid'])
"trialing", const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid'])
"active",
"past_due",
"unpaid",
]);
const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
"trialing",
"active",
"past_due",
"unpaid",
]);
module.exports = { module.exports = {
SUBSCRIPTION_PRICE_IDS, SUBSCRIPTION_PRICE_IDS,
@@ -93,4 +83,4 @@ module.exports = {
PREMIUM_SUBSCRIPTION_STATUSES, PREMIUM_SUBSCRIPTION_STATUSES,
CANCELABLE_SUBSCRIPTION_STATUSES, CANCELABLE_SUBSCRIPTION_STATUSES,
ACTIVE_SUBSCRIPTION_STATUSES, ACTIVE_SUBSCRIPTION_STATUSES,
}; }
+6 -12
View File
@@ -1,14 +1,8 @@
const { listSubscriptionPlans, listCoinPacks } = require("./catalog"); const { listSubscriptionPlans, listCoinPacks } = require('./catalog')
const { const { createSubscriptionCheckoutSession, createCoinPackCheckoutSession } = require('./checkout')
createSubscriptionCheckoutSession, const { cancelActiveSubscription, getActiveSubscription } = require('./management')
createCoinPackCheckoutSession, const { handleStripeWebhook } = require('./webhooks')
} = require("./checkout"); const { processAnnualSubscriptionAllowances } = require('./schedule')
const {
cancelActiveSubscription,
getActiveSubscription,
} = require("./management");
const { handleStripeWebhook } = require("./webhooks");
const { processAnnualSubscriptionAllowances } = require("./schedule");
module.exports = { module.exports = {
listSubscriptionPlans, listSubscriptionPlans,
@@ -19,4 +13,4 @@ module.exports = {
createCoinPackCheckoutSession, createCoinPackCheckoutSession,
handleStripeWebhook, handleStripeWebhook,
processAnnualSubscriptionAllowances, 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 { getStripeClient, mapStripeErrorToHttps } = require('../../helpers/stripe')
const { REGION } = require("./config"); const { REGION } = require('./config')
const { const { CANCELABLE_SUBSCRIPTION_STATUSES, ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants')
CANCELABLE_SUBSCRIPTION_STATUSES, const { refsList, formatSubscriptionForClient, resolveUserContext } = require('./shared')
ACTIVE_SUBSCRIPTION_STATUSES,
} = require("./constants");
const {
refsList,
formatSubscriptionForClient,
resolveUserContext,
} = require("./shared");
const cancelActiveSubscription = onCall({ region: REGION }, async (request) => { const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
try { try {
const uid = request?.auth?.uid; const uid = request?.auth?.uid
if (!uid) { if (!uid) {
throw new HttpsError( throw new HttpsError('unauthenticated', 'Connecte-toi pour gérer ton abonnement.')
"unauthenticated",
"Connecte-toi pour gérer ton abonnement.",
);
} }
const stripe = getStripeClient(); const stripe = getStripeClient()
const userRef = refsList?.users?.doc(uid) || null; const userRef = refsList?.users?.doc(uid) || null
const snapshot = userRef ? await userRef.get() : null; const snapshot = userRef ? await userRef.get() : null
const userData = snapshot?.exists ? snapshot.data() || {} : {}; const userData = snapshot?.exists ? snapshot.data() || {} : {}
const inputSubscriptionId = const inputSubscriptionId =
typeof request?.data?.subscriptionId === "string" typeof request?.data?.subscriptionId === 'string' ? request.data.subscriptionId.trim() : ''
? request.data.subscriptionId.trim()
: "";
let subscriptionId = let subscriptionId =
inputSubscriptionId || inputSubscriptionId ||
userData?.stripeSubscription?.id || userData?.stripeSubscription?.id ||
userData?.stripeSubscription?.subscriptionId || userData?.stripeSubscription?.subscriptionId ||
null; null
const customerId = userData?.stripeCustomerId || null; const customerId = userData?.stripeCustomerId || null
if (!subscriptionId && customerId) { if (!subscriptionId && customerId) {
try { try {
const response = await stripe.subscriptions.list({ const response = await stripe.subscriptions.list({
customer: customerId, customer: customerId,
status: "all", status: 'all',
limit: 5, limit: 5,
}); })
const { data: subscriptionList = [] } = response || {}; const { data: subscriptionList = [] } = response || {}
const activeSubscription = subscriptionList.find( const activeSubscription = subscriptionList.find(
(candidate) => (candidate) => candidate?.status && CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status)
candidate?.status && )
CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status),
);
if (activeSubscription?.id) { if (activeSubscription?.id) {
subscriptionId = activeSubscription.id; subscriptionId = activeSubscription.id
} }
} catch (error) { } catch (error) {
console.warn( console.warn(
"[subscription-cancelActiveSubscription] Unable to list subscriptions", '[subscription-cancelActiveSubscription] Unable to list subscriptions',
customerId, customerId,
error?.message || error, error?.message || error
); )
} }
} }
if (!subscriptionId) { if (!subscriptionId) {
throw new HttpsError( throw new HttpsError('failed-precondition', 'Aucun abonnement actif à annuler.')
"failed-precondition",
"Aucun abonnement actif à annuler.",
);
} }
const subscription = await stripe.subscriptions.retrieve(subscriptionId); const subscription = await stripe.subscriptions.retrieve(subscriptionId)
if (!subscription) { 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 { return {
subscriptionId: subscription.id, subscriptionId: subscription.id,
status: subscription.status, status: subscription.status,
cancelAtPeriodEnd: subscription.cancel_at_period_end === true, cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
currentPeriodEnd: subscription.current_period_end || null, currentPeriodEnd: subscription.current_period_end || null,
alreadyCanceled: true, alreadyCanceled: true,
}; }
} }
if (subscription.cancel_at_period_end === true) { if (subscription.cancel_at_period_end === true) {
@@ -95,15 +78,12 @@ const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
cancelAtPeriodEnd: true, cancelAtPeriodEnd: true,
currentPeriodEnd: subscription.current_period_end || null, currentPeriodEnd: subscription.current_period_end || null,
alreadyCanceled: false, alreadyCanceled: false,
}; }
} }
const updatedSubscription = await stripe.subscriptions.update( const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
subscriptionId, cancel_at_period_end: true,
{ })
cancel_at_period_end: true,
},
);
return { return {
subscriptionId: updatedSubscription.id, subscriptionId: updatedSubscription.id,
@@ -111,30 +91,24 @@ const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end === true, cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end === true,
currentPeriodEnd: updatedSubscription.current_period_end || null, currentPeriodEnd: updatedSubscription.current_period_end || null,
alreadyCanceled: false, alreadyCanceled: false,
};
} catch (error) {
console.error("[subscription-cancelActiveSubscription] error", error);
if (error instanceof HttpsError) {
throw error;
} }
throw mapStripeErrorToHttps( } catch (error) {
error, console.error('[subscription-cancelActiveSubscription] error', error)
"Impossible d'annuler l'abonnement Stripe.", if (error instanceof HttpsError) {
); throw error
}
throw mapStripeErrorToHttps(error, "Impossible d'annuler l'abonnement Stripe.")
} }
}); })
const getActiveSubscription = onCall({ region: REGION }, async (request) => { const getActiveSubscription = onCall({ region: REGION }, async (request) => {
try { try {
const uid = request?.auth?.uid; const uid = request?.auth?.uid
if (!uid) { if (!uid) {
throw new HttpsError( throw new HttpsError('unauthenticated', 'Connecte-toi pour récupérer ton abonnement.')
"unauthenticated",
"Connecte-toi pour récupérer ton abonnement.",
);
} }
const stripe = getStripeClient(); const stripe = getStripeClient()
const { const {
uid: resolvedUid, uid: resolvedUid,
@@ -143,71 +117,66 @@ const getActiveSubscription = onCall({ region: REGION }, async (request) => {
} = await resolveUserContext({ } = await resolveUserContext({
metadata: request?.data?.metadata || {}, metadata: request?.data?.metadata || {},
customerId: null, customerId: null,
}); })
const lookupUid = resolvedUid || uid; const lookupUid = resolvedUid || uid
const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null; const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null
const snapshot = lookupRef ? await lookupRef.get() : null; const snapshot = lookupRef ? await lookupRef.get() : null
const data = snapshot?.exists ? snapshot.data() || {} : userData || {}; const data = snapshot?.exists ? snapshot.data() || {} : userData || {}
const subscriptionId = const subscriptionId =
data?.stripeSubscription?.id || data?.stripeSubscription?.id || data?.stripeSubscription?.subscriptionId || null
data?.stripeSubscription?.subscriptionId || const customerId = data?.stripeCustomerId || null
null;
const customerId = data?.stripeCustomerId || null;
if (!subscriptionId && !customerId) { if (!subscriptionId && !customerId) {
return { return {
subscription: null, subscription: null,
customerId: null, customerId: null,
}; }
} }
if (subscriptionId) { if (subscriptionId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, { const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ["items.data.price.product"], expand: ['items.data.price.product'],
}); })
if (subscription) { if (subscription) {
return { return {
subscription: formatSubscriptionForClient(subscription), subscription: formatSubscriptionForClient(subscription),
customerId: subscription.customer || customerId || null, customerId: subscription.customer || customerId || null,
}; }
} }
} }
if (customerId) { if (customerId) {
const response = await stripe.subscriptions.list({ const response = await stripe.subscriptions.list({
customer: customerId, customer: customerId,
status: "all", status: 'all',
limit: 5, limit: 5,
expand: ["data.items.data.price.product"], expand: ['data.items.data.price.product'],
}); })
const [subscription] = response?.data || []; const [subscription] = response?.data || []
if (subscription && ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) { if (subscription && ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
return { return {
subscription: formatSubscriptionForClient(subscription), subscription: formatSubscriptionForClient(subscription),
customerId, customerId,
}; }
} }
} }
return { return {
subscription: null, subscription: null,
customerId, customerId,
};
} catch (error) {
console.error("[subscription-getActiveSubscription] error", error);
if (error instanceof HttpsError) {
throw error;
} }
throw mapStripeErrorToHttps( } catch (error) {
error, console.error('[subscription-getActiveSubscription] error', error)
"Impossible de récupérer l'abonnement Stripe.", if (error instanceof HttpsError) {
); throw error
}
throw mapStripeErrorToHttps(error, "Impossible de récupérer l'abonnement Stripe.")
} }
}); })
module.exports = { module.exports = {
cancelActiveSubscription, cancelActiveSubscription,
getActiveSubscription, 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 { ORDER_TYPES, createOrderDocument } = require('../helpers/orders')
const { batchFirestore } = require("../../helpers/firebase"); const { batchFirestore } = require('../../helpers/firebase')
const { BATCH_TYPE } = require("../../config/types"); const { BATCH_TYPE } = require('../../config/types')
const { const { admin, refsList, computeNextGrantTimestamp, getServerTimestamp } = require('./shared')
admin, const { ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants')
refsList,
computeNextGrantTimestamp,
getServerTimestamp,
} = require("./shared");
const { ACTIVE_SUBSCRIPTION_STATUSES } = require("./constants");
// Toggle to stop monthly grants for annual subscriptions while keeping logic handy. // 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( const processAnnualSubscriptionAllowances = onSchedule(
{ {
schedule: "30 3 * * *", schedule: '30 3 * * *',
timeZone: "Europe/Paris", timeZone: 'Europe/Paris',
}, },
async () => { async () => {
if (!ENABLE_ANNUAL_GRANT_SCHEDULER) { if (!ENABLE_ANNUAL_GRANT_SCHEDULER) {
console.log( console.log('[subscription-processAnnualSubscriptionAllowances] skipped (disabled)')
"[subscription-processAnnualSubscriptionAllowances] skipped (disabled)", return
);
return;
} }
const nowTimestamp = admin.firestore.Timestamp.now(); const nowTimestamp = admin.firestore.Timestamp.now()
const pageSize = 200; const pageSize = 200
let lastDoc = null; let lastDoc = null
let processedUsers = 0; let processedUsers = 0
let grantsCreated = 0; let grantsCreated = 0
let docsToUpdate = []; let docsToUpdate = []
const flushUpdates = async () => { const flushUpdates = async () => {
if (!docsToUpdate.length) { if (!docsToUpdate.length) {
return; return
} }
await batchFirestore({ await batchFirestore({
docs: docsToUpdate, docs: docsToUpdate,
type: BATCH_TYPE.UPDATE, type: BATCH_TYPE.UPDATE,
}); })
docsToUpdate = []; docsToUpdate = []
}; }
try { try {
while (true) { while (true) {
let query = refsList.users let query = refsList.users
.where("premiumBillingPeriod", "==", "annual") .where('premiumBillingPeriod', '==', 'annual')
.where("subscriptionGrantInterval", "==", "monthly") .where('subscriptionGrantInterval', '==', 'monthly')
.where("subscriptionNextGrantAt", "<=", nowTimestamp) .where('subscriptionNextGrantAt', '<=', nowTimestamp)
.orderBy("subscriptionNextGrantAt") .orderBy('subscriptionNextGrantAt')
.limit(pageSize); .limit(pageSize)
if (lastDoc) { if (lastDoc) {
query = query.startAfter(lastDoc); query = query.startAfter(lastDoc)
} }
const snapshot = await query.get(); const snapshot = await query.get()
if (snapshot.empty) { if (snapshot.empty) {
break; break
} }
for (const doc of snapshot.docs) { for (const doc of snapshot.docs) {
processedUsers += 1; processedUsers += 1
const data = doc.data() || {}; const data = doc.data() || {}
const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0); const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0)
if (!Number.isFinite(coinsPerMonth) || coinsPerMonth <= 0) { if (!Number.isFinite(coinsPerMonth) || coinsPerMonth <= 0) {
continue; continue
} }
const status = const status =
typeof data.stripeSubscriptionStatus === "string" typeof data.stripeSubscriptionStatus === 'string'
? data.stripeSubscriptionStatus.toLowerCase() ? data.stripeSubscriptionStatus.toLowerCase()
: null; : null
if (status && !ACTIVE_SUBSCRIPTION_STATUSES.has(status)) { if (status && !ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
continue; continue
} }
const nextGrantAt = data.subscriptionNextGrantAt; const nextGrantAt = data.subscriptionNextGrantAt
if (!nextGrantAt || typeof nextGrantAt.toDate !== "function") { if (!nextGrantAt || typeof nextGrantAt.toDate !== 'function') {
continue; continue
} }
const nextGrantDate = nextGrantAt.toDate(); const nextGrantDate = nextGrantAt.toDate()
if (!nextGrantDate || nextGrantDate > new Date()) { if (!nextGrantDate || nextGrantDate > new Date()) {
continue; continue
} }
const subscriptionInfo = const subscriptionInfo =
data.stripeSubscription && data.stripeSubscription && typeof data.stripeSubscription === 'object'
typeof data.stripeSubscription === "object"
? data.stripeSubscription ? data.stripeSubscription
: {}; : {}
const subscriptionId = const subscriptionId = subscriptionInfo.id || data.stripeSubscriptionId || null
subscriptionInfo.id || data.stripeSubscriptionId || null;
const orderId = subscriptionId const orderId = subscriptionId
? `subscription_${subscriptionId}_sched_${nextGrantAt.seconds}` ? `subscription_${subscriptionId}_sched_${nextGrantAt.seconds}`
: `subscription_${doc.id}_sched_${nextGrantAt.seconds}`; : `subscription_${doc.id}_sched_${nextGrantAt.seconds}`
try { try {
const { orderId: processedOrderId } = await createOrderDocument({ const { orderId: processedOrderId } = await createOrderDocument({
@@ -108,25 +99,25 @@ const processAnnualSubscriptionAllowances = onSchedule(
type: ORDER_TYPES.SUBSCRIPTION, type: ORDER_TYPES.SUBSCRIPTION,
amount: coinsPerMonth, amount: coinsPerMonth,
metadata: { metadata: {
source: "STRIPE_SUBSCRIPTION", source: 'STRIPE_SUBSCRIPTION',
schedule: "annual_scheduler", schedule: 'annual_scheduler',
subscriptionId, subscriptionId,
scheduledGrantAt: nextGrantDate.toISOString(), scheduledGrantAt: nextGrantDate.toISOString(),
}, },
orderId, orderId,
}); })
let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1); let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1)
const currentPeriodEnd = subscriptionInfo.currentPeriodEnd; const currentPeriodEnd = subscriptionInfo.currentPeriodEnd
if ( if (
nextGrantTimestamp && nextGrantTimestamp &&
currentPeriodEnd && currentPeriodEnd &&
typeof currentPeriodEnd.toDate === "function" typeof currentPeriodEnd.toDate === 'function'
) { ) {
const periodEndDate = currentPeriodEnd.toDate(); const periodEndDate = currentPeriodEnd.toDate()
const nextGrantFutureDate = nextGrantTimestamp.toDate(); const nextGrantFutureDate = nextGrantTimestamp.toDate()
if (periodEndDate && nextGrantFutureDate > periodEndDate) { if (periodEndDate && nextGrantFutureDate > periodEndDate) {
nextGrantTimestamp = null; nextGrantTimestamp = null
} }
} }
@@ -136,53 +127,47 @@ const processAnnualSubscriptionAllowances = onSchedule(
subscriptionLastGrantAt: getServerTimestamp(), subscriptionLastGrantAt: getServerTimestamp(),
subscriptionLastGrantAmount: coinsPerMonth, subscriptionLastGrantAmount: coinsPerMonth,
subscriptionLastGrantOrderId: processedOrderId, subscriptionLastGrantOrderId: processedOrderId,
subscriptionLastGrantSource: "annual_scheduler", subscriptionLastGrantSource: 'annual_scheduler',
subscriptionNextGrantAt: nextGrantTimestamp || null, subscriptionNextGrantAt: nextGrantTimestamp || null,
subscriptionGrantInterval: nextGrantTimestamp ? "monthly" : null, subscriptionGrantInterval: nextGrantTimestamp ? 'monthly' : null,
}, },
}); })
grantsCreated += 1; grantsCreated += 1
if (docsToUpdate.length >= 450) { if (docsToUpdate.length >= 450) {
await flushUpdates(); await flushUpdates()
} }
} catch (error) { } catch (error) {
console.error( console.error(
"[subscription-processAnnualSubscriptionAllowances] Unable to create order", '[subscription-processAnnualSubscriptionAllowances] Unable to create order',
{ {
userId: doc.id, userId: doc.id,
subscriptionId, subscriptionId,
error: error?.message || error, error: error?.message || error,
}, }
); )
} }
} }
lastDoc = snapshot.docs[snapshot.docs.length - 1]; lastDoc = snapshot.docs[snapshot.docs.length - 1]
if (snapshot.size < pageSize) { if (snapshot.size < pageSize) {
break; break
} }
} }
await flushUpdates(); await flushUpdates()
console.log( console.log('[subscription-processAnnualSubscriptionAllowances] completed', {
"[subscription-processAnnualSubscriptionAllowances] completed", processedUsers,
{ grantsCreated,
processedUsers, })
grantsCreated,
},
);
} catch (error) { } catch (error) {
console.error( console.error('[subscription-processAnnualSubscriptionAllowances] error', error)
"[subscription-processAnnualSubscriptionAllowances] error", throw error
error,
);
throw error;
} }
}, }
); )
module.exports = { module.exports = {
processAnnualSubscriptionAllowances, processAnnualSubscriptionAllowances,
}; }
+148 -171
View File
@@ -1,242 +1,226 @@
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const { FieldValue } = require("firebase-admin/firestore"); const { FieldValue } = require('firebase-admin/firestore')
const { refList } = require("../../index"); const { refList } = require('../../index')
const { STRIPE_WEBHOOK_SECRET } = require("../../config/keys"); const { STRIPE_WEBHOOK_SECRET } = require('../../config/keys')
const { const {
SUBSCRIPTION_LEVEL_ALLOWANCES, SUBSCRIPTION_LEVEL_ALLOWANCES,
SUBSCRIPTION_PRICE_METADATA, SUBSCRIPTION_PRICE_METADATA,
COIN_PACK_PRODUCT_MAP, COIN_PACK_PRODUCT_MAP,
} = require("./constants"); } = require('./constants')
const refsList = refList; const refsList = refList
const paymentsCollection = admin.firestore().collection("payments"); const paymentsCollection = admin.firestore().collection('payments')
let cachedStripeWebhookSecret = null; let cachedStripeWebhookSecret = null
const toFiniteNumber = (value) => { const toFiniteNumber = (value) => {
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
return value; return value
} }
if (typeof value === "string") { if (typeof value === 'string') {
const normalized = value.trim().replace(",", "."); const normalized = value.trim().replace(',', '.')
if (!normalized) { if (!normalized) {
return null; return null
} }
const parsed = Number(normalized); const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null; return Number.isFinite(parsed) ? parsed : null
} }
return null; return null
}; }
const parseCoinsPerMonth = (metadata) => { const parseCoinsPerMonth = (metadata) => {
if (!metadata || typeof metadata !== "object") { if (!metadata || typeof metadata !== 'object') {
return null; return null
} }
if (!Object.prototype.hasOwnProperty.call(metadata, "coinsPerMonth")) { if (!Object.prototype.hasOwnProperty.call(metadata, 'coinsPerMonth')) {
return null; return null
} }
const candidateValue = toFiniteNumber(metadata.coinsPerMonth); const candidateValue = toFiniteNumber(metadata.coinsPerMonth)
if (candidateValue === null || candidateValue <= 0) { if (candidateValue === null || candidateValue <= 0) {
return null; return null
} }
return Math.round(candidateValue); return Math.round(candidateValue)
}; }
const parseCoinAmount = (metadata) => { const parseCoinAmount = (metadata) => {
if (!metadata || typeof metadata !== "object") { if (!metadata || typeof metadata !== 'object') {
return null; return null
} }
if (!Object.prototype.hasOwnProperty.call(metadata, "coins")) { if (!Object.prototype.hasOwnProperty.call(metadata, 'coins')) {
return null; return null
} }
const candidateValue = toFiniteNumber(metadata.coins); const candidateValue = toFiniteNumber(metadata.coins)
if (candidateValue === null || candidateValue <= 0) { if (candidateValue === null || candidateValue <= 0) {
return null; return null
} }
return Math.round(candidateValue); return Math.round(candidateValue)
}; }
const toDateSafe = (value) => { const toDateSafe = (value) => {
if (!value) { if (!value) {
return null; return null
} }
if (value instanceof Date) { if (value instanceof Date) {
return value; return value
} }
if (typeof value?.toDate === "function") { if (typeof value?.toDate === 'function') {
try { try {
return value.toDate(); return value.toDate()
} catch (_error) { } catch (_error) {
return null; return null
} }
} }
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
if (value > 1e12) { 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) => { const addMonths = (date, months = 1) => {
if (!(date instanceof Date) || !Number.isFinite(months)) { if (!(date instanceof Date) || !Number.isFinite(months)) {
return null; return null
} }
const result = new Date(date.getTime()); const result = new Date(date.getTime())
const initialDay = result.getDate(); const initialDay = result.getDate()
result.setMonth(result.getMonth() + months); result.setMonth(result.getMonth() + months)
if (result.getDate() !== initialDay) { if (result.getDate() !== initialDay) {
result.setDate(0); result.setDate(0)
} }
return result; return result
}; }
const computeNextGrantTimestamp = (base, months = 1) => { const computeNextGrantTimestamp = (base, months = 1) => {
const baseDate = toDateSafe(base); const baseDate = toDateSafe(base)
if (!baseDate) { if (!baseDate) {
return null; return null
} }
const nextDate = addMonths(baseDate, months); const nextDate = addMonths(baseDate, months)
if (!nextDate) { if (!nextDate) {
return null; return null
} }
return admin.firestore.Timestamp.fromDate(nextDate); return admin.firestore.Timestamp.fromDate(nextDate)
}; }
const getServerTimestamp = () => { const getServerTimestamp = () => {
if (typeof FieldValue?.serverTimestamp === "function") { if (typeof FieldValue?.serverTimestamp === 'function') {
return FieldValue.serverTimestamp(); return FieldValue.serverTimestamp()
} }
const fallback = admin.firestore?.FieldValue; const fallback = admin.firestore?.FieldValue
if (typeof fallback?.serverTimestamp === "function") { if (typeof fallback?.serverTimestamp === 'function') {
return fallback.serverTimestamp(); return fallback.serverTimestamp()
} }
throw new Error("Firestore FieldValue.serverTimestamp indisponible."); throw new Error('Firestore FieldValue.serverTimestamp indisponible.')
}; }
const resolveStripeWebhookSecret = () => { const resolveStripeWebhookSecret = () => {
if (cachedStripeWebhookSecret) { if (cachedStripeWebhookSecret) {
return cachedStripeWebhookSecret; return cachedStripeWebhookSecret
} }
const envSecret = const envSecret =
typeof process?.env?.STRIPE_WEBHOOK_SECRET === "string" typeof process?.env?.STRIPE_WEBHOOK_SECRET === 'string'
? process.env.STRIPE_WEBHOOK_SECRET.trim() ? process.env.STRIPE_WEBHOOK_SECRET.trim()
: ""; : ''
const inlineSecret = const inlineSecret = typeof STRIPE_WEBHOOK_SECRET === 'string' ? STRIPE_WEBHOOK_SECRET.trim() : ''
typeof STRIPE_WEBHOOK_SECRET === "string"
? STRIPE_WEBHOOK_SECRET.trim()
: "";
const secret = envSecret || inlineSecret; const secret = envSecret || inlineSecret
if (!secret) { if (!secret) {
throw new Error("STRIPE_WEBHOOK_SECRET not configured"); throw new Error('STRIPE_WEBHOOK_SECRET not configured')
} }
cachedStripeWebhookSecret = secret; cachedStripeWebhookSecret = secret
return cachedStripeWebhookSecret; return cachedStripeWebhookSecret
}; }
const toFirestoreTimestamp = (unixSeconds) => { const toFirestoreTimestamp = (unixSeconds) => {
if (typeof unixSeconds !== "number" || !Number.isFinite(unixSeconds)) { if (typeof unixSeconds !== 'number' || !Number.isFinite(unixSeconds)) {
return null; return null
} }
try { try {
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000); return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000)
} catch (error) { } catch (error) {
console.error("[subscription-toFirestoreTimestamp] Conversion error", unixSeconds, error); console.error('[subscription-toFirestoreTimestamp] Conversion error', unixSeconds, error)
return null; return null
} }
}; }
const extractFirebaseUid = (metadata) => { const extractFirebaseUid = (metadata) => {
if (!metadata || typeof metadata !== "object") { if (!metadata || typeof metadata !== 'object') {
return null; return null
} }
const candidates = [ const candidates = [metadata.firebaseUID, metadata.firebaseUid, metadata.uid, metadata.userId]
metadata.firebaseUID,
metadata.firebaseUid,
metadata.uid,
metadata.userId,
];
for (let index = 0; index < candidates.length; index += 1) { for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index]; const candidate = candidates[index]
if (typeof candidate === "string" && candidate.trim()) { if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim(); return candidate.trim()
} }
} }
return null; return null
}; }
const formatCoinPack = ({ product, price }) => { const formatCoinPack = ({ product, price }) => {
if (!product || typeof product !== "object") { if (!product || typeof product !== 'object') {
return null; return null
} }
const resolvedPrice = const resolvedPrice =
price || price || (typeof product.default_price === 'object' && product.default_price) || null
(typeof product.default_price === "object" && product.default_price) ||
null;
const priceId = const priceId =
(resolvedPrice && resolvedPrice.id) || (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) { if (coinAmount === null) {
throw new Error( throw new Error(`[formatCoinPack] Missing metadata.coins on product ${product.id}`)
`[formatCoinPack] Missing metadata.coins on product ${product.id}`,
);
} }
return { return {
productId: product.id, productId: product.id,
priceId, priceId,
name: product.name || "", name: product.name || '',
description: product.description || "", description: product.description || '',
coinAmount, coinAmount,
currency: currency:
resolvedPrice?.currency || resolvedPrice?.currency ||
(typeof resolvedPrice?.currency === "string" (typeof resolvedPrice?.currency === 'string' ? resolvedPrice.currency.toLowerCase() : 'eur'),
? resolvedPrice.currency.toLowerCase()
: "eur"),
unitAmount: resolvedPrice?.unit_amount ?? null, unitAmount: resolvedPrice?.unit_amount ?? null,
metadata: product.metadata || {}, metadata: product.metadata || {},
}; }
}; }
const getSubscriptionMetaFromPrice = (priceId) => { const getSubscriptionMetaFromPrice = (priceId) => {
if (typeof priceId !== "string") { if (typeof priceId !== 'string') {
return null; return null
} }
return SUBSCRIPTION_PRICE_METADATA[priceId] || null; return SUBSCRIPTION_PRICE_METADATA[priceId] || null
}; }
const buildEventSnapshot = (eventType, entityId) => { const buildEventSnapshot = (eventType, entityId) => {
const now = admin.firestore.Timestamp.now(); const now = admin.firestore.Timestamp.now()
return { return {
eventType: eventType || null, eventType: eventType || null,
entityId: entityId || null, entityId: entityId || null,
syncedAt: now, syncedAt: now,
}; }
}; }
const formatSubscriptionForClient = (subscription) => { const formatSubscriptionForClient = (subscription) => {
if (!subscription || typeof subscription !== "object") { if (!subscription || typeof subscription !== 'object') {
return null; return null
} }
return { return {
id: subscription.id, id: subscription.id,
status: subscription.status, status: subscription.status,
customer: subscription.customer, customer: subscription.customer,
currentPeriodStart: toFirestoreTimestamp( currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
subscription.current_period_start,
),
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end), currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
cancelAtPeriodEnd: subscription.cancel_at_period_end === true, cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
created: toFirestoreTimestamp(subscription.created), created: toFirestoreTimestamp(subscription.created),
@@ -248,12 +232,12 @@ const formatSubscriptionForClient = (subscription) => {
})) }))
: [], : [],
metadata: subscription.metadata || {}, metadata: subscription.metadata || {},
}; }
}; }
const buildSubscriptionPayload = (subscription) => { const buildSubscriptionPayload = (subscription) => {
if (!subscription || typeof subscription !== "object") { if (!subscription || typeof subscription !== 'object') {
return null; return null
} }
const items = Array.isArray(subscription.items?.data) const items = Array.isArray(subscription.items?.data)
@@ -264,20 +248,19 @@ const buildSubscriptionPayload = (subscription) => {
quantity: item.quantity || 0, quantity: item.quantity || 0,
price: item.price || null, price: item.price || null,
})) }))
: []; : []
const primaryItem = items[0] || null; const primaryItem = items[0] || null
const productId = primaryItem?.productId || null; const productId = primaryItem?.productId || null
const priceId = primaryItem?.priceId || null; const priceId = primaryItem?.priceId || null
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}; const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
const metadataLevel = subscription.metadata?.subscriptionLevel || null; const metadataLevel = subscription.metadata?.subscriptionLevel || null
const resolvedLevel = metadataLevel || priceMeta?.level || null; const resolvedLevel = metadataLevel || priceMeta?.level || null
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null; const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null; const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null
const customerId = const customerId = typeof subscription.customer === 'string' ? subscription.customer : null
typeof subscription.customer === "string" ? subscription.customer : null;
return { return {
id: subscription.id, id: subscription.id,
@@ -289,34 +272,32 @@ const buildSubscriptionPayload = (subscription) => {
billingPeriod: resolvedPeriod, billingPeriod: resolvedPeriod,
status: subscription.status || null, status: subscription.status || null,
cancelAtPeriodEnd: subscription.cancel_at_period_end === true, cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
currentPeriodStart: toFirestoreTimestamp( currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
subscription.current_period_start,
),
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end), currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
}; }
}; }
const resolveUserContext = async ({ metadata, customerId }) => { const resolveUserContext = async ({ metadata, customerId }) => {
const firebaseUid = extractFirebaseUid(metadata); const firebaseUid = extractFirebaseUid(metadata)
if (firebaseUid) { if (firebaseUid) {
const userRef = refsList?.users?.doc(firebaseUid) || null; const userRef = refsList?.users?.doc(firebaseUid) || null
if (userRef) { if (userRef) {
try { try {
const snapshot = await userRef.get(); const snapshot = await userRef.get()
if (snapshot.exists) { if (snapshot.exists) {
return { return {
uid: firebaseUid, uid: firebaseUid,
userRef, userRef,
userData: snapshot.data() || null, userData: snapshot.data() || null,
}; }
} }
} catch (error) { } catch (error) {
console.warn( console.warn(
"[subscription-resolveUserContext] Unable to read user", '[subscription-resolveUserContext] Unable to read user',
firebaseUid, firebaseUid,
error?.message || error, error?.message || error
); )
} }
} }
} }
@@ -324,23 +305,23 @@ const resolveUserContext = async ({ metadata, customerId }) => {
if (customerId) { if (customerId) {
try { try {
const snapshot = await refsList.users const snapshot = await refsList.users
.where("stripeCustomerId", "==", customerId) .where('stripeCustomerId', '==', customerId)
.limit(1) .limit(1)
.get(); .get()
if (!snapshot.empty) { if (!snapshot.empty) {
const doc = snapshot.docs[0]; const doc = snapshot.docs[0]
return { return {
uid: doc.id, uid: doc.id,
userRef: doc.ref, userRef: doc.ref,
userData: doc.data() || null, userData: doc.data() || null,
}; }
} }
} catch (error) { } catch (error) {
console.warn( console.warn(
"[subscription-resolveUserContext] Unable to query by customer", '[subscription-resolveUserContext] Unable to query by customer',
customerId, customerId,
error?.message || error, error?.message || error
); )
} }
} }
@@ -348,15 +329,15 @@ const resolveUserContext = async ({ metadata, customerId }) => {
uid: firebaseUid || null, uid: firebaseUid || null,
userRef: null, userRef: null,
userData: null, userData: null,
}; }
}; }
const upsertPaymentDocument = async (docId, data = {}) => { const upsertPaymentDocument = async (docId, data = {}) => {
if (!docId) { if (!docId) {
return null; return null
} }
const docRef = paymentsCollection.doc(docId); const docRef = paymentsCollection.doc(docId)
try { try {
await docRef.set( await docRef.set(
{ {
@@ -364,17 +345,13 @@ const upsertPaymentDocument = async (docId, data = {}) => {
updatedAt: getServerTimestamp(), updatedAt: getServerTimestamp(),
createdAt: getServerTimestamp(), createdAt: getServerTimestamp(),
}, },
{ merge: true }, { merge: true }
); )
} catch (error) { } catch (error) {
console.error( console.error('[subscription-upsertPaymentDocument] Failed to persist payment', docId, error)
"[subscription-upsertPaymentDocument] Failed to persist payment",
docId,
error,
);
} }
return docRef; return docRef
}; }
module.exports = { module.exports = {
admin, admin,
@@ -398,4 +375,4 @@ module.exports = {
resolveUserContext, resolveUserContext,
upsertPaymentDocument, upsertPaymentDocument,
COIN_PACK_PRODUCT_MAP, COIN_PACK_PRODUCT_MAP,
}; }
+221 -280
View File
@@ -1,9 +1,9 @@
const { onRequest } = require("firebase-functions/v2/https"); const { onRequest } = require('firebase-functions/v2/https')
const { HttpsError } = require("firebase-functions/https"); const { HttpsError } = require('firebase-functions/https')
const { ORDER_TYPES, createOrderDocument } = require("../helpers/orders"); const { ORDER_TYPES, createOrderDocument } = require('../helpers/orders')
const { getStripeClient } = require("../../helpers/stripe"); const { getStripeClient } = require('../../helpers/stripe')
const { REGION } = require("./config"); const { REGION } = require('./config')
const { const {
paymentsCollection, paymentsCollection,
resolveStripeWebhookSecret, resolveStripeWebhookSecret,
@@ -17,25 +17,21 @@ const {
buildSubscriptionPayload, buildSubscriptionPayload,
resolveUserContext, resolveUserContext,
upsertPaymentDocument, upsertPaymentDocument,
} = require("./shared"); } = require('./shared')
const { PREMIUM_SUBSCRIPTION_STATUSES } = require("./constants"); const { PREMIUM_SUBSCRIPTION_STATUSES } = require('./constants')
const handleCheckoutSessionCompleted = async ( const handleCheckoutSessionCompleted = async (session, event, { stripe } = {}) => {
session, if (!session || typeof session !== 'object') {
event, return
{ stripe } = {},
) => {
if (!session || typeof session !== "object") {
return;
} }
const firebaseUid = extractFirebaseUid(session.metadata); const firebaseUid = extractFirebaseUid(session.metadata)
const paymentDocRef = await upsertPaymentDocument(session.id, { const paymentDocRef = await upsertPaymentDocument(session.id, {
userId: firebaseUid || null, userId: firebaseUid || null,
customerId: session.customer || null, customerId: session.customer || null,
subscriptionId: session.subscription || null, subscriptionId: session.subscription || null,
invoiceId: session.invoice || null, invoiceId: session.invoice || null,
status: session.status || "completed", status: session.status || 'completed',
paymentStatus: session.payment_status || null, paymentStatus: session.payment_status || null,
mode: session.mode || null, mode: session.mode || null,
amountSubtotal: session.amount_subtotal ?? null, amountSubtotal: session.amount_subtotal ?? null,
@@ -44,109 +40,103 @@ const handleCheckoutSessionCompleted = async (
metadata: session.metadata || {}, metadata: session.metadata || {},
completedAt: toFirestoreTimestamp(session.created), completedAt: toFirestoreTimestamp(session.created),
expiresAt: toFirestoreTimestamp(session.expires_at), expiresAt: toFirestoreTimestamp(session.expires_at),
paymentIntentId: paymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : null,
typeof session.payment_intent === "string"
? session.payment_intent
: null,
lastEventType: event?.type || null, lastEventType: event?.type || null,
lastEventId: event?.id || null, lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(), lastEventAt: getServerTimestamp(),
}); })
const { uid, userRef } = await resolveUserContext({ const { uid, userRef } = await resolveUserContext({
metadata: session.metadata, metadata: session.metadata,
customerId: session.customer, customerId: session.customer,
}); })
if (userRef) { if (userRef) {
const lastEvent = buildEventSnapshot(event?.type, session.id); const lastEvent = buildEventSnapshot(event?.type, session.id)
if (firebaseUid) { if (firebaseUid) {
lastEvent.uid = firebaseUid; lastEvent.uid = firebaseUid
} }
const userUpdate = { const userUpdate = {
lastStripeWebhookEvent: lastEvent, lastStripeWebhookEvent: lastEvent,
}; }
if (session.customer) { if (session.customer) {
userUpdate.stripeCustomerId = session.customer; userUpdate.stripeCustomerId = session.customer
} }
if (session.metadata?.subscriptionLevel) { if (session.metadata?.subscriptionLevel) {
userUpdate.premiumLevel = session.metadata.subscriptionLevel; userUpdate.premiumLevel = session.metadata.subscriptionLevel
} }
if (session.metadata?.subscriptionBillingPeriod) { if (session.metadata?.subscriptionBillingPeriod) {
userUpdate.premiumBillingPeriod = userUpdate.premiumBillingPeriod = session.metadata.subscriptionBillingPeriod
session.metadata.subscriptionBillingPeriod;
} }
await userRef.set(userUpdate, { merge: true }); await userRef.set(userUpdate, { merge: true })
} }
if ( if (
stripe && stripe &&
session.mode === "subscription" && session.mode === 'subscription' &&
typeof session.subscription === "string" && typeof session.subscription === 'string' &&
session.subscription session.subscription
) { ) {
try { try {
const subscription = await stripe.subscriptions.retrieve( const subscription = await stripe.subscriptions.retrieve(session.subscription, {
session.subscription, expand: ['items.data.price.product'],
{ expand: ["items.data.price.product"] }, })
);
if (subscription) { if (subscription) {
await handleCustomerSubscriptionEvent(subscription, event, { stripe }); await handleCustomerSubscriptionEvent(subscription, event, { stripe })
} }
} catch (error) { } catch (error) {
console.error( console.error(
"[subscription-handleCheckoutSessionCompleted] Unable to sync subscription", '[subscription-handleCheckoutSessionCompleted] Unable to sync subscription',
session.subscription, session.subscription,
error, error
); )
} }
} }
if ( if (
session.mode === "payment" && session.mode === 'payment' &&
(session.payment_status === "paid" || (session.payment_status === 'paid' || session.payment_status === 'no_payment_required') &&
session.payment_status === "no_payment_required") && session.metadata?.purchaseType === 'COIN_PACK' &&
session.metadata?.purchaseType === "COIN_PACK" &&
userRef userRef
) { ) {
const coinAmountRaw = Number(session.metadata?.coinAmount || 0); const coinAmountRaw = Number(session.metadata?.coinAmount || 0)
const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0; const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0
if (coinAmount > 0 && paymentDocRef) { if (coinAmount > 0 && paymentDocRef) {
let paymentSnapshot = null; let paymentSnapshot = null
try { try {
paymentSnapshot = await paymentDocRef.get(); paymentSnapshot = await paymentDocRef.get()
} catch (error) { } catch (error) {
console.warn( console.warn(
"[subscription-handleCheckoutSessionCompleted] Unable to read payment doc", '[subscription-handleCheckoutSessionCompleted] Unable to read payment doc',
session.id, session.id,
error?.message || error, error?.message || error
); )
} }
const alreadyGranted = Boolean( const alreadyGranted = Boolean(
paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt, paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt
); )
if (!alreadyGranted) { if (!alreadyGranted) {
const targetUserId = uid || firebaseUid || userRef.id; const targetUserId = uid || firebaseUid || userRef.id
await createOrderDocument({ await createOrderDocument({
userId: targetUserId, userId: targetUserId,
type: ORDER_TYPES.COINS, type: ORDER_TYPES.COINS,
amount: coinAmount, amount: coinAmount,
metadata: { metadata: {
source: "STRIPE_CHECKOUT", source: 'STRIPE_CHECKOUT',
paymentId: session.id || null, paymentId: session.id || null,
coinPackKey: session.metadata?.coinPackKey || null, coinPackKey: session.metadata?.coinPackKey || null,
}, },
orderId: `stripe_${session.id}`, orderId: `stripe_${session.id}`,
}); })
await paymentDocRef.set( await paymentDocRef.set(
{ {
@@ -154,25 +144,21 @@ const handleCheckoutSessionCompleted = async (
coinPackGrantedAmount: coinAmount, coinPackGrantedAmount: coinAmount,
coinPackGrantedKey: session.metadata?.coinPackKey || null, coinPackGrantedKey: session.metadata?.coinPackKey || null,
}, },
{ merge: true }, { merge: true }
); )
} }
} }
} }
}; }
const handleCustomerSubscriptionEvent = async ( const handleCustomerSubscriptionEvent = async (subscription, event, { stripe } = {}) => {
subscription, if (!subscription || typeof subscription !== 'object') {
event, return
{ stripe } = {},
) => {
if (!subscription || typeof subscription !== "object") {
return;
} }
const subscriptionPayload = buildSubscriptionPayload(subscription); const subscriptionPayload = buildSubscriptionPayload(subscription)
if (!subscriptionPayload) { if (!subscriptionPayload) {
return; return
} }
const { const {
@@ -182,36 +168,36 @@ const handleCustomerSubscriptionEvent = async (
} = await resolveUserContext({ } = await resolveUserContext({
metadata: subscription.metadata, metadata: subscription.metadata,
customerId: subscription.customer, customerId: subscription.customer,
}); })
let userData = resolvedUserData || null; let userData = resolvedUserData || null
if (!userData && userRef) { if (!userData && userRef) {
try { try {
const snapshot = await userRef.get(); const snapshot = await userRef.get()
userData = snapshot.exists ? snapshot.data() || null : null; userData = snapshot.exists ? snapshot.data() || null : null
} catch (error) { } catch (error) {
console.warn( console.warn(
"[subscription-handleCustomerSubscriptionEvent] Unable to read user", '[subscription-handleCustomerSubscriptionEvent] Unable to read user',
subscription.customer, subscription.customer,
error?.message || error, error?.message || error
); )
} }
} }
let resolvedCustomerId = subscriptionPayload?.customerId || null; let resolvedCustomerId = subscriptionPayload?.customerId || null
if (!resolvedCustomerId && subscription.customer) { if (!resolvedCustomerId && subscription.customer) {
resolvedCustomerId = subscription.customer; resolvedCustomerId = subscription.customer
} }
const fallbackUid = extractFirebaseUid(subscription.metadata); const fallbackUid = extractFirebaseUid(subscription.metadata)
const resolvedUid = uid || fallbackUid || null; const resolvedUid = uid || fallbackUid || null
await upsertPaymentDocument(subscription.id, { await upsertPaymentDocument(subscription.id, {
userId: resolvedUid, userId: resolvedUid,
customerId: resolvedCustomerId, customerId: resolvedCustomerId,
subscriptionId: subscription.id || null, subscriptionId: subscription.id || null,
status: subscription.status || null, status: subscription.status || null,
mode: "subscription", mode: 'subscription',
priceId: subscriptionPayload?.priceId || null, priceId: subscriptionPayload?.priceId || null,
productId: subscriptionPayload?.productId || null, productId: subscriptionPayload?.productId || null,
cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null, cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null,
@@ -224,76 +210,66 @@ const handleCustomerSubscriptionEvent = async (
lastEventType: event?.type || null, lastEventType: event?.type || null,
lastEventId: event?.id || null, lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(), lastEventAt: getServerTimestamp(),
}); })
if (!userRef) { if (!userRef) {
console.warn( console.warn('[subscription-handleCustomerSubscriptionEvent] User not resolved', {
"[subscription-handleCustomerSubscriptionEvent] User not resolved", subscriptionId: subscription?.id || null,
{ customerId: subscription?.customer || null,
subscriptionId: subscription?.id || null, metadataKeys: Object.keys(subscription?.metadata || {}),
customerId: subscription?.customer || null, eventType: event?.type || null,
metadataKeys: Object.keys(subscription?.metadata || {}), })
eventType: event?.type || null, return
},
);
return;
} }
const lastEvent = buildEventSnapshot(event?.type, subscription.id); const lastEvent = buildEventSnapshot(event?.type, subscription.id)
if (resolvedUid) { if (resolvedUid) {
lastEvent.uid = resolvedUid; lastEvent.uid = resolvedUid
} }
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId); const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId)
const metadataLevel = subscription.metadata?.subscriptionLevel || null; const metadataLevel = subscription.metadata?.subscriptionLevel || null
const metadataPeriod = const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null
subscription.metadata?.subscriptionBillingPeriod || null; const resolvedLevel = metadataLevel || priceMeta?.level || null
const resolvedLevel = metadataLevel || priceMeta?.level || null; const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
const isPremium = subscription.status const isPremium = subscription.status
? PREMIUM_SUBSCRIPTION_STATUSES.has(subscription.status) ? PREMIUM_SUBSCRIPTION_STATUSES.has(subscription.status)
: false; : false
const primaryItem = Array.isArray(subscriptionPayload?.items) const primaryItem = Array.isArray(subscriptionPayload?.items)
? subscriptionPayload.items[0] ? subscriptionPayload.items[0]
: null; : null
let coinsPerMonth = null; let coinsPerMonth = null
const productMetadata = const productMetadata =
primaryItem?.price && primaryItem?.price &&
typeof primaryItem.price === "object" && typeof primaryItem.price === 'object' &&
primaryItem.price.product && primaryItem.price.product &&
typeof primaryItem.price.product === "object" typeof primaryItem.price.product === 'object'
? primaryItem.price.product.metadata ? primaryItem.price.product.metadata
: null; : null
coinsPerMonth = parseCoinsPerMonth(productMetadata || {}); coinsPerMonth = parseCoinsPerMonth(productMetadata || {})
if (coinsPerMonth === null && stripe && primaryItem?.price?.id) { if (coinsPerMonth === null && stripe && primaryItem?.price?.id) {
try { try {
const priceWithProduct = await stripe.prices.retrieve( const priceWithProduct = await stripe.prices.retrieve(primaryItem.price.id, {
primaryItem.price.id, expand: ['product'],
{ expand: ["product"] }, })
); coinsPerMonth = parseCoinsPerMonth(priceWithProduct?.product?.metadata || {})
coinsPerMonth = parseCoinsPerMonth(
priceWithProduct?.product?.metadata || {},
);
} catch (error) { } catch (error) {
console.warn( console.warn(
"[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata", '[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata',
primaryItem.price.id, primaryItem.price.id,
error?.message || error, error?.message || error
); )
} }
} }
let subscriptionNextGrantAt = null; let subscriptionNextGrantAt = null
if (coinsPerMonth !== null && subscriptionPayload?.currentPeriodEnd) { if (coinsPerMonth !== null && subscriptionPayload?.currentPeriodEnd) {
subscriptionNextGrantAt = computeNextGrantTimestamp( subscriptionNextGrantAt = computeNextGrantTimestamp(subscriptionPayload.currentPeriodEnd, 1)
subscriptionPayload.currentPeriodEnd,
1,
);
} }
const userUpdate = { const userUpdate = {
@@ -313,53 +289,46 @@ const handleCustomerSubscriptionEvent = async (
premiumLevel: isPremium ? resolvedLevel : null, premiumLevel: isPremium ? resolvedLevel : null,
premiumBillingPeriod: isPremium ? resolvedPeriod : null, premiumBillingPeriod: isPremium ? resolvedPeriod : null,
subscriptionNextGrantAt, subscriptionNextGrantAt,
}; }
if (!isPremium) { 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 } = {}) => { const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
if (!invoice || typeof invoice !== "object") { if (!invoice || typeof invoice !== 'object') {
return; return
} }
const firebaseUid = extractFirebaseUid(invoice.metadata); const firebaseUid = extractFirebaseUid(invoice.metadata)
const eventType = event?.type || null; const eventType = event?.type || null
const paymentDocRef = paymentsCollection.doc(invoice.id); const paymentDocRef = paymentsCollection.doc(invoice.id)
let resolvedSubscriptionId = let resolvedSubscriptionId =
typeof invoice.subscription === "string" && invoice.subscription typeof invoice.subscription === 'string' && invoice.subscription ? invoice.subscription : null
? invoice.subscription
: null;
if (!resolvedSubscriptionId) { if (!resolvedSubscriptionId) {
const lineSubscriptionId = Array.isArray(invoice?.lines?.data) const lineSubscriptionId = Array.isArray(invoice?.lines?.data)
? invoice.lines.data ? invoice.lines.data
.map((line) => .map((line) =>
typeof line?.subscription === "string" && line.subscription typeof line?.subscription === 'string' && line.subscription ? line.subscription : null
? line.subscription
: null,
) )
.find((value) => value) .find((value) => value)
: null; : null
if (lineSubscriptionId) { if (lineSubscriptionId) {
resolvedSubscriptionId = lineSubscriptionId; resolvedSubscriptionId = lineSubscriptionId
console.log( console.log('[subscription-handleInvoiceEvent] Subscription resolved from invoice line', {
"[subscription-handleInvoiceEvent] Subscription resolved from invoice line", invoiceId: invoice?.id || null,
{ subscriptionId: resolvedSubscriptionId,
invoiceId: invoice?.id || null, })
subscriptionId: resolvedSubscriptionId,
},
);
} }
} }
console.log("[subscription-handleInvoiceEvent] Received invoice webhook", { console.log('[subscription-handleInvoiceEvent] Received invoice webhook', {
eventType, eventType,
invoiceId: invoice?.id || null, invoiceId: invoice?.id || null,
subscriptionId: invoice?.subscription || null, subscriptionId: invoice?.subscription || null,
@@ -368,17 +337,14 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
status: invoice?.status || null, status: invoice?.status || null,
billingReason: invoice?.billing_reason || null, billingReason: invoice?.billing_reason || null,
attemptCount: invoice?.attempt_count ?? null, attemptCount: invoice?.attempt_count ?? null,
}); })
await upsertPaymentDocument(invoice.id, { await upsertPaymentDocument(invoice.id, {
userId: firebaseUid || null, userId: firebaseUid || null,
customerId: invoice.customer || null, customerId: invoice.customer || null,
subscriptionId: resolvedSubscriptionId, subscriptionId: resolvedSubscriptionId,
status: invoice.status || null, status: invoice.status || null,
paymentStatus: paymentStatus: eventType === 'invoice.payment_failed' ? 'failed' : invoice.status || null,
eventType === "invoice.payment_failed"
? "failed"
: invoice.status || null,
amountDue: invoice.amount_due ?? null, amountDue: invoice.amount_due ?? null,
amountPaid: invoice.amount_paid ?? null, amountPaid: invoice.amount_paid ?? null,
amountRemaining: invoice.amount_remaining ?? null, amountRemaining: invoice.amount_remaining ?? null,
@@ -394,8 +360,8 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
lastEventType: eventType, lastEventType: eventType,
lastEventId: event?.id || null, lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(), lastEventAt: getServerTimestamp(),
mode: "invoice", mode: 'invoice',
}); })
const { const {
uid, uid,
@@ -404,124 +370,109 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
} = await resolveUserContext({ } = await resolveUserContext({
metadata: invoice.metadata, metadata: invoice.metadata,
customerId: invoice.customer, customerId: invoice.customer,
}); })
if (!userRef) { if (!userRef) {
console.warn( console.warn('[subscription-handleInvoiceEvent] User context not resolved', {
"[subscription-handleInvoiceEvent] User context not resolved", invoiceId: invoice?.id || null,
{ customerId: invoice?.customer || null,
invoiceId: invoice?.id || null, firebaseUid: firebaseUid || null,
customerId: invoice?.customer || null, metadataKeys: Object.keys(invoice?.metadata || {}),
firebaseUid: firebaseUid || null, })
metadataKeys: Object.keys(invoice?.metadata || {}), return
},
);
return;
} }
let userData = resolvedUserData || null; let userData = resolvedUserData || null
if (!userData) { if (!userData) {
try { try {
const snapshot = await userRef.get(); const snapshot = await userRef.get()
userData = snapshot.exists ? snapshot.data() || null : null; userData = snapshot.exists ? snapshot.data() || null : null
} catch (error) { } catch (error) {
console.warn( console.warn(
"[subscription-handleInvoiceEvent] Unable to read user", '[subscription-handleInvoiceEvent] Unable to read user',
invoice.customer, invoice.customer,
error?.message || error, error?.message || error
); )
} }
} }
const lastEvent = buildEventSnapshot(eventType, invoice.id); const lastEvent = buildEventSnapshot(eventType, invoice.id)
if (firebaseUid) { if (firebaseUid) {
lastEvent.uid = firebaseUid; lastEvent.uid = firebaseUid
} }
const userUpdate = { const userUpdate = {
lastStripeWebhookEvent: lastEvent, lastStripeWebhookEvent: lastEvent,
}; }
const billingReason = invoice.billing_reason || null; const billingReason = invoice.billing_reason || null
const isInvoicePaid = invoice.status === "paid"; const isInvoicePaid = invoice.status === 'paid'
if ( if (billingReason === 'subscription_create' || billingReason === 'subscription_cycle') {
billingReason === "subscription_create" ||
billingReason === "subscription_cycle"
) {
if (isInvoicePaid && invoice.subscription) { 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) const subscriptionLine = Array.isArray(invoice?.lines?.data)
? invoice.lines.data.find( ? invoice.lines.data.find(
(line) => (line) => line && typeof line === 'object' && (line.type === 'subscription' || line.price)
line &&
typeof line === "object" &&
(line.type === "subscription" || line.price),
) )
: null; : null
const priceId = const priceId =
typeof subscriptionLine?.price?.id === "string" typeof subscriptionLine?.price?.id === 'string'
? subscriptionLine.price.id ? subscriptionLine.price.id
: typeof subscriptionLine?.price === "string" : typeof subscriptionLine?.price === 'string'
? subscriptionLine.price ? subscriptionLine.price
: null; : null
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}; const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
const billingPeriod = const billingPeriod =
priceMeta.billingPeriod || priceMeta.billingPeriod ||
(subscriptionLine?.price?.recurring?.interval === "year" (subscriptionLine?.price?.recurring?.interval === 'year'
? "annual" ? 'annual'
: subscriptionLine?.price?.recurring?.interval === "month" : subscriptionLine?.price?.recurring?.interval === 'month'
? "monthly" ? 'monthly'
: null); : null)
const coinsPerMonth = const coinsPerMonth =
priceMeta.coinsPerMonth ?? priceMeta.coinsPerMonth ??
parseCoinsPerMonth(subscriptionLine?.price?.product?.metadata || {}) ?? parseCoinsPerMonth(subscriptionLine?.price?.product?.metadata || {}) ??
null; null
const coinsToGrant = const coinsToGrant =
billingPeriod === "annual" && typeof coinsPerMonth === "number" billingPeriod === 'annual' && typeof coinsPerMonth === 'number' ? coinsPerMonth * 12 : null
? coinsPerMonth * 12
: null;
const targetSubscriptionId = const targetSubscriptionId =
resolvedSubscriptionId || resolvedSubscriptionId ||
(typeof invoice.subscription === "string" ? invoice.subscription : null) || (typeof invoice.subscription === 'string' ? invoice.subscription : null) ||
(typeof subscriptionLine?.subscription === "string" (typeof subscriptionLine?.subscription === 'string' ? subscriptionLine.subscription : null)
? subscriptionLine.subscription
: null);
const shouldGrantUpfront = const shouldGrantUpfront =
isInvoicePaid && isInvoicePaid &&
coinsToGrant && coinsToGrant &&
(billingReason === "subscription_create" || (billingReason === 'subscription_create' || billingReason === 'subscription_cycle')
billingReason === "subscription_cycle");
if (shouldGrantUpfront && targetSubscriptionId) { if (shouldGrantUpfront && targetSubscriptionId) {
let grantSnapshot = null; let grantSnapshot = null
try { try {
grantSnapshot = await paymentDocRef.get(); grantSnapshot = await paymentDocRef.get()
} catch (error) { } catch (error) {
console.warn( console.warn(
"[subscription-handleInvoiceEvent] Unable to read payment doc before grant", '[subscription-handleInvoiceEvent] Unable to read payment doc before grant',
invoice?.id || null, invoice?.id || null,
error?.message || error, error?.message || error
); )
} }
const alreadyGranted = const alreadyGranted =
grantSnapshot?.exists && grantSnapshot?.exists && Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt)
Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt);
if (!alreadyGranted) { if (!alreadyGranted) {
const targetUserId = uid || firebaseUid || userRef.id; const targetUserId = uid || firebaseUid || userRef.id
const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}`; const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}`
try { try {
const { orderId: processedOrderId } = await createOrderDocument({ const { orderId: processedOrderId } = await createOrderDocument({
@@ -529,139 +480,129 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
type: ORDER_TYPES.SUBSCRIPTION, type: ORDER_TYPES.SUBSCRIPTION,
amount: coinsToGrant, amount: coinsToGrant,
metadata: { metadata: {
source: "STRIPE_INVOICE", source: 'STRIPE_INVOICE',
billingPeriod, billingPeriod,
coinsPerMonth, coinsPerMonth,
invoiceId: invoice.id || null, invoiceId: invoice.id || null,
subscriptionId: targetSubscriptionId, subscriptionId: targetSubscriptionId,
grantStrategy: "upfront", grantStrategy: 'upfront',
}, },
orderId, orderId,
}); })
await paymentDocRef.set( await paymentDocRef.set(
{ {
subscriptionCoinsGrantedAt: getServerTimestamp(), subscriptionCoinsGrantedAt: getServerTimestamp(),
subscriptionCoinsGrantAmount: coinsToGrant, subscriptionCoinsGrantAmount: coinsToGrant,
subscriptionCoinsGrantOrderId: processedOrderId, subscriptionCoinsGrantOrderId: processedOrderId,
subscriptionCoinsGrantSource: "invoice_upfront", subscriptionCoinsGrantSource: 'invoice_upfront',
}, },
{ merge: true }, { merge: true }
); )
await userRef.set( await userRef.set(
{ {
subscriptionLastGrantAt: getServerTimestamp(), subscriptionLastGrantAt: getServerTimestamp(),
subscriptionLastGrantAmount: coinsToGrant, subscriptionLastGrantAmount: coinsToGrant,
subscriptionLastGrantOrderId: processedOrderId, subscriptionLastGrantOrderId: processedOrderId,
subscriptionLastGrantSource: "invoice_upfront", subscriptionLastGrantSource: 'invoice_upfront',
subscriptionNextGrantAt: null, subscriptionNextGrantAt: null,
subscriptionGrantInterval: null, subscriptionGrantInterval: null,
subscriptionGrantStrategy: "upfront", subscriptionGrantStrategy: 'upfront',
subscriptionCoinsPerMonth: coinsPerMonth, subscriptionCoinsPerMonth: coinsPerMonth,
}, },
{ merge: true }, { merge: true }
); )
} catch (error) { } catch (error) {
console.error( console.error(
"[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins", '[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins',
{ {
invoiceId: invoice?.id || null, invoiceId: invoice?.id || null,
subscriptionId: targetSubscriptionId, subscriptionId: targetSubscriptionId,
error: error?.message || error, error: error?.message || error,
}, }
); )
} }
} }
} }
}; }
const handleStripeWebhookEvent = async ({ event, stripe }) => { const handleStripeWebhookEvent = async ({ event, stripe }) => {
if (!event || typeof event !== "object") { if (!event || typeof event !== 'object') {
return; return
} }
const eventType = event.type; const eventType = event.type
switch (eventType) { switch (eventType) {
case "checkout.session.completed": case 'checkout.session.completed':
await handleCheckoutSessionCompleted(event.data?.object, event, { await handleCheckoutSessionCompleted(event.data?.object, event, {
stripe, stripe,
}); })
break; break
case "customer.subscription.created": case 'customer.subscription.created':
case "customer.subscription.updated": case 'customer.subscription.updated':
case "customer.subscription.deleted": case 'customer.subscription.deleted':
await handleCustomerSubscriptionEvent(event.data?.object, event, { await handleCustomerSubscriptionEvent(event.data?.object, event, {
stripe, stripe,
}); })
break; break
case "invoice.payment_succeeded": case 'invoice.payment_succeeded':
case "invoice.payment_failed": case 'invoice.payment_failed':
case "invoice.finalized": case 'invoice.finalized':
await handleInvoiceEvent(event.data?.object, event, { stripe }); await handleInvoiceEvent(event.data?.object, event, { stripe })
break; break
default: default:
console.log( console.log('[subscription-handleStripeWebhookEvent] Unhandled event type', eventType)
"[subscription-handleStripeWebhookEvent] Unhandled event type",
eventType,
);
} }
}; }
const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => { const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => {
if (req.method !== "POST") { if (req.method !== 'POST') {
res.status(405).send("Method Not Allowed"); res.status(405).send('Method Not Allowed')
return; return
} }
const signature = req.headers["stripe-signature"]; const signature = req.headers['stripe-signature']
if (!signature) { if (!signature) {
res.status(400).send("Missing Stripe signature"); res.status(400).send('Missing Stripe signature')
return; return
} }
let rawBody = req.rawBody; let rawBody = req.rawBody
if (!rawBody && req.body) { if (!rawBody && req.body) {
rawBody = Buffer.from(JSON.stringify(req.body)); rawBody = Buffer.from(JSON.stringify(req.body))
} }
if (!rawBody) { if (!rawBody) {
res.status(400).send("Missing request body"); res.status(400).send('Missing request body')
return; return
} }
let stripe = null; let stripe = null
try { try {
stripe = getStripeClient(); stripe = getStripeClient()
} catch (error) { } catch (error) {
console.error( console.error('[subscription-handleStripeWebhook] Stripe client error', error)
"[subscription-handleStripeWebhook] Stripe client error", res.status(500).send('Client Stripe indisponible')
error, return
);
res.status(500).send("Client Stripe indisponible");
return;
} }
try { try {
const event = stripe.webhooks.constructEvent( const event = stripe.webhooks.constructEvent(rawBody, signature, resolveStripeWebhookSecret())
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) { } catch (error) {
console.error("[subscription-handleStripeWebhook] error", error); console.error('[subscription-handleStripeWebhook] error', error)
if (error instanceof HttpsError) { if (error instanceof HttpsError) {
res.status(400).send(error.message); res.status(400).send(error.message)
return; return
} }
res.status(500).send("Erreur lors du traitement du webhook"); res.status(500).send('Erreur lors du traitement du webhook')
} }
}); })
module.exports = { module.exports = {
handleStripeWebhook, handleStripeWebhook,
}; }
+72 -83
View File
@@ -1,151 +1,140 @@
const { onObjectFinalized } = require("firebase-functions/v2/storage"); const { onObjectFinalized } = require('firebase-functions/v2/storage')
const logger = require("firebase-functions/logger"); const logger = require('firebase-functions/logger')
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const { FieldValue } = require("firebase-admin/firestore"); const { FieldValue } = require('firebase-admin/firestore')
const ffmpeg = require("fluent-ffmpeg"); const ffmpeg = require('fluent-ffmpeg')
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg"); const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg')
const fs = require("node:fs/promises"); const fs = require('node:fs/promises')
const os = require("node:os"); const os = require('node:os')
const path = require("node:path"); const path = require('node:path')
const crypto = require("node:crypto"); const crypto = require('node:crypto')
const { refList } = require("../index"); const { refList } = require('../index')
ffmpeg.setFfmpegPath(ffmpegInstaller.path); ffmpeg.setFfmpegPath(ffmpegInstaller.path)
exports.generateVideoThumbnail = onObjectFinalized( exports.generateVideoThumbnail = onObjectFinalized(
{ {
region: "europe-west1", region: 'europe-west1',
timeoutSeconds: 180, timeoutSeconds: 180,
memory: "1GiB", memory: '1GiB',
cpu: 1, cpu: 1,
}, },
async (event) => { async (event) => {
const file = event.data || {}; const file = event.data || {}
const bucketName = file.bucket; const bucketName = file.bucket
const objectName = file.name || ""; const objectName = file.name || ''
const contentType = file.contentType || ""; const contentType = file.contentType || ''
// Basic guards + helpful logs for debugging why events might be ignored // Basic guards + helpful logs for debugging why events might be ignored
if (!bucketName || !objectName) { if (!bucketName || !objectName) {
logger.info("[Thumbnail] Ignored: missing bucket or object name", { logger.info('[Thumbnail] Ignored: missing bucket or object name', {
bucketName, bucketName,
objectName, objectName,
contentType, contentType,
}); })
return; return
} }
// Avoid processing our own generated thumbnails // Avoid processing our own generated thumbnails
if (/_thumb9x16\.jpg$/i.test(objectName)) { if (/_thumb9x16\.jpg$/i.test(objectName)) {
logger.info("[Thumbnail] Ignored: already a thumbnail", { objectName }); logger.info('[Thumbnail] Ignored: already a thumbnail', { objectName })
return; return
} }
// Accept if contentType says it's a video OR fallback to extension-based check // Accept if contentType says it's a video OR fallback to extension-based check
const isVideoContentType = const isVideoContentType = typeof contentType === 'string' && contentType.startsWith('video/')
typeof contentType === "string" && contentType.startsWith("video/"); const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(objectName.toLowerCase())
const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(
objectName.toLowerCase()
);
if (!isVideoContentType && !isVideoLikeName) { if (!isVideoContentType && !isVideoLikeName) {
logger.info("[Thumbnail] Ignored: not a video", { logger.info('[Thumbnail] Ignored: not a video', {
objectName, objectName,
contentType, contentType,
}); })
return; return
} }
logger.info("[Thumbnail] Event accepted", { logger.info('[Thumbnail] Event accepted', {
bucketName, bucketName,
objectName, objectName,
contentType, contentType,
}); })
const bucket = admin.storage().bucket(bucketName); const bucket = admin.storage().bucket(bucketName)
const playbackMatch = objectName.match( const playbackMatch = objectName.match(/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i)
/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i const projectId = playbackMatch ? playbackMatch[1] : null
);
const projectId = playbackMatch ? playbackMatch[1] : null;
// Use unique folder under /tmp to avoid name collisions // Use unique folder under /tmp to avoid name collisions
const tmpDir = path.join( const tmpDir = path.join(os.tmpdir(), `thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`)
os.tmpdir(), const baseName = path.basename(objectName)
`thumb-${Date.now()}-${Math.round(Math.random() * 1000)}` const dirName = path.posix.dirname(objectName)
); const localVideoPath = path.join(tmpDir, baseName)
const baseName = path.basename(objectName);
const dirName = path.posix.dirname(objectName);
const localVideoPath = path.join(tmpDir, baseName);
const thumbBase = baseName.replace(/\.[^.]+$/, "") + "_thumb9x16.jpg"; const thumbBase = baseName.replace(/\.[^.]+$/, '') + '_thumb9x16.jpg'
const localThumbPath = path.join(tmpDir, thumbBase); const localThumbPath = path.join(tmpDir, thumbBase)
const remoteThumbPath = const remoteThumbPath =
dirName && dirName !== "." dirName && dirName !== '.' ? path.posix.join(dirName, thumbBase) : thumbBase
? path.posix.join(dirName, thumbBase)
: thumbBase;
try { try {
await fs.mkdir(tmpDir, { recursive: true }); await fs.mkdir(tmpDir, { recursive: true })
// Télécharger la vidéo depuis le bucket // 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 // Extraire 1 frame en 9:16 (1080x1920) de manière robuste
const vfCoverCrop = const vfCoverCrop = 'scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920'
"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920";
const vfPad = 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) => const extractFrame = (ssSeconds, vf) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
ffmpeg(localVideoPath) ffmpeg(localVideoPath)
.inputOptions([`-ss ${ssSeconds}`]) .inputOptions([`-ss ${ssSeconds}`])
.frames(1) .frames(1)
.outputOptions(["-vf", vf, "-q:v", "2"]) .outputOptions(['-vf', vf, '-q:v', '2'])
.output(localThumbPath) .output(localThumbPath)
.on("end", resolve) .on('end', resolve)
.on("error", reject) .on('error', reject)
.run(); .run()
}); })
try { try {
// 1) Essai principal: 1s + cover/crop // 1) Essai principal: 1s + cover/crop
await extractFrame(1, vfCoverCrop); await extractFrame(1, vfCoverCrop)
} catch (e1) { } catch (e1) {
logger.warn("[Thumbnail] First attempt failed, retrying at 0s", { logger.warn('[Thumbnail] First attempt failed, retrying at 0s', {
objectName, objectName,
error: e1?.message || String(e1), error: e1?.message || String(e1),
}); })
try { try {
// 2) Deuxième essai: 0s + cover/crop (si vidéo très courte) // 2) Deuxième essai: 0s + cover/crop (si vidéo très courte)
await extractFrame(0, vfCoverCrop); await extractFrame(0, vfCoverCrop)
} catch (e2) { } catch (e2) {
logger.warn("[Thumbnail] Second attempt failed, fallback to pad", { logger.warn('[Thumbnail] Second attempt failed, fallback to pad', {
objectName, objectName,
error: e2?.message || String(e2), error: e2?.message || String(e2),
}); })
// 3) Fallback: 0s + pad (aucun crop, bandes latérales si besoin) // 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 // Upload du thumbnail avec un token de téléchargement public Firebase
const downloadToken = crypto.randomUUID(); const downloadToken = crypto.randomUUID()
await bucket.upload(localThumbPath, { await bucket.upload(localThumbPath, {
destination: remoteThumbPath, destination: remoteThumbPath,
metadata: { metadata: {
contentType: "image/jpeg", contentType: 'image/jpeg',
cacheControl: "public, max-age=86400", cacheControl: 'public, max-age=86400',
metadata: { metadata: {
original: objectName, original: objectName,
aspect: "9:16", aspect: '9:16',
t: "1s", t: '1s',
firebaseStorageDownloadTokens: downloadToken, firebaseStorageDownloadTokens: downloadToken,
}, },
}, },
}); })
const encodedPath = encodeURIComponent(remoteThumbPath); const encodedPath = encodeURIComponent(remoteThumbPath)
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`; const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`
if (projectId) { if (projectId) {
await refList.projects.doc(projectId).set( await refList.projects.doc(projectId).set(
@@ -154,23 +143,23 @@ exports.generateVideoThumbnail = onObjectFinalized(
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true }
); )
} }
logger.info("✅ [Thumbnail] Uploaded", { logger.info('✅ [Thumbnail] Uploaded', {
objectName, objectName,
remoteThumbPath, remoteThumbPath,
projectId, projectId,
thumbnailUrl, thumbnailUrl,
}); })
} catch (error) { } catch (error) {
logger.error("❌ [Thumbnail] Failed", { logger.error('❌ [Thumbnail] Failed', {
objectName, objectName,
error: error?.message || String(error), error: error?.message || String(error),
}); })
throw error; throw error
} finally { } 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) // functions/mergeVideoAndAudio.js (ou dans index.js)
const { onCall, HttpsError } = require("firebase-functions/v2/https"); const { onCall, HttpsError } = require('firebase-functions/v2/https')
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const logger = require("firebase-functions/logger"); const logger = require('firebase-functions/logger')
const axios = require("axios"); const axios = require('axios')
const fs = require("node:fs/promises"); const fs = require('node:fs/promises')
const os = require("node:os"); const os = require('node:os')
const path = require("node:path"); const path = require('node:path')
const crypto = require("node:crypto"); const crypto = require('node:crypto')
const ffmpeg = require("fluent-ffmpeg"); const ffmpeg = require('fluent-ffmpeg')
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg"); const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg')
const { Buffer } = require("node:buffer"); const { Buffer } = require('node:buffer')
const { FieldValue } = require("firebase-admin/firestore"); const { FieldValue } = require('firebase-admin/firestore')
if (!admin.apps.length) admin.initializeApp(); if (!admin.apps.length) admin.initializeApp()
ffmpeg.setFfmpegPath(ffmpegInstaller.path); ffmpeg.setFfmpegPath(ffmpegInstaller.path)
const db = admin.firestore(); const db = admin.firestore()
const PLAYBACK_CODEC_TAG = "h264-v1"; const PLAYBACK_CODEC_TAG = 'h264-v1'
async function downloadToFile(url, destPath) { async function downloadToFile(url, destPath) {
if (!/^https?:\/\//i.test(url || "")) { if (!/^https?:\/\//i.test(url || '')) {
throw new HttpsError("invalid-argument", `URL non supportée: ${url}`); throw new HttpsError('invalid-argument', `URL non supportée: ${url}`)
} }
const res = await axios.get(url, { responseType: "arraybuffer" }); const res = await axios.get(url, { responseType: 'arraybuffer' })
await fs.writeFile(destPath, Buffer.from(res.data)); await fs.writeFile(destPath, Buffer.from(res.data))
return res.headers?.["content-type"] || ""; return res.headers?.['content-type'] || ''
} }
const SCALE_FILTER = 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 }) { async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -37,92 +37,92 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
.input(videoPath) // 0:v .input(videoPath) // 0:v
.input(audioPath) // 1:a .input(audioPath) // 1:a
.outputOptions([ .outputOptions([
"-map", '-map',
"0:v:0", // garder la 1re piste vidéo de l'entrée 0 '0:v:0', // garder la 1re piste vidéo de l'entrée 0
"-map", '-map',
"1:a:0", // prendre la 1re piste audio de l'entrée 1 '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) // Force une sortie H264 1080p max pour compatibilité totale iOS (les WebM VP8/9 posaient problème)
"-vf", '-vf',
SCALE_FILTER, SCALE_FILTER,
"-c:v", '-c:v',
"libx264", 'libx264',
"-preset", '-preset',
"veryfast", 'veryfast',
"-crf", '-crf',
"22", '22',
"-pix_fmt", '-pix_fmt',
"yuv420p", 'yuv420p',
"-profile:v", '-profile:v',
"high", 'high',
"-level:v", '-level:v',
"4.1", '4.1',
"-c:a", '-c:a',
"aac", 'aac',
"-b:a", '-b:a',
"192k", '192k',
"-movflags", '-movflags',
"+faststart", '+faststart',
"-shortest", // couper à la plus courte des 2 sources '-shortest', // couper à la plus courte des 2 sources
"-tag:v", '-tag:v',
"avc1", 'avc1',
]) ])
.on("error", reject) .on('error', reject)
.on("end", resolve) .on('end', resolve)
.save(outPath); .save(outPath)
}); })
} }
async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) { async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "merge-")); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'merge-'))
const videoPath = path.join(tmpDir, "video.mp4"); const videoPath = path.join(tmpDir, 'video.mp4')
const audioPath = path.join(tmpDir, "audio.mp3"); const audioPath = path.join(tmpDir, 'audio.mp3')
const outPath = path.join(tmpDir, "output.mp4"); const outPath = path.join(tmpDir, 'output.mp4')
try { 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(videoUrl, videoPath)
await downloadToFile(audioUrl, audioPath); await downloadToFile(audioUrl, audioPath)
logger.info("[merge] transcodage/mux ffmpeg"); logger.info('[merge] transcodage/mux ffmpeg')
await muxAudioIntoVideo({ videoPath, audioPath, outPath }); await muxAudioIntoVideo({ videoPath, audioPath, outPath })
const bucket = admin.storage().bucket(); const bucket = admin.storage().bucket()
const downloadToken = crypto.randomUUID(); const downloadToken = crypto.randomUUID()
await bucket.upload(outPath, { await bucket.upload(outPath, {
destination: storagePath, destination: storagePath,
metadata: { metadata: {
contentType: "video/mp4", contentType: 'video/mp4',
cacheControl: "public,max-age=86400", cacheControl: 'public,max-age=86400',
metadata: { firebaseStorageDownloadTokens: downloadToken }, metadata: { firebaseStorageDownloadTokens: downloadToken },
}, },
}); })
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
storagePath storagePath
)}?alt=media&token=${downloadToken}`; )}?alt=media&token=${downloadToken}`
logger.info("[merge] upload terminé", { storagePath }); logger.info('[merge] upload terminé', { storagePath })
return { return {
success: true, success: true,
url: fileUrl, url: fileUrl,
contentType: "video/mp4", contentType: 'video/mp4',
storagePath, storagePath,
}; }
} catch (err) { } catch (err) {
logger.error("[merge] échec", { error: err?.message || String(err) }); logger.error('[merge] échec', { error: err?.message || String(err) })
if (err instanceof HttpsError) throw err; if (err instanceof HttpsError) throw err
throw new HttpsError("internal", err?.message || "Fusion échouée"); throw new HttpsError('internal', err?.message || 'Fusion échouée')
} finally { } finally {
await fs.rm(tmpDir, { recursive: true, force: true }); await fs.rm(tmpDir, { recursive: true, force: true })
} }
} }
async function markPlaybackCompatibility({ projectId, initiatorUid = null }) { async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
if (!projectId) return; if (!projectId) return
const docRef = db.collection("projects").doc(projectId); const docRef = db.collection('projects').doc(projectId)
await docRef.set( await docRef.set(
{ {
playbackCompatibility: { playbackCompatibility: {
@@ -132,89 +132,75 @@ async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
}, },
}, },
{ merge: true } { merge: true }
); )
} }
exports.mergeVideoAndAudio = onCall( exports.mergeVideoAndAudio = onCall(
{ timeoutSeconds: 540, memory: "1GiB" }, { timeoutSeconds: 540, memory: '1GiB' },
async ({ data = {}, auth }) => { async ({ data = {}, auth }) => {
const uid = auth?.uid; const uid = auth?.uid
if (!uid) if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise')
throw new HttpsError("unauthenticated", "Authentification requise");
const { videoUrl, audioUrl, storagePath, projectId } = data || {}; const { videoUrl, audioUrl, storagePath, projectId } = data || {}
if (!videoUrl || !audioUrl || !storagePath) { if (!videoUrl || !audioUrl || !storagePath) {
throw new HttpsError( throw new HttpsError('invalid-argument', 'Requis: { videoUrl, audioUrl, storagePath }')
"invalid-argument",
"Requis: { videoUrl, audioUrl, storagePath }"
);
} }
const expectedPrefix = `users/${uid}/`; const expectedPrefix = `users/${uid}/`
if (!storagePath.startsWith(expectedPrefix)) { if (!storagePath.startsWith(expectedPrefix)) {
throw new HttpsError( throw new HttpsError('permission-denied', `storagePath doit commencer par ${expectedPrefix}`)
"permission-denied",
`storagePath doit commencer par ${expectedPrefix}`
);
} }
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }); const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath })
if (projectId) { if (projectId) {
await markPlaybackCompatibility({ projectId, initiatorUid: uid }); await markPlaybackCompatibility({ projectId, initiatorUid: uid })
} }
return result; return result
} }
); )
exports.reencodePlayback = onCall( exports.reencodePlayback = onCall(
{ timeoutSeconds: 540, memory: "1GiB" }, { timeoutSeconds: 540, memory: '1GiB' },
async ({ data = {}, auth }) => { async ({ data = {}, auth }) => {
const uid = auth?.uid; const uid = auth?.uid
if (!uid) if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise')
throw new HttpsError("unauthenticated", "Authentification requise");
const projectId = data?.projectId; const projectId = data?.projectId
if (!projectId) { if (!projectId) {
throw new HttpsError( throw new HttpsError('invalid-argument', 'Requis: { projectId } pour relancer le transcodage')
"invalid-argument",
"Requis: { projectId } pour relancer le transcodage"
);
} }
logger.info("[reencodePlayback] request received", { logger.info('[reencodePlayback] request received', {
projectId, projectId,
initiator: uid, initiator: uid,
}); })
const projectRef = db.collection("projects").doc(projectId); const projectRef = db.collection('projects').doc(projectId)
const projectSnap = await projectRef.get(); const projectSnap = await projectRef.get()
if (!projectSnap.exists) { if (!projectSnap.exists) {
logger.warn("[reencodePlayback] project not found", { logger.warn('[reencodePlayback] project not found', {
projectId, projectId,
initiator: uid, initiator: uid,
}); })
throw new HttpsError("not-found", "Projet introuvable"); throw new HttpsError('not-found', 'Projet introuvable')
} }
const project = projectSnap.data() || {}; const project = projectSnap.data() || {}
const videoUrl = project.playbackUrl; const videoUrl = project.playbackUrl
const audioUrl = project.songUrl; const audioUrl = project.songUrl
const ownerId = project.userId; const ownerId = project.userId
if (!videoUrl || !audioUrl || !ownerId) { if (!videoUrl || !audioUrl || !ownerId) {
logger.warn("[reencodePlayback] missing fields", { logger.warn('[reencodePlayback] missing fields', {
projectId, projectId,
hasPlaybackUrl: !!videoUrl, hasPlaybackUrl: !!videoUrl,
hasSongUrl: !!audioUrl, hasSongUrl: !!audioUrl,
ownerId, ownerId,
}); })
throw new HttpsError( throw new HttpsError('failed-precondition', 'playbackUrl, songUrl ou userId manquant')
"failed-precondition",
"playbackUrl, songUrl ou userId manquant"
);
} }
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`; const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }); const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath })
await projectRef.set( await projectRef.set(
{ {
@@ -222,13 +208,13 @@ exports.reencodePlayback = onCall(
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true }
); )
await markPlaybackCompatibility({ projectId, initiatorUid: uid }); await markPlaybackCompatibility({ projectId, initiatorUid: uid })
logger.info("[reencodePlayback] success", { logger.info('[reencodePlayback] success', {
projectId, projectId,
initiator: uid, initiator: uid,
storagePath, storagePath,
}); })
return result; return result
} }
); )
+64 -78
View File
@@ -1,137 +1,123 @@
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const { FieldValue } = require("firebase-admin/firestore"); const { FieldValue } = require('firebase-admin/firestore')
const { const { onDocumentDeleted, onDocumentCreated } = require('firebase-functions/firestore')
onDocumentDeleted, const { refList } = require('../index')
onDocumentCreated, const { ORDER_TYPES, createOrderDocument } = require('./helpers/orders')
} = require("firebase-functions/firestore"); const { deleteFolder } = require('../helpers/firebase')
const { refList } = require("../index"); const { Resend } = require('resend')
const { ORDER_TYPES, createOrderDocument } = require("./helpers/orders"); const { welcomeTemplate } = require('../helpers/email')
const { deleteFolder } = require("../helpers/firebase"); const { RESEND_API_KEY } = require('../config/keys')
const { Resend } = require("resend"); const { onRequest } = require('firebase-functions/https')
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 resendClient = new Resend(RESEND_API_KEY)
const WELCOME_EMAIL_FROM = const WELCOME_EMAIL_FROM = process.env.RESEND_FROM_EMAIL || 'MusicLand <musicland@musicland.ai>'
process.env.RESEND_FROM_EMAIL || "MusicLand <musicland@musicland.ai>"; const WELCOME_EMAIL_SUBJECT = 'Bienvenue sur MusicLand'
const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
exports.testWelcomMail = onRequest(async (req, res) => { exports.testWelcomMail = onRequest(async (req, res) => {
if (req.method !== "GET") { if (req.method !== 'GET') {
res.set("Allow", "GET"); res.set('Allow', 'GET')
return res return res.status(405).json({ success: false, error: 'Method not allowed' })
.status(405)
.json({ success: false, error: "Method not allowed" });
} }
try { try {
const targetEmail = req.query.email || "tdtomthomas@gmail.com"; const targetEmail = req.query.email || 'tdtomthomas@gmail.com'
const firstName = req.query.firstName || "Toto"; const firstName = req.query.firstName || 'Toto'
const lastName = req.query.lastName || "Test"; const lastName = req.query.lastName || 'Test'
const { data, error } = await resendClient.emails.send({ const { data, error } = await resendClient.emails.send({
from: WELCOME_EMAIL_FROM, from: WELCOME_EMAIL_FROM,
to: [targetEmail], to: [targetEmail],
subject: WELCOME_EMAIL_SUBJECT, subject: WELCOME_EMAIL_SUBJECT,
html: welcomeTemplate({ firstName, lastName }), html: welcomeTemplate({ firstName, lastName }),
}); })
if (error) { if (error) {
console.log("Failed to send welcome email:", error); console.log('Failed to send welcome email:', error)
return res return res.status(500).json({ success: false, error: error.message || error.toString() })
.status(500)
.json({ success: false, error: error.message || error.toString() });
} }
console.log("Welcome email sent:", data); console.log('Welcome email sent:', data)
return res.status(200).json({ success: true, data }); return res.status(200).json({ success: true, data })
} catch (e) { } catch (e) {
console.log(e); console.log(e)
return res return res.status(500).json({ success: false, error: e.message || e.toString() })
.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 { try {
const { const { email = '', firstName = '', lastName = '' } = event?.data?.data() || {}
email = "",
firstName = "",
lastName = "",
} = event?.data?.data() || {};
const userId = event?.params?.userID; const userId = event?.params?.userID
try { try {
await createOrderDocument({ await createOrderDocument({
userId, userId,
type: ORDER_TYPES.GIFT, type: ORDER_TYPES.GIFT,
amount: 10, amount: 10,
metadata: { reason: "WELCOME_BONUS" }, metadata: { reason: 'WELCOME_BONUS' },
orderId: `welcome_${userId}`, orderId: `welcome_${userId}`,
}); })
} catch (coinError) { } catch (coinError) {
console.warn( console.warn(
"[users-onUserCreated] Unable to grant welcome coins", '[users-onUserCreated] Unable to grant welcome coins',
event?.params?.userID, event?.params?.userID,
coinError?.message || coinError, coinError?.message || coinError
); )
} }
if (email) { if (email) {
if (!resendClient) { if (!resendClient) {
console.warn("Resend API key not configured; skipping welcome email."); console.warn('Resend API key not configured; skipping welcome email.')
return; return
} }
try { try {
console.log(`Sending welcome email to ${email}`); console.log(`Sending welcome email to ${email}`)
const { data, error } = await resendClient.emails.send({ const { data, error } = await resendClient.emails.send({
from: WELCOME_EMAIL_FROM, from: WELCOME_EMAIL_FROM,
to: [email], to: [email],
subject: WELCOME_EMAIL_SUBJECT, subject: WELCOME_EMAIL_SUBJECT,
html: welcomeTemplate({ firstName, lastName }), html: welcomeTemplate({ firstName, lastName }),
}); })
if (error) { if (error) {
console.log("Failed to send welcome email:", error); console.log('Failed to send welcome email:', error)
return; return
} }
console.log("Welcome email sent:", data); console.log('Welcome email sent:', data)
} catch (error) { } catch (error) {
console.log("Failed to send welcome email:", error); console.log('Failed to send welcome email:', error)
} }
} else { } else {
throw new Error("User created with empty email"); throw new Error('User created with empty email')
} }
} catch (e) { } catch (e) {
console.log(e); console.log(e)
} }
}); })
exports.onUserDelete = onDocumentDeleted("users/{userID}", async (event) => { exports.onUserDelete = onDocumentDeleted('users/{userID}', async (event) => {
try { try {
const userID = event?.params?.userID; const userID = event?.params?.userID
await clearAllUserData(userID); await clearAllUserData(userID)
await deleteFolder(`users/${userID}/`); await deleteFolder(`users/${userID}/`)
await admin.auth().deleteUser(userID); await admin.auth().deleteUser(userID)
console.log(`User ${userID} deleted successfully`); console.log(`User ${userID} deleted successfully`)
} catch (e) { } catch (e) {
console.log(e); console.log(e)
} }
}); })
async function clearAllUserData(userID) { async function clearAllUserData(userID) {
const deleteAll = async (ref, key, operator = "==") => { const deleteAll = async (ref, key, operator = '==') => {
const snapshot = await ref.where(key, operator, userID).get(); const snapshot = await ref.where(key, operator, userID).get()
snapshot.forEach((item) => item.ref.delete()); snapshot.forEach((item) => item.ref.delete())
}; }
const removeFromArray = async (ref, arrayName) => { 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) => snapshot.forEach((item) =>
item.ref.update({ item.ref.update({
[arrayName]: FieldValue.arrayRemove(userID), [arrayName]: FieldValue.arrayRemove(userID),
}), })
); )
}; }
await deleteAll(refList.projects, "userId"); await deleteAll(refList.projects, 'userId')
await deleteAll(refList.playlists, "createdBy"); await deleteAll(refList.playlists, 'createdBy')
} }
+120 -146
View File
@@ -1,34 +1,29 @@
const { onCall, HttpsError } = require("firebase-functions/v2/https"); const { onCall, HttpsError } = require('firebase-functions/v2/https')
const { defineSecret } = require("firebase-functions/params"); const { defineSecret } = require('firebase-functions/params')
const logger = require("firebase-functions/logger"); const logger = require('firebase-functions/logger')
const functions = require("firebase-functions"); const functions = require('firebase-functions')
const admin = require("firebase-admin"); const admin = require('firebase-admin')
const { FieldValue } = require("firebase-admin/firestore"); const { FieldValue } = require('firebase-admin/firestore')
const axios = require("axios"); const axios = require('axios')
const fs = require("node:fs"); const fs = require('node:fs')
const fsp = require("node:fs/promises"); const fsp = require('node:fs/promises')
const os = require("node:os"); const os = require('node:os')
const path = require("node:path"); const path = require('node:path')
const { google } = require("googleapis"); const { google } = require('googleapis')
// ---- Secrets déclarés (gen2 + Secret Manager) // ---- Secrets déclarés (gen2 + Secret Manager)
const S_YT_CLIENT_ID = defineSecret("YOUTUBE_CLIENT_ID"); const S_YT_CLIENT_ID = defineSecret('YOUTUBE_CLIENT_ID')
const S_YT_CLIENT_SECRET = defineSecret("YOUTUBE_CLIENT_SECRET"); const S_YT_CLIENT_SECRET = defineSecret('YOUTUBE_CLIENT_SECRET')
const S_YT_REFRESH_TOKEN = defineSecret("YOUTUBE_REFRESH_TOKEN"); const S_YT_REFRESH_TOKEN = defineSecret('YOUTUBE_REFRESH_TOKEN')
const S_YT_REDIRECT_URI = defineSecret("YOUTUBE_REDIRECT_URI"); const S_YT_REDIRECT_URI = defineSecret('YOUTUBE_REDIRECT_URI')
const S_YT_PRIVACY_STATUS = defineSecret("YOUTUBE_PRIVACY_STATUS"); const S_YT_PRIVACY_STATUS = defineSecret('YOUTUBE_PRIVACY_STATUS')
const S_YT_CATEGORY_ID = defineSecret("YOUTUBE_CATEGORY_ID"); const S_YT_CATEGORY_ID = defineSecret('YOUTUBE_CATEGORY_ID')
// Firestore // Firestore
const firestore = admin.firestore(); const firestore = admin.firestore()
const projectsRef = firestore.collection("projects"); const projectsRef = firestore.collection('projects')
const YOUTUBE_IN_PROGRESS_STATUSES = [ const YOUTUBE_IN_PROGRESS_STATUSES = ['PUBLISHING', 'UPLOADING', 'PROCESSING', 'QUEUED']
"PUBLISHING",
"UPLOADING",
"PROCESSING",
"QUEUED",
];
// Lecture des secrets (recommandé en v2) // Lecture des secrets (recommandé en v2)
const getSecretsYoutubeConfig = () => const getSecretsYoutubeConfig = () =>
@@ -40,44 +35,44 @@ const getSecretsYoutubeConfig = () =>
redirect_uri: S_YT_REDIRECT_URI.value(), redirect_uri: S_YT_REDIRECT_URI.value(),
privacy_status: S_YT_PRIVACY_STATUS.value(), privacy_status: S_YT_PRIVACY_STATUS.value(),
category_id: S_YT_CATEGORY_ID.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) // Compat facultative v1 -> renverra {} en v2 (et on log un warn propre)
const getLegacyYoutubeConfig = () => { const getLegacyYoutubeConfig = () => {
if (typeof functions.config !== "function") { if (typeof functions.config !== 'function') {
return {}; return {}
} }
try { try {
return functions.config()?.youtube || {}; return functions.config()?.youtube || {}
} catch (error) { } catch (error) {
if ( if (
typeof error?.message === "string" && typeof error?.message === 'string' &&
error.message.includes("functions.config() is no longer available") error.message.includes('functions.config() is no longer available')
) { ) {
logger.warn( logger.warn(
"[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)" '[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)'
); )
return {}; return {}
} }
throw error; throw error
} }
}; }
const ensureYoutubeConfig = () => { const ensureYoutubeConfig = () => {
// Fusionne (par prudence) lancienne config et les secrets actuels // Fusionne (par prudence) lancienne config et les secrets actuels
const firebaseConfig = getLegacyYoutubeConfig(); const firebaseConfig = getLegacyYoutubeConfig()
const secretConfig = getSecretsYoutubeConfig(); const secretConfig = getSecretsYoutubeConfig()
const cfg = { ...firebaseConfig, ...secretConfig }; const cfg = { ...firebaseConfig, ...secretConfig }
const requiredKeys = ["client_id", "client_secret", "refresh_token"]; const requiredKeys = ['client_id', 'client_secret', 'refresh_token']
const missing = requiredKeys.filter((key) => !cfg[key]); const missing = requiredKeys.filter((key) => !cfg[key])
if (missing.length) { if (missing.length) {
throw new HttpsError( throw new HttpsError(
"failed-precondition", 'failed-precondition',
`Configuration YouTube manquante: ${missing.join(", ")}` `Configuration YouTube manquante: ${missing.join(', ')}`
); )
} }
return { return {
@@ -87,63 +82,54 @@ const ensureYoutubeConfig = () => {
redirectUri: cfg.redirect_uri, redirectUri: cfg.redirect_uri,
defaultPrivacyStatus: cfg.privacy_status, defaultPrivacyStatus: cfg.privacy_status,
defaultCategoryId: cfg.category_id, defaultCategoryId: cfg.category_id,
}; }
}; }
const createYoutubeClient = ({ const createYoutubeClient = ({ clientId, clientSecret, refreshToken, redirectUri }) => {
clientId, const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri)
clientSecret, oauth2Client.setCredentials({ refresh_token: refreshToken })
refreshToken,
redirectUri,
}) => {
const oauth2Client = new google.auth.OAuth2(
clientId,
clientSecret,
redirectUri
);
oauth2Client.setCredentials({ refresh_token: refreshToken });
const youtube = google.youtube({ const youtube = google.youtube({
version: "v3", version: 'v3',
auth: oauth2Client, auth: oauth2Client,
}); })
return { youtube, oauth2Client }; return { youtube, oauth2Client }
}; }
const downloadFile = async (url, destinationPath) => { const downloadFile = async (url, destinationPath) => {
if (!/^https?:\/\//i.test(url || "")) { if (!/^https?:\/\//i.test(url || '')) {
throw new HttpsError("invalid-argument", `URL non valide: ${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) => { await new Promise((resolve, reject) => {
const writer = fs.createWriteStream(destinationPath); const writer = fs.createWriteStream(destinationPath)
response.data.pipe(writer); response.data.pipe(writer)
writer.on("finish", resolve); writer.on('finish', resolve)
writer.on("error", reject); writer.on('error', reject)
}); })
return destinationPath; return destinationPath
}; }
const buildVideoMetadata = (project, defaults) => { const buildVideoMetadata = (project, defaults) => {
const baseTitle = project?.title || "Création MusicLand"; const baseTitle = project?.title || 'Création MusicLand'
const youtubeTitle = `${baseTitle} | 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 description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`
const tags = Array.isArray(project?.youtubeTags) const tags = Array.isArray(project?.youtubeTags)
? project.youtubeTags.filter(Boolean).slice(0, 500) ? project.youtubeTags.filter(Boolean).slice(0, 500)
: undefined; : undefined
const snippet = { const snippet = {
title: youtubeTitle, title: youtubeTitle,
description, description,
categoryId: defaults.defaultCategoryId, categoryId: defaults.defaultCategoryId,
}; }
if (tags && tags.length) { if (tags && tags.length) {
snippet.tags = tags; snippet.tags = tags
} }
return { return {
@@ -153,17 +139,17 @@ const buildVideoMetadata = (project, defaults) => {
embeddable: true, embeddable: true,
selfDeclaredMadeForKids: false, selfDeclaredMadeForKids: false,
}, },
}; }
}; }
exports.publishPlaybackToYoutube = onCall( exports.publishPlaybackToYoutube = onCall(
{ {
timeoutSeconds: 540, timeoutSeconds: 540,
memory: "1GiB", memory: '1GiB',
cors: [ cors: [
"http://localhost:8081", 'http://localhost:8081',
"https://musicland-one.vercel.app/", 'https://musicland-one.vercel.app/',
"https://musicland-d33f9.firebaseapp.com", 'https://musicland-d33f9.firebaseapp.com',
], ],
// Secrets requis pour lexécution (v2) // Secrets requis pour lexécution (v2)
secrets: [ secrets: [
@@ -176,98 +162,86 @@ exports.publishPlaybackToYoutube = onCall(
], ],
}, },
async ({ data = {}, auth }) => { async ({ data = {}, auth }) => {
const uid = auth?.uid; const uid = auth?.uid
if (!uid) { if (!uid) {
throw new HttpsError("unauthenticated", "Authentification requise"); throw new HttpsError('unauthenticated', 'Authentification requise')
} }
const projectId = data?.projectId; const projectId = data?.projectId
if (!projectId || typeof projectId !== "string") { if (!projectId || typeof projectId !== 'string') {
throw new HttpsError("invalid-argument", "Paramètre projectId requis"); 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) { 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) { if (!project || project.userId !== uid) {
throw new HttpsError( throw new HttpsError('permission-denied', "Vous n'avez pas les droits sur ce projet")
"permission-denied",
"Vous n'avez pas les droits sur ce projet"
);
} }
if (!project.playbackUrl) { if (!project.playbackUrl) {
throw new HttpsError( throw new HttpsError('failed-precondition', 'Aucun playback disponible pour la publication')
"failed-precondition",
"Aucun playback disponible pour la publication"
);
} }
if ( if (project.youtubeStatus && YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)) {
project.youtubeStatus &&
YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)
) {
throw new HttpsError( throw new HttpsError(
"failed-precondition", 'failed-precondition',
"Une publication est déjà en cours pour ce projet" 'Une publication est déjà en cours pour ce projet'
); )
} }
let youtubeDefaults; let youtubeDefaults
let youtubeClient; let youtubeClient
try { try {
youtubeDefaults = ensureYoutubeConfig(); youtubeDefaults = ensureYoutubeConfig()
youtubeClient = createYoutubeClient(youtubeDefaults); youtubeClient = createYoutubeClient(youtubeDefaults)
await youtubeClient.oauth2Client.getAccessToken(); await youtubeClient.oauth2Client.getAccessToken()
} catch (error) { } catch (error) {
logger.error("[publishPlaybackToYoutube] configuration invalide", { logger.error('[publishPlaybackToYoutube] configuration invalide', {
error: error?.message, error: error?.message,
}); })
throw new HttpsError( throw new HttpsError('failed-precondition', 'Configuration YouTube invalide ou incomplète')
"failed-precondition",
"Configuration YouTube invalide ou incomplète"
);
} }
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "yt-upload-")); const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'yt-upload-'))
const videoPath = path.join(tmpDir, "playback.mp4"); const videoPath = path.join(tmpDir, 'playback.mp4')
try { try {
await projectsRef.doc(projectId).set( await projectsRef.doc(projectId).set(
{ {
youtubeStatus: "PUBLISHING", youtubeStatus: 'PUBLISHING',
youtubePublished: false, youtubePublished: false,
youtubeError: null, youtubeError: null,
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
{ merge: true } { 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({ const uploadResponse = await youtubeClient.youtube.videos.insert({
part: ["snippet", "status"].join(","), part: ['snippet', 'status'].join(','),
requestBody: metadata, requestBody: metadata,
media: { media: {
body: fs.createReadStream(videoPath), body: fs.createReadStream(videoPath),
}, },
}); })
const videoId = uploadResponse?.data?.id; const videoId = uploadResponse?.data?.id
if (!videoId) { 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( await projectsRef.doc(projectId).set(
{ {
youtubeStatus: "PUBLISHED", youtubeStatus: 'PUBLISHED',
youtubePublished: true, youtubePublished: true,
youtubeUrl: youtubeLink, youtubeUrl: youtubeLink,
youtubeVideoId: videoId, youtubeVideoId: videoId,
@@ -276,45 +250,45 @@ exports.publishPlaybackToYoutube = onCall(
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true }
); )
logger.info("[publishPlaybackToYoutube] publication réussie", { logger.info('[publishPlaybackToYoutube] publication réussie', {
projectId, projectId,
videoId, videoId,
}); })
return { return {
videoId, videoId,
youtubeUrl: youtubeLink, youtubeUrl: youtubeLink,
}; }
} catch (error) { } catch (error) {
logger.error("[publishPlaybackToYoutube] échec de publication", { logger.error('[publishPlaybackToYoutube] échec de publication', {
projectId, projectId,
error: error?.message, error: error?.message,
}); })
const errorMessage = const errorMessage =
error instanceof HttpsError error instanceof HttpsError
? error.message ? error.message
: error?.message || "Publication YouTube échouée"; : error?.message || 'Publication YouTube échouée'
await projectsRef.doc(projectId).set( await projectsRef.doc(projectId).set(
{ {
youtubeStatus: "FAILED", youtubeStatus: 'FAILED',
youtubePublished: false, youtubePublished: false,
youtubeError: errorMessage, youtubeError: errorMessage,
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true }
); )
if (error instanceof HttpsError) { if (error instanceof HttpsError) {
throw error; throw error
} }
throw new HttpsError("internal", errorMessage); throw new HttpsError('internal', errorMessage)
} finally { } 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 { registerRootComponent } from 'expo'
import App from "./App"; import App from './App'
// registerRootComponent calls AppRegistry.registerComponent('main', () => App); // registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build, // 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 }) { function HeadlessCheck({ isHeadless }) {
if (isHeadless) { if (isHeadless) {
// App has been launched in the background by iOS, ignore // 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 '@expo/metro-runtime'
import { registerRootComponent } from "expo"; import { registerRootComponent } from 'expo'
import App from "./App"; import App from './App'
// Inject minimal global CSS and a small setNativeProps polyfill for web // Inject minimal global CSS and a small setNativeProps polyfill for web
if (typeof document !== "undefined") { if (typeof document !== 'undefined') {
document.documentElement?.setAttribute("translate", "no"); document.documentElement?.setAttribute('translate', 'no')
document.body?.setAttribute("translate", "no"); document.body?.setAttribute('translate', 'no')
const style = document.createElement("style"); const style = document.createElement('style')
style.setAttribute("data-inline-global", "true"); style.setAttribute('data-inline-global', 'true')
style.innerHTML = ` style.innerHTML = `
html, body, #root { height: 100%; } html, body, #root { height: 100%; }
body { margin: 0; } body { margin: 0; }
*:focus { outline: none; } *:focus { outline: none; }
* { scrollbar-width: none; -ms-overflow-style: none; } * { scrollbar-width: none; -ms-overflow-style: none; }
*::-webkit-scrollbar { display: none; } *::-webkit-scrollbar { display: none; }
`; `
document.head.appendChild(style); document.head.appendChild(style)
const proto = window.HTMLElement && window.HTMLElement.prototype; const proto = window.HTMLElement && window.HTMLElement.prototype
if (proto && typeof proto.setNativeProps !== "function") { if (proto && typeof proto.setNativeProps !== 'function') {
proto.setNativeProps = function (nativeProps = {}) { proto.setNativeProps = function (nativeProps = {}) {
try { try {
const { style: s, pointerEvents, ...rest } = nativeProps || {}; const { style: s, pointerEvents, ...rest } = nativeProps || {}
if (pointerEvents != null) { 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) { for (const k in s) {
if (Object.prototype.hasOwnProperty.call(s, k)) { if (Object.prototype.hasOwnProperty.call(s, k)) {
try { try {
this.style[k] = s[k]; this.style[k] = s[k]
} catch {} } catch {}
} }
} }
@@ -39,13 +39,13 @@ if (typeof document !== "undefined") {
for (const k in rest) { for (const k in rest) {
if (Object.prototype.hasOwnProperty.call(rest, k)) { if (Object.prototype.hasOwnProperty.call(rest, k)) {
try { try {
this.style[k] = rest[k]; this.style[k] = rest[k]
} catch {} } catch {}
} }
} }
} 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 // Vérifier que .js est bien présent
if (!config.resolver.sourceExts.includes("js")) { if (!config.resolver.sourceExts.includes('js')) {
config.resolver.sourceExts.push("js"); config.resolver.sourceExts.push('js')
} }
// Ajouter ce dont tu as besoin // 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é // Assurer que la plateforme web est bien prise en compte en priorité
config.resolver.platforms = Array.from( 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", "start": "expo start --dev-client",
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios -d", "ios": "expo run:ios -d",
"format": "prettier --write \"src/**/*.js\"",
"web": "expo start --web", "web": "expo start --web",
"xcode": "open ios/musicland.xcworkspace", "xcode": "open ios/musicland.xcworkspace",
"clean": "npx expo prebuild --clean", "clean": "npx expo prebuild --clean",
@@ -111,7 +112,7 @@
"imagemin-jpegtran": "~7.0.0", "imagemin-jpegtran": "~7.0.0",
"imagemin-optipng": "~8.0.0", "imagemin-optipng": "~8.0.0",
"imagemin-svgo": "~11.0.1", "imagemin-svgo": "~11.0.1",
"prettier": "~3.2.5", "prettier": "^3.7.4",
"typescript": "~5.3.3" "typescript": "~5.3.3"
}, },
"resolutions": { "resolutions": {
+7 -7
View File
@@ -1,18 +1,18 @@
import moment from 'moment'; import moment from 'moment'
export const validateDate = (date = null) => { export const validateDate = (date = null) => {
if (!date) { if (!date) {
return null; return null
} }
if (moment(date).isValid()) { if (moment(date).isValid()) {
return date; return date
} }
if (typeof date?.toDate === 'function') { if (typeof date?.toDate === 'function') {
return date?.toDate(); return date?.toDate()
} }
return null; return null
}; }
export function getAge(birthDate) { 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, responsiveHeight as _responsiveHeight,
responsiveWidth as _responsiveWidth, responsiveWidth as _responsiveWidth,
responsiveFontSize as _responsiveFontSize, responsiveFontSize as _responsiveFontSize,
} from "react-native-responsive-dimensions"; } from 'react-native-responsive-dimensions'
import { import {
isWeb, isWeb,
@@ -10,31 +10,31 @@ import {
isLargeDesktop, isLargeDesktop,
isSmallDesktop, isSmallDesktop,
isSuperSmallDesktop, isSuperSmallDesktop,
} from "../hooks/useLayoutType"; } from '../hooks/useLayoutType'
export const reductionCoeff = (bypass) => 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) => { export const responsiveHeight = (height, bypass = false) => {
return _responsiveHeight(height) / reductionCoeff(bypass); return _responsiveHeight(height) / reductionCoeff(bypass)
}; }
export const responsiveWidth = (width, bypass = false) => { export const responsiveWidth = (width, bypass = false) => {
return _responsiveWidth(width) / reductionCoeff(bypass); return _responsiveWidth(width) / reductionCoeff(bypass)
}; }
export const responsiveFontSize = (fontSize, bypass = false) => { export const responsiveFontSize = (fontSize, bypass = false) => {
if (isWeb) { if (isWeb) {
if (isSuperSmallDesktop) { if (isSuperSmallDesktop) {
return fontSize * 6; return fontSize * 6
} else if (isSmallDesktop) { } else if (isSmallDesktop) {
return fontSize * 7; return fontSize * 7
} else { } else {
return fontSize * 7.5; return fontSize * 7.5
} }
} else { } else {
return _responsiveFontSize(fontSize) / reductionCoeff(bypass); return _responsiveFontSize(fontSize) / reductionCoeff(bypass)
} }
}; }
// test // test
+59 -63
View File
@@ -1,86 +1,82 @@
export function checkIfEmailIsValid({ email }) { export function checkIfEmailIsValid({ email }) {
let regex = new RegExp( let regex = new RegExp(
"([!#-'*+/-9=?A-Z^-~-]+(.[!#-'*+/-9=?A-Z^-~-]+)*|\"([]!#-[^-~ \t]|(\\[\t -~]))+\")@([!#-'*+/-9=?A-Z^-~-]+(.[!#-'*+/-9=?A-Z^-~-]+)*|[[\t -Z^-~]*])" "([!#-'*+/-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 }) { export function checkIfPasswordIsStrongEnough({ password }) {
const reg = const reg = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{6,})/
/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{6,})/; return reg.test(password)
return reg.test(password);
} }
export const formatPhoneNumber = ({ phoneNumber = null }) => { export const formatPhoneNumber = ({ phoneNumber = null }) => {
if (!phoneNumber) { if (!phoneNumber) {
return null; return null
} }
let newPhoneNumber = phoneNumber; let newPhoneNumber = phoneNumber
newPhoneNumber = newPhoneNumber newPhoneNumber = newPhoneNumber
.trim() .trim()
.replace(/\s+/g, "") // remove spaces .replace(/\s+/g, '') // remove spaces
.replace(/\D/g, ""); // remove non digits .replace(/\D/g, '') // remove non digits
if (newPhoneNumber.startsWith("330")) { if (newPhoneNumber.startsWith('330')) {
newPhoneNumber = newPhoneNumber.substring(2); newPhoneNumber = newPhoneNumber.substring(2)
} }
if (newPhoneNumber.startsWith("06") || newPhoneNumber.startsWith("07")) { if (newPhoneNumber.startsWith('06') || newPhoneNumber.startsWith('07')) {
newPhoneNumber = `+33${newPhoneNumber.slice(1)}`; newPhoneNumber = `+33${newPhoneNumber.slice(1)}`
} else if ( } else if (newPhoneNumber.startsWith('336') || newPhoneNumber.startsWith('337')) {
newPhoneNumber.startsWith("336") || newPhoneNumber = `+${newPhoneNumber}`
newPhoneNumber.startsWith("337")
) {
newPhoneNumber = `+${newPhoneNumber}`;
} else { } else {
newPhoneNumber = null; newPhoneNumber = null
} }
return newPhoneNumber; return newPhoneNumber
}; }
export function handleFirebaseError(code = "") { export function handleFirebaseError(code = '') {
switch (code) { switch (code) {
case "auth/user-not-found": case 'auth/user-not-found':
return "Ce compte n'existe pas."; return "Ce compte n'existe pas."
case "auth/user-disabled": case 'auth/user-disabled':
return "Ce compte est désactivé. Contacte le support si besoin."; return 'Ce compte est désactivé. Contacte le support si besoin.'
case "auth/invalid-verification-code": case 'auth/invalid-verification-code':
return "Ton code de validation est incorrect."; return 'Ton code de validation est incorrect.'
case "auth/provider-already-linked": case 'auth/provider-already-linked':
return "Ce compte est déjà lié à un utilisateur."; return 'Ce compte est déjà lié à un utilisateur.'
case "auth/invalid-credential": case 'auth/invalid-credential':
case "auth/invalid-login-credential": case 'auth/invalid-login-credential':
case "auth/invalid-login-credentials": case 'auth/invalid-login-credentials':
return "Identifiants incorrects."; return 'Identifiants incorrects.'
case "auth/credential-already-in-use": case 'auth/credential-already-in-use':
return "Ce compte existe déjà ou est déjà lié."; return 'Ce compte existe déjà ou est déjà lié.'
case "auth/operation-not-allowed": case 'auth/operation-not-allowed':
return "Le fournisseur d'identité n'est pas disponible."; return "Le fournisseur d'identité n'est pas disponible."
case "auth/invalid-email": case 'auth/invalid-email':
return "Adresse e-mail invalide."; return 'Adresse e-mail invalide.'
case "auth/wrong-password": case 'auth/wrong-password':
return "Mot de passe incorrect."; return 'Mot de passe incorrect.'
case "auth/invalid-verification-id": case 'auth/invalid-verification-id':
return "Impossible de t'authentifier, réessaie dans quelques secondes."; return "Impossible de t'authentifier, réessaie dans quelques secondes."
case "auth/invalid-phone-number": case 'auth/invalid-phone-number':
return "Numéro de téléphone incorrect."; return 'Numéro de téléphone incorrect.'
case "auth/too-many-requests": case 'auth/too-many-requests':
return "Trop de tentatives. Réessaie dans quelques minutes."; return 'Trop de tentatives. Réessaie dans quelques minutes.'
case "auth/email-already-in-use": case 'auth/email-already-in-use':
return "Un compte avec cette adresse mail existe déjà."; return 'Un compte avec cette adresse mail existe déjà.'
case "auth/missing-password": case 'auth/missing-password':
return "Renseigne ton mot de passe."; return 'Renseigne ton mot de passe.'
case "auth/weak-password": case 'auth/weak-password':
return "Ton mot de passe est trop faible."; return 'Ton mot de passe est trop faible.'
case "auth/network-request-failed": case 'auth/network-request-failed':
return "Problème de connexion réseau. Vérifie ta connexion et réessaie."; return 'Problème de connexion réseau. Vérifie ta connexion et réessaie.'
case "auth/invalid-action-code": case 'auth/invalid-action-code':
case "auth/expired-action-code": case 'auth/expired-action-code':
case "auth/missing-oob-code": case 'auth/missing-oob-code':
return "Ce lien de réinitialisation n'est plus valide. Demande un nouveau mot de passe."; return "Ce lien de réinitialisation n'est plus valide. Demande un nouveau mot de passe."
case "auth/missing-email": case 'auth/missing-email':
return "Renseigne ton adresse e-mail."; return 'Renseigne ton adresse e-mail.'
default: default:
return "Une erreur est survenue. Réessaie dans quelques instants."; return 'Une erreur est survenue. Réessaie dans quelques instants.'
} }
} }
+4 -4
View File
@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from 'react'
import Svg, { Circle, Path } from "react-native-svg"; import Svg, { Circle, Path } from 'react-native-svg'
function EyeSVG(props) { function EyeSVG(props) {
return ( 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" /> <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} /> <Circle cx={12} cy={12} r={3} />
</Svg> </Svg>
); )
} }
export default EyeSVG; export default EyeSVG
+4 -4
View File
@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from 'react'
import Svg, { Path } from "react-native-svg"; import Svg, { Path } from 'react-native-svg'
function EyeSlashSVG(props) { function EyeSlashSVG(props) {
return ( return (
@@ -21,7 +21,7 @@ function EyeSlashSVG(props) {
<Path d="M12 9a3 3 0 013 3" /> <Path d="M12 9a3 3 0 013 3" />
<Path d="M9.88 9.88A3 3 0 0012 15a3 3 0 002.12-.88" /> <Path d="M9.88 9.88A3 3 0 0012 15a3 3 0 002.12-.88" />
</Svg> </Svg>
); )
} }
export default EyeSlashSVG; export default EyeSlashSVG
+10 -10
View File
@@ -1,11 +1,11 @@
import * as React from "react"; import * as React from 'react'
import { View } from "react-native"; import { View } from 'react-native'
import Svg, { ClipPath, Defs, G, Path, Rect } from "react-native-svg"; import Svg, { ClipPath, Defs, G, Path, Rect } from 'react-native-svg'
const RestartSpinnerIcon = ({ const RestartSpinnerIcon = ({
size = 30, size = 30,
color = "#F94697", color = '#F94697',
background = "transparent", background = 'transparent',
style, style,
}) => { }) => {
return ( return (
@@ -15,8 +15,8 @@ const RestartSpinnerIcon = ({
width: size + 8, width: size + 8,
height: size + 8, height: size + 8,
borderRadius: size, borderRadius: size,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
backgroundColor: background, backgroundColor: background,
}, },
style, style,
@@ -55,7 +55,7 @@ const RestartSpinnerIcon = ({
</Defs> </Defs>
</Svg> </Svg>
</View> </View>
); )
}; }
export default RestartSpinnerIcon; export default RestartSpinnerIcon
+125 -127
View File
@@ -1,110 +1,110 @@
import addTab from "./UI/tabs/add.png"; import addTab from './UI/tabs/add.png'
import albums from "./UI/tabs/albums.png"; import albums from './UI/tabs/albums.png'
import chat from "./UI/tabs/chat.png"; import chat from './UI/tabs/chat.png'
import design from "./UI/tabs/design.png"; import design from './UI/tabs/design.png'
import home from "./UI/tabs/home.png"; import home from './UI/tabs/home.png'
import mic from "./UI/tabs/mic.png"; import mic from './UI/tabs/mic.png'
import person from "./UI/tabs/person.png"; import person from './UI/tabs/person.png'
import quotes from "./UI/tabs/quotes.png"; import quotes from './UI/tabs/quotes.png'
import ribbon from "./UI/tabs/ribbon.png"; import ribbon from './UI/tabs/ribbon.png'
import settings from "./UI/tabs/settings.png"; import settings from './UI/tabs/settings.png'
import tasks from "./UI/tabs/tasks.png"; import tasks from './UI/tabs/tasks.png'
import bell from "./UI/bell.png"; import bell from './UI/bell.png'
import sort from "./UI/sort.png"; import sort from './UI/sort.png'
import arrowRight from "./UI/arrowRight.png"; import arrowRight from './UI/arrowRight.png'
import chevronDown from "./UI/chevronDown.png"; import chevronDown from './UI/chevronDown.png'
import threeDots from "./UI/threeDots.png"; import threeDots from './UI/threeDots.png'
import thumbDown from "./UI/thumbDown.png"; import thumbDown from './UI/thumbDown.png'
import thumbUp from "./UI/thumbUp.png"; import thumbUp from './UI/thumbUp.png'
import add from "./UI/add.png"; import add from './UI/add.png'
import addFile from "./UI/addFile.png"; import addFile from './UI/addFile.png'
import check from "./UI/check.png"; import check from './UI/check.png'
import checkCircle from "./UI/checkCircle.png"; import checkCircle from './UI/checkCircle.png'
import deliveryTime from "./UI/deliveryTime.png"; import deliveryTime from './UI/deliveryTime.png'
import edit from "./UI/edit.png"; import edit from './UI/edit.png'
import eye from "./UI/eye.png"; import eye from './UI/eye.png'
import lock from "./UI/lock.png"; import lock from './UI/lock.png'
import message from "./UI/message.png"; import message from './UI/message.png'
import search from "./UI/search.png"; import search from './UI/search.png'
import send from "./UI/send.png"; import send from './UI/send.png'
import shoppingBag from "./UI/shoppingBag.png"; import shoppingBag from './UI/shoppingBag.png'
import tools from "./UI/tools.png"; import tools from './UI/tools.png'
import trash from "./UI/trash.png"; import trash from './UI/trash.png'
import undo from "./UI/undo.png"; import undo from './UI/undo.png'
import focus from "./UI/focus.png"; import focus from './UI/focus.png'
import picture from "./UI/picture.png"; import picture from './UI/picture.png'
import screenshot from "./UI/screenshot.png"; import screenshot from './UI/screenshot.png'
import android from "./UI/android.png"; import android from './UI/android.png'
import ios from "./UI/ios.png"; import ios from './UI/ios.png'
import web from "./UI/web.png"; import web from './UI/web.png'
import law from "./UI/law.png"; import law from './UI/law.png'
import support from "./UI/support.png"; import support from './UI/support.png'
import user from "./UI/user.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 cloud from './icons/cloud.png'
import dashboard from "./icons/dashboard.png"; import dashboard from './icons/dashboard.png'
import file from "./icons/file.png"; import file from './icons/file.png'
import algolia from "./icons/algolia.png"; import algolia from './icons/algolia.png'
import calendar from "./icons/calendar.png"; import calendar from './icons/calendar.png'
import chatBubble from "./icons/chatBubble.png"; import chatBubble from './icons/chatBubble.png'
import close from "./icons/close.png"; import close from './icons/close.png'
import coin from "./icons/coin.png"; import coin from './icons/coin.png'
import disk from "./icons/disk.png"; import disk from './icons/disk.png'
import figma from "./icons/figma.png"; import figma from './icons/figma.png'
import forward from "./icons/forward.png"; import forward from './icons/forward.png'
import gitlab from "./icons/gitlab.png"; import gitlab from './icons/gitlab.png'
import heart from "./icons/heart.png"; import heart from './icons/heart.png'
import heartOutline from "./icons/heartOutline.png"; import heartOutline from './icons/heartOutline.png'
import hitParadeLogo from "./icons/hitParadeLogo.png"; import hitParadeLogo from './icons/hitParadeLogo.png'
import more from "./icons/more.png"; import more from './icons/more.png'
import musicLandAccueil from "./icons/musicLandAccueil.png"; import musicLandAccueil from './icons/musicLandAccueil.png'
import musicLandLogo from "./icons/musicLandLogo.png"; import musicLandLogo from './icons/musicLandLogo.png'
import musicLandProduction from "./icons/musicLandProduction.png"; import musicLandProduction from './icons/musicLandProduction.png'
import musicLandStudio from "./icons/musicLandStudio.png"; import musicLandStudio from './icons/musicLandStudio.png'
import musicLandVideo from "./icons/musicLandVideo.png"; import musicLandVideo from './icons/musicLandVideo.png'
import musicLandWriting from "./icons/musicLandWriting.png"; import musicLandWriting from './icons/musicLandWriting.png'
import pause from "./icons/pause.png"; import pause from './icons/pause.png'
import play from "./icons/play.png"; import play from './icons/play.png'
import share from "./icons/share.png"; import share from './icons/share.png'
import stars from "./icons/stars.png"; import stars from './icons/stars.png'
import hitParadeBG from "./UI/hitParadeBG.png"; import hitParadeBG from './UI/hitParadeBG.png'
import homeBG from "./UI/homeBG.png"; import homeBG from './UI/homeBG.png'
import libraryBG from "./UI/libraryBG.png"; import libraryBG from './UI/libraryBG.png'
import libraryBG2 from "./UI/libraryBG2.png"; import libraryBG2 from './UI/libraryBG2.png'
import playbackBG from "./UI/playbackBG.png"; import playbackBG from './UI/playbackBG.png'
import playbackBG2 from "./UI/playbackBG2.png"; import playbackBG2 from './UI/playbackBG2.png'
import productionBG from "./UI/productionBG.png"; import productionBG from './UI/productionBG.png'
import productionBG2 from "./UI/productionBG2.png"; import productionBG2 from './UI/productionBG2.png'
import profileBG from "./UI/profileBG.png"; import profileBG from './UI/profileBG.png'
import studioBG from "./UI/studioBG.png"; import studioBG from './UI/studioBG.png'
import studioBG2 from "./UI/studioBG2.png"; import studioBG2 from './UI/studioBG2.png'
import writingBG from "./UI/writingBG.png"; import writingBG from './UI/writingBG.png'
import bena from "./UI/bena.png"; import bena from './UI/bena.png'
import john from "./UI/john.png"; import john from './UI/john.png'
import malik from "./UI/malik.png"; import malik from './UI/malik.png'
import nathalie from "./UI/nathalie.png"; import nathalie from './UI/nathalie.png'
import theo from "./UI/theo.png"; import theo from './UI/theo.png'
import { Platform } from "react-native"; import { Platform } from 'react-native'
import goodVibe from "./UI/goodVibe.png"; import goodVibe from './UI/goodVibe.png'
import placeholder from "./UI/placeholder.jpg"; import placeholder from './UI/placeholder.jpg'
import placeholder2 from "./UI/placeholder2.jpg"; import placeholder2 from './UI/placeholder2.jpg'
import placeholder3 from "./UI/placeholder3.png"; import placeholder3 from './UI/placeholder3.png'
import placeholder4 from "./UI/placeholder4.jpg"; import placeholder4 from './UI/placeholder4.jpg'
import profile from "./UI/profile.jpg"; import profile from './UI/profile.jpg'
import musiclandClub from "./UI/musiclandClub.png"; import musiclandClub from './UI/musiclandClub.png'
export const tabs = { export const tabs = {
home, home,
tasks, tasks,
@@ -117,12 +117,12 @@ export const tabs = {
ribbon, ribbon,
mic, mic,
person, person,
}; }
export const icons = { export const icons = {
bell, bell,
sort, sort,
dragDots: require("./icons/dragDots.png"), dragDots: require('./icons/dragDots.png'),
chevronDown, chevronDown,
arrowRight, arrowRight,
@@ -186,13 +186,13 @@ export const icons = {
hitParadeLogo, hitParadeLogo,
calendar, calendar,
coin, coin,
club: require("./icons/club.png"), club: require('./icons/club.png'),
clubIcon: require("./icons/clubIcon.png"), clubIcon: require('./icons/clubIcon.png'),
}; }
export const background = { export const background = {
writingBG, writingBG,
writingBgWeb: require("./UI/writingBgWeb.png"), writingBgWeb: require('./UI/writingBgWeb.png'),
studioBG, studioBG,
studioBG2, studioBG2,
productionBG, productionBG,
@@ -200,21 +200,21 @@ export const background = {
playbackBG, playbackBG,
playbackBG2, playbackBG2,
libraryBG, libraryBG,
libraryBgWeb: require("./UI/libraryBgWeb.png"), libraryBgWeb: require('./UI/libraryBgWeb.png'),
libraryBG2, libraryBG2,
libraryBG2Web: require("./UI/libraryBG2Web.png"), libraryBG2Web: require('./UI/libraryBG2Web.png'),
profileBG, profileBG,
profileBgWeb: require("./UI/profileBgWeb.png"), profileBgWeb: require('./UI/profileBgWeb.png'),
hitParadeBG, hitParadeBG,
hitParadeBG2: require("./UI/hitparadeBG2.jpg"), hitParadeBG2: require('./UI/hitparadeBG2.jpg'),
playbackWeb: require("./UI/playbackWeb.png"), playbackWeb: require('./UI/playbackWeb.png'),
playbackMobile: require("./UI/playbackMobile.png"), playbackMobile: require('./UI/playbackMobile.png'),
homeBG, homeBG,
homeBGWeb: require("./UI/homeBGWeb.png"), homeBGWeb: require('./UI/homeBGWeb.png'),
loginBgWeb: require("./UI/loginBgWeb.png"), loginBgWeb: require('./UI/loginBgWeb.png'),
profileWebBG: require("./UI/profileWebBG.jpg"), profileWebBG: require('./UI/profileWebBG.jpg'),
bgTrans: require("./UI/bgTrans.png"), bgTrans: require('./UI/bgTrans.png'),
}; }
export const ai = { export const ai = {
nathalie, nathalie,
@@ -222,15 +222,13 @@ export const ai = {
malik, malik,
bena, bena,
john, john,
}; }
export const videos = { export const videos = {
test: test:
Platform.OS === "web" Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'),
? require("./video/testVideoWeb.mp4") club: require('./video/club.mp4'),
: require("./video/testVideo.mp4"), }
club: require("./video/club.mp4"),
};
export const img = { export const img = {
placeholder, placeholder,
@@ -240,17 +238,17 @@ export const img = {
profile, profile,
goodVibe, goodVibe,
musiclandClub, musiclandClub,
}; }
export const cardsImg = { export const cardsImg = {
writing: require("./icons/writing.png"), writing: require('./icons/writing.png'),
studio: require("./icons/studio.png"), studio: require('./icons/studio.png'),
video: require("./icons/video.png"), video: require('./icons/video.png'),
production: require("./icons/production.png"), production: require('./icons/production.png'),
}; }
export const subBadges = { export const subBadges = {
starter: require("./icons/starterBadge.png"), starter: require('./icons/starterBadge.png'),
pro: require("./icons/proBadge.png"), pro: require('./icons/proBadge.png'),
premium: require("./icons/premiumBadge.png"), premium: require('./icons/premiumBadge.png'),
}; }
+5 -6
View File
@@ -1,9 +1,8 @@
import {Dimensions} from 'react-native'; import { Dimensions } from 'react-native'
import {resWidth} from '../styles'; import { resWidth } from '../styles'
const {width: DIMENSION_WIDTH, height: DIMENSION_HEIGHT} = const { width: DIMENSION_WIDTH, height: DIMENSION_HEIGHT } = Dimensions.get('screen')
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 ({ export default ({
message, message,
defaultMessage = "Chargement...", defaultMessage = 'Chargement...',
containerStyle = {}, containerStyle = {},
indicatorProps = {}, indicatorProps = {},
messageStyle = {}, messageStyle = {},
@@ -13,8 +13,8 @@ export default ({
<View <View
style={[ style={[
{ {
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
}, },
containerStyle, containerStyle,
]} ]}
@@ -26,5 +26,5 @@ export default ({
indicatorProps={indicatorProps} indicatorProps={indicatorProps}
/> />
</View> </View>
); )
}; }
+63 -76
View File
@@ -1,56 +1,48 @@
import { PortalProvider } from "@gorhom/portal"; import { PortalProvider } from '@gorhom/portal'
import React, { useState } from "react"; import React, { useState } from 'react'
import { Alert, Platform, StyleSheet, Text, View } from "react-native"; import { Alert, Platform, StyleSheet, Text, View } from 'react-native'
import { BlurView } from "expo-blur"; import { BlurView } from 'expo-blur'
import { Fonts, Palette, gutters } from "../styles"; import { Fonts, Palette, gutters } from '../styles'
import GradientButton from "./GradientButton"; import GradientButton from './GradientButton'
import { LinearGradient } from "./LinearGradient/LinearGradient"; import { LinearGradient } from './LinearGradient/LinearGradient'
import Overlay from "./Overlay"; import Overlay from './Overlay'
const WebAlertModal = ({ title, description, options }) => { const WebAlertModal = ({ title, description, options }) => {
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true)
const confirmOption = options?.find(({ style }) => style !== "cancel"); const confirmOption = options?.find(({ style }) => style !== 'cancel')
const cancelOption = options?.find(({ style }) => style === "cancel"); const cancelOption = options?.find(({ style }) => style === 'cancel')
const hasSecondaryAction = Boolean(cancelOption); const hasSecondaryAction = Boolean(cancelOption)
const buttonContainerStyle = hasSecondaryAction const buttonContainerStyle = hasSecondaryAction ? styles.actionButton : styles.singleActionButton
? styles.actionButton
: styles.singleActionButton;
const onConfirm = () => { const onConfirm = () => {
setVisible(false); setVisible(false)
confirmOption?.onPress(); confirmOption?.onPress()
}; }
const onCancel = () => { const onCancel = () => {
setVisible(false); setVisible(false)
cancelOption?.onPress(); cancelOption?.onPress()
}; }
const renderDescription = () => { const renderDescription = () => {
if ( if (typeof description === 'string' || typeof description === 'number') {
typeof description === "string" || return <Text style={styles.description}>{description}</Text>
typeof description === "number"
) {
return <Text style={styles.description}>{description}</Text>;
} }
if (!description) { if (!description) {
return null; return null
} }
return <View style={styles.customDescription}>{description}</View>; return <View style={styles.customDescription}>{description}</View>
}; }
return ( return (
<PortalProvider> <PortalProvider>
<Overlay <Overlay isVisible={visible} contentContainerStyle={styles.overlayContent}>
isVisible={visible}
contentContainerStyle={styles.overlayContent}
>
<View style={styles.modalWrapper}> <View style={styles.modalWrapper}>
<LinearGradient <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 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.modalBorder} style={styles.modalBorder}
@@ -59,24 +51,19 @@ const WebAlertModal = ({ title, description, options }) => {
<Text style={styles.title}>{title}</Text> <Text style={styles.title}>{title}</Text>
{renderDescription()} {renderDescription()}
<View style={styles.divider} /> <View style={styles.divider} />
<View <View style={hasSecondaryAction ? styles.actionsRow : styles.actions}>
style={hasSecondaryAction ? styles.actionsRow : styles.actions}
>
{cancelOption && ( {cancelOption && (
<GradientButton <GradientButton
title={cancelOption.text} title={cancelOption.text}
onPress={onCancel} onPress={onCancel}
colors={[ colors={['rgba(255,255,255,0.16)', 'rgba(255,255,255,0.08)']}
"rgba(255,255,255,0.16)",
"rgba(255,255,255,0.08)",
]}
textStyle={styles.secondaryButtonText} textStyle={styles.secondaryButtonText}
gradientStyle={styles.secondaryButtonGradient} gradientStyle={styles.secondaryButtonGradient}
containerStyle={buttonContainerStyle} containerStyle={buttonContainerStyle}
/> />
)} )}
<GradientButton <GradientButton
title={confirmOption?.text || "OK"} title={confirmOption?.text || 'OK'}
onPress={onConfirm} onPress={onConfirm}
containerStyle={buttonContainerStyle} containerStyle={buttonContainerStyle}
/> />
@@ -86,16 +73,16 @@ const WebAlertModal = ({ title, description, options }) => {
</View> </View>
</Overlay> </Overlay>
</PortalProvider> </PortalProvider>
); )
}; }
const alertPolyfill = (title, description, options, extra) => { const alertPolyfill = (title, description, options, extra) => {
const rootDiv = document.createElement("div"); const rootDiv = document.createElement('div')
document.body.appendChild(rootDiv); document.body.appendChild(rootDiv)
const closeModal = () => { const closeModal = () => {
document.body.removeChild(rootDiv); document.body.removeChild(rootDiv)
}; }
const WebAlertComponent = () => ( const WebAlertComponent = () => (
<WebAlertModal <WebAlertModal
@@ -104,38 +91,38 @@ const alertPolyfill = (title, description, options, extra) => {
options={options} options={options}
onDismiss={closeModal} onDismiss={closeModal}
/> />
); )
// Render the React component into the div // 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) => { const customAlert = (title, description, options, extra) => {
// Utilisation de la propriété userInterfaceStyle pour le mettre en mode sombre // Utilisation de la propriété userInterfaceStyle pour le mettre en mode sombre
Alert.alert(title, description, options, { Alert.alert(title, description, options, {
...extra, ...extra,
userInterfaceStyle: "dark", userInterfaceStyle: 'dark',
}); })
}; }
const alert = Platform.OS === "web" ? alertPolyfill : customAlert; const alert = Platform.OS === 'web' ? alertPolyfill : customAlert
export const showPremiumRequiredAlert = () => export const showPremiumRequiredAlert = () =>
alert( alert(
"Accès premium requis", 'Accès premium requis',
"Vous devez payer pour créer une nouvelle musique." 'Vous devez payer pour créer une nouvelle musique.'
// [{ text: "OK", style: "cancel" }] // [{ text: "OK", style: "cancel" }]
); )
export default alert; export default alert
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlayContent: { overlayContent: {
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
}, },
modalWrapper: { modalWrapper: {
width: "90%", width: '90%',
maxWidth: 460, maxWidth: 460,
paddingHorizontal: 12, paddingHorizontal: 12,
}, },
@@ -147,49 +134,49 @@ const styles = StyleSheet.create({
borderRadius: 23, borderRadius: 23,
paddingHorizontal: gutters * 1.8, paddingHorizontal: gutters * 1.8,
paddingVertical: gutters, paddingVertical: gutters,
backgroundColor: "rgba(15, 12, 20, 0.9)", backgroundColor: 'rgba(15, 12, 20, 0.9)',
overflow: "hidden", overflow: 'hidden',
gap: gutters * 0.75, gap: gutters * 0.75,
}, },
title: Fonts({ title: Fonts({
type: "mainTitle", type: 'mainTitle',
fontSize: 3, fontSize: 3,
style: { textAlign: "center" }, style: { textAlign: 'center' },
}), }),
description: Fonts({ description: Fonts({
type: "default", type: 'default',
color: Palette.gray, color: Palette.gray,
fontSize: 2, fontSize: 2,
style: { lineHeight: 22, textAlign: "center" }, style: { lineHeight: 22, textAlign: 'center' },
}), }),
divider: { divider: {
height: 1, height: 1,
backgroundColor: "rgba(255,255,255,0.08)", backgroundColor: 'rgba(255,255,255,0.08)',
marginVertical: 0, marginVertical: 0,
}, },
customDescription: { customDescription: {
width: "100%", width: '100%',
}, },
actionsRow: { actionsRow: {
flexDirection: "row", flexDirection: 'row',
gap: gutters, gap: gutters,
width: "100%", width: '100%',
}, },
actions: { actions: {
width: "100%", width: '100%',
gap: gutters, gap: gutters,
}, },
actionButton: { actionButton: {
flex: 1, flex: 1,
}, },
singleActionButton: { singleActionButton: {
width: "100%", width: '100%',
}, },
secondaryButtonGradient: { secondaryButtonGradient: {
borderWidth: 1, borderWidth: 1,
borderColor: "rgba(255,255,255,0.2)", borderColor: 'rgba(255,255,255,0.2)',
}, },
secondaryButtonText: { secondaryButtonText: {
color: Palette.gray, color: Palette.gray,
}, },
}); })
@@ -1,11 +1,11 @@
import React, { useMemo } from "react"; import React, { useMemo } from 'react'
import { Animated, StyleSheet, useWindowDimensions, View } from "react-native"; import { Animated, StyleSheet, useWindowDimensions, View } from 'react-native'
import { Palette } from "../../styles"; import { Palette } from '../../styles'
const ORIENTATION = { const ORIENTATION = {
HORIZONTAL: "horizontal", HORIZONTAL: 'horizontal',
VERTICAL: "vertical", VERTICAL: 'vertical',
}; }
const AnimatedPaginationDot = ({ const AnimatedPaginationDot = ({
data = [], data = [],
@@ -16,44 +16,32 @@ const AnimatedPaginationDot = ({
orientation = ORIENTATION.HORIZONTAL, orientation = ORIENTATION.HORIZONTAL,
expandingDotSize = 20, expandingDotSize = 20,
inactiveDotOpacity = 0.4, inactiveDotOpacity = 0.4,
inactiveDotColor = "rgba(255,255,255,0.4)", inactiveDotColor = 'rgba(255,255,255,0.4)',
activeDotColor = Palette.white, activeDotColor = Palette.white,
baseDotSize = 10, baseDotSize = 10,
}) => { }) => {
const animatedValue = useMemo( const animatedValue = useMemo(() => scrollValue || new Animated.Value(0), [scrollValue])
() => scrollValue || new Animated.Value(0),
[scrollValue]
);
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions()
const distance = useMemo(() => { const distance = useMemo(() => {
if (typeof itemDimension === "number" && itemDimension > 0) { if (typeof itemDimension === 'number' && itemDimension > 0) {
return itemDimension; return itemDimension
} }
if (orientation === ORIENTATION.VERTICAL) { if (orientation === ORIENTATION.VERTICAL) {
return Math.max(height, 1); return Math.max(height, 1)
} }
return Math.max(width, 1); return Math.max(width, 1)
}, [height, itemDimension, orientation, width]); }, [height, itemDimension, orientation, width])
const resolvedDotStyle = useMemo( const resolvedDotStyle = useMemo(() => StyleSheet.flatten(dotStyle) || {}, [dotStyle])
() => StyleSheet.flatten(dotStyle) || {}, const defaultSize = Math.max(baseDotSize, 1)
[dotStyle]
);
const defaultSize = Math.max(baseDotSize, 1);
const baseWidth = const baseWidth =
typeof resolvedDotStyle?.width === "number" typeof resolvedDotStyle?.width === 'number' ? resolvedDotStyle.width : defaultSize
? resolvedDotStyle.width
: defaultSize;
const baseHeight = const baseHeight =
typeof resolvedDotStyle?.height === "number" typeof resolvedDotStyle?.height === 'number' ? resolvedDotStyle.height : defaultSize
? resolvedDotStyle.height
: defaultSize;
const containerOrientationStyle = const containerOrientationStyle =
orientation === ORIENTATION.VERTICAL orientation === ORIENTATION.VERTICAL ? styles.containerVertical : styles.containerHorizontal
? styles.containerVertical
: styles.containerHorizontal;
return ( return (
<View <View
@@ -61,28 +49,24 @@ const AnimatedPaginationDot = ({
style={[styles.containerBase, containerOrientationStyle, containerStyle]} style={[styles.containerBase, containerOrientationStyle, containerStyle]}
> >
{data.map((_, index) => { {data.map((_, index) => {
const inputRange = [ const inputRange = [(index - 1) * distance, index * distance, (index + 1) * distance]
(index - 1) * distance,
index * distance,
(index + 1) * distance,
];
const staticSizeStyle = { const staticSizeStyle = {
width: baseWidth, width: baseWidth,
height: baseHeight, height: baseHeight,
}; }
const color = animatedValue.interpolate({ const color = animatedValue.interpolate({
inputRange, inputRange,
outputRange: [inactiveDotColor, activeDotColor, inactiveDotColor], outputRange: [inactiveDotColor, activeDotColor, inactiveDotColor],
extrapolate: "clamp", extrapolate: 'clamp',
}); })
const opacity = animatedValue.interpolate({ const opacity = animatedValue.interpolate({
inputRange, inputRange,
outputRange: [inactiveDotOpacity, 1, inactiveDotOpacity], outputRange: [inactiveDotOpacity, 1, inactiveDotOpacity],
extrapolate: "clamp", extrapolate: 'clamp',
}); })
const primarySize = animatedValue.interpolate({ const primarySize = animatedValue.interpolate({
inputRange, inputRange,
@@ -91,13 +75,11 @@ const AnimatedPaginationDot = ({
expandingDotSize, expandingDotSize,
orientation === ORIENTATION.VERTICAL ? baseHeight : baseWidth, orientation === ORIENTATION.VERTICAL ? baseHeight : baseWidth,
], ],
extrapolate: "clamp", extrapolate: 'clamp',
}); })
const animatedSizeStyle = const animatedSizeStyle =
orientation === ORIENTATION.VERTICAL orientation === ORIENTATION.VERTICAL ? { height: primarySize } : { width: primarySize }
? { height: primarySize }
: { width: primarySize };
return ( return (
<Animated.View <Animated.View
@@ -110,30 +92,30 @@ const AnimatedPaginationDot = ({
{ backgroundColor: color, opacity }, { backgroundColor: color, opacity },
]} ]}
/> />
); )
})} })}
</View> </View>
); )
}; }
AnimatedPaginationDot.orientation = ORIENTATION; AnimatedPaginationDot.orientation = ORIENTATION
export default AnimatedPaginationDot; export default AnimatedPaginationDot
const styles = StyleSheet.create({ const styles = StyleSheet.create({
containerBase: { containerBase: {
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
}, },
containerHorizontal: { containerHorizontal: {
flexDirection: "row", flexDirection: 'row',
}, },
containerVertical: { containerVertical: {
flexDirection: "column", flexDirection: 'column',
}, },
dotBase: { dotBase: {
borderRadius: 999, borderRadius: 999,
marginHorizontal: 4, marginHorizontal: 4,
marginVertical: 4, marginVertical: 4,
}, },
}); })
+28 -34
View File
@@ -1,31 +1,25 @@
import { Portal } from "@gorhom/portal"; import { Portal } from '@gorhom/portal'
import { BlurView } from "expo-blur"; import { BlurView } from 'expo-blur'
import { useCallback } from "react"; import { useCallback } from 'react'
import { Platform, Pressable, StyleSheet, View } from "react-native"; import { Platform, Pressable, StyleSheet, View } from 'react-native'
import ActionSheet, { SheetManager } from "react-native-actions-sheet"; import ActionSheet, { SheetManager } from 'react-native-actions-sheet'
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { isWeb } from "../hooks/useLayoutType"; import { isWeb } from '../hooks/useLayoutType'
import { gutters, Palette } from "../styles"; import { gutters, Palette } from '../styles'
const AppActionSheet = ({ const AppActionSheet = ({ id, children, webModal = false, onClose = () => {}, ...sheetProps }) => {
id, const insets = useSafeAreaInsets()
children,
webModal = false,
onClose = () => {},
...sheetProps
}) => {
const insets = useSafeAreaInsets();
const handleRequestClose = useCallback(() => { const handleRequestClose = useCallback(() => {
if (id) { if (id) {
Promise.resolve(SheetManager.hide(id)) Promise.resolve(SheetManager.hide(id))
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
onClose?.(); onClose?.()
}); })
return; return
} }
onClose?.(); onClose?.()
}, [id, onClose]); }, [id, onClose])
if (isWeb && webModal) { if (isWeb && webModal) {
return ( return (
@@ -43,7 +37,7 @@ const AppActionSheet = ({
</View> </View>
</View> </View>
</Portal> </Portal>
); )
} }
return ( return (
@@ -62,7 +56,7 @@ const AppActionSheet = ({
{...sheetProps} {...sheetProps}
> >
<BlurView <BlurView
intensity={Platform.OS === "ios" || isWeb ? 20 : 10} intensity={Platform.OS === 'ios' || isWeb ? 20 : 10}
style={{ style={{
paddingTop: 36, paddingTop: 36,
paddingHorizontal: 14, paddingHorizontal: 14,
@@ -70,7 +64,7 @@ const AppActionSheet = ({
backgroundColor: Palette.glass, backgroundColor: Palette.glass,
borderTopLeftRadius: 20, borderTopLeftRadius: 20,
borderTopRightRadius: 20, borderTopRightRadius: 20,
overflow: "hidden", overflow: 'hidden',
}} }}
// experimentalBlurMethod={ // experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none" // Platform.OS !== "ios" ? "dimezisBlurView" : "none"
@@ -79,33 +73,33 @@ const AppActionSheet = ({
{children} {children}
</BlurView> </BlurView>
</ActionSheet> </ActionSheet>
); )
}; }
export default AppActionSheet; export default AppActionSheet
const styles = StyleSheet.create({ const styles = StyleSheet.create({
webOverlay: { webOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
position: "fixed", position: 'fixed',
zIndex: 100, zIndex: 100,
}, },
webBackdrop: { webBackdrop: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.55)", backgroundColor: 'rgba(0, 0, 0, 0.55)',
}, },
webModalWrapper: { webModalWrapper: {
flex: 1, flex: 1,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
padding: 24, padding: 24,
}, },
webModalCard: { webModalCard: {
width: 480, width: 480,
maxWidth: "90%", maxWidth: '90%',
borderRadius: 28, borderRadius: 28,
overflow: "hidden", overflow: 'hidden',
backgroundColor: Palette.glass, backgroundColor: Palette.glass,
padding: 32, padding: 32,
}, },
}); })
+8 -8
View File
@@ -1,8 +1,8 @@
import { View, Text, Pressable } from "react-native"; import { View, Text, Pressable } from 'react-native'
import React from "react"; import React from 'react'
import { Palette, Style } from "../styles"; import { Palette, Style } from '../styles'
import { size } from "../styles/Style"; import { size } from '../styles/Style'
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from '../styles/Fonts'
const AppCheckbox = ({ onPress, selected, label }) => { const AppCheckbox = ({ onPress, selected, label }) => {
return ( return (
@@ -42,7 +42,7 @@ const AppCheckbox = ({ onPress, selected, label }) => {
{label} {label}
</Text> </Text>
</Pressable> </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 AsyncStorage from '@react-native-async-storage/async-storage'
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from 'react'
import { import { Image, Linking, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
Image,
Linking,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { icons } from "../assets"; import { icons } from '../assets'
import { appleAppStoreUrl, googlePlayStoreUrl } from "../data"; import { appleAppStoreUrl, googlePlayStoreUrl } from '../data'
import useLayoutType from "../hooks/useLayoutType"; import useLayoutType from '../hooks/useLayoutType'
import { Palette, gutters } from "../styles"; import { Palette, gutters } from '../styles'
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from '../styles/Fonts'
const STORAGE_KEY = "appDownloadBanner:dismissed"; const STORAGE_KEY = 'appDownloadBanner:dismissed'
const AppDownloadBanner = () => { const AppDownloadBanner = () => {
const { isMobileWeb } = useLayoutType(); const { isMobileWeb } = useLayoutType()
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false)
const [hasHydrated, setHasHydrated] = useState(false); const [hasHydrated, setHasHydrated] = useState(false)
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true
if (!isMobileWeb) { if (!isMobileWeb) {
setIsVisible(false); setIsVisible(false)
setHasHydrated(false); setHasHydrated(false)
return undefined; return undefined
} }
AsyncStorage.getItem(STORAGE_KEY) AsyncStorage.getItem(STORAGE_KEY)
.then((value) => { .then((value) => {
if (!mounted) return; if (!mounted) return
setIsVisible(value !== "hidden"); setIsVisible(value !== 'hidden')
setHasHydrated(true); setHasHydrated(true)
}) })
.catch(() => { .catch(() => {
if (!mounted) return; if (!mounted) return
setIsVisible(true); setIsVisible(true)
setHasHydrated(true); setHasHydrated(true)
}); })
return () => { return () => {
mounted = false; mounted = false
}; }
}, [isMobileWeb]); }, [isMobileWeb])
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
setIsVisible(false); setIsVisible(false)
AsyncStorage.setItem(STORAGE_KEY, "hidden").catch(() => {}); AsyncStorage.setItem(STORAGE_KEY, 'hidden').catch(() => {})
}, []); }, [])
const openLink = useCallback((url) => { const openLink = useCallback((url) => {
if (typeof url !== "string") return; if (typeof url !== 'string') return
const target = url.trim(); const target = url.trim()
if (!target) return; if (!target) return
if (Platform.OS === "web") { if (Platform.OS === 'web') {
try { try {
window.open(target, "_blank", "noopener,noreferrer"); window.open(target, '_blank', 'noopener,noreferrer')
return; return
} catch (error) { } 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) => { 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( const handleOpenStore = useCallback(
(store) => { (store) => {
if (store === "ios") { if (store === 'ios') {
openLink(appleAppStoreUrl); openLink(appleAppStoreUrl)
return; return
} }
if (store === "android") { if (store === 'android') {
openLink(googlePlayStoreUrl); openLink(googlePlayStoreUrl)
} }
}, },
[openLink], [openLink]
); )
if (!isMobileWeb || !isVisible || !hasHydrated) { if (!isMobileWeb || !isVisible || !hasHydrated) {
return null; return null
} }
return ( return (
@@ -101,8 +93,8 @@ const AppDownloadBanner = () => {
<View style={styles.titleContent}> <View style={styles.titleContent}>
<Text style={styles.title}>Télécharge l'app MusicLand</Text> <Text style={styles.title}>Télécharge l'app MusicLand</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Pour une expérience mobile plus fluide, utilise l'application Pour une expérience mobile plus fluide, utilise l'application native et retrouve
native et retrouve toutes les fonctionnalités. toutes les fonctionnalités.
</Text> </Text>
</View> </View>
</View> </View>
@@ -119,38 +111,38 @@ const AppDownloadBanner = () => {
<View style={styles.actions}> <View style={styles.actions}>
<TouchableOpacity <TouchableOpacity
style={[styles.cta, styles.appStoreCta]} style={[styles.cta, styles.appStoreCta]}
onPress={() => handleOpenStore("ios")} onPress={() => handleOpenStore('ios')}
> >
<Text style={styles.ctaText}>App Store</Text> <Text style={styles.ctaText}>App Store</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.cta, styles.playStoreCta]} style={[styles.cta, styles.playStoreCta]}
onPress={() => handleOpenStore("android")} onPress={() => handleOpenStore('android')}
> >
<Text style={styles.ctaText}>Google Play</Text> <Text style={styles.ctaText}>Google Play</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</View> </View>
); )
}; }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
position: "fixed", position: 'fixed',
bottom: gutters, bottom: gutters,
left: gutters, left: gutters,
right: gutters, right: gutters,
alignItems: "center", alignItems: 'center',
zIndex: 80, zIndex: 80,
}, },
banner: { banner: {
width: "100%", width: '100%',
maxWidth: 520, maxWidth: 520,
borderRadius: 18, borderRadius: 18,
paddingVertical: 14, paddingVertical: 14,
paddingHorizontal: 16, paddingHorizontal: 16,
backgroundColor: "rgba(12, 10, 16, 0.95)", backgroundColor: 'rgba(12, 10, 16, 0.95)',
borderWidth: 1, borderWidth: 1,
borderColor: Palette.ultraLightWhite, borderColor: Palette.ultraLightWhite,
shadowColor: Palette.black, shadowColor: Palette.black,
@@ -160,29 +152,29 @@ const styles = StyleSheet.create({
elevation: 10, elevation: 10,
}, },
headerRow: { headerRow: {
flexDirection: "row", flexDirection: 'row',
alignItems: "flex-start", alignItems: 'flex-start',
marginBottom: 12, marginBottom: 12,
}, },
titleRow: { titleRow: {
flex: 1, flex: 1,
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
}, },
logoWrapper: { logoWrapper: {
width: 50, width: 50,
height: 50, height: 50,
borderRadius: 12, borderRadius: 12,
backgroundColor: "rgba(255, 255, 255, 0.06)", backgroundColor: 'rgba(255, 255, 255, 0.06)',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
borderWidth: 1, borderWidth: 1,
borderColor: Palette.ultraLightWhite, borderColor: Palette.ultraLightWhite,
}, },
logo: { logo: {
width: "80%", width: '80%',
height: "80%", height: '80%',
resizeMode: "contain", resizeMode: 'contain',
}, },
titleContent: { titleContent: {
flex: 1, flex: 1,
@@ -210,16 +202,16 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
}, },
actions: { actions: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
}, },
cta: { cta: {
flex: 1, flex: 1,
height: 46, height: 46,
borderRadius: 12, borderRadius: 12,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
borderWidth: 1, borderWidth: 1,
borderColor: Palette.ultraLightWhite, borderColor: Palette.ultraLightWhite,
}, },
@@ -237,6 +229,6 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold, 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 { Text, View } from 'react-native'
import { Image } from "expo-image"; import { Image } from 'expo-image'
import { responsiveWidth } from "../actions/responsiveSizes.js"; import { responsiveWidth } from '../actions/responsiveSizes.js'
import { formatImageURL, getInitials } from "../helpers"; import { formatImageURL, getInitials } from '../helpers'
import { Fonts, Palette, Style } from "../styles"; import { Fonts, Palette, Style } from '../styles'
import Badge from "./Badge.js"; import Badge from './Badge.js'
export default ({ export default ({
name = "", name = '',
url = null, url = null,
size = responsiveWidth(7), size = responsiveWidth(7),
containerStyle = {}, containerStyle = {},
forceRawImage = false, forceRawImage = false,
badge = {}, badge = {},
}) => { }) => {
const uniqColorById = (uniqId = "test") => { const uniqColorById = (uniqId = 'test') => {
// Calculer un nombre unique à partir de l'ID de l'employé // 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++) { for (let i = 0; i < uniqId?.length; i++) {
uniqueNumber += uniqId.charCodeAt(i); uniqueNumber += uniqId.charCodeAt(i)
} }
// génère des couleurs pastels claires // 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 ( return (
<View <View
@@ -39,14 +39,14 @@ export default ({
<View <View
style={{ style={{
...Style.containerRound, ...Style.containerRound,
width: "100%", width: '100%',
height: "100%", height: '100%',
backgroundColor: url ? "transparent" : uniqColorById(name), backgroundColor: url ? 'transparent' : uniqColorById(name),
}} }}
> >
{url ? ( {url ? (
<Image <Image
cachePolicy={"memory"} cachePolicy={'memory'}
source={{ source={{
uri: forceRawImage ? url : formatImageURL({ url, size: 200 }), uri: forceRawImage ? url : formatImageURL({ url, size: 200 }),
}} }}
@@ -59,7 +59,7 @@ export default ({
) : ( ) : (
<Text <Text
style={Fonts({ style={Fonts({
type: "section", type: 'section',
color: Palette.darkPurple, color: Palette.darkPurple,
style: { fontSize: size / 3 }, style: { fontSize: size / 3 },
})} })}
@@ -70,5 +70,5 @@ export default ({
</View> </View>
<Badge {...badge} /> <Badge {...badge} />
</View> </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 = ({ const Badge = ({ count = 0, size = 20, customContent = null, backgroundColor = null } = {}) => {
count = 0,
size = 20,
customContent = null,
backgroundColor = null,
} = {}) => {
if (!count && !customContent) { if (!count && !customContent) {
return null; return null
} }
return ( return (
<Motion.View <Motion.View
animate={{ scale: 1 }} animate={{ scale: 1 }}
initial={{ scale: 0 }} initial={{ scale: 0 }}
transition={{ type: "tween", duration: 0.5 }} transition={{ type: 'tween', duration: 0.5 }}
style={{ style={{
position: "absolute", position: 'absolute',
top: -size / 4, top: -size / 4,
right: -size / 4, right: -size / 4,
backgroundColor: backgroundColor || Palette.red, backgroundColor: backgroundColor || Palette.red,
@@ -34,7 +29,7 @@ const Badge = ({
<Motion.Text <Motion.Text
animate={{ scale: 1 }} animate={{ scale: 1 }}
initial={{ scale: 0 }} initial={{ scale: 0 }}
transition={{ type: "tween", duration: 0.5 }} transition={{ type: 'tween', duration: 0.5 }}
style={{ style={{
...Fonts({ color: Palette.white }), ...Fonts({ color: Palette.white }),
}} }}
@@ -43,7 +38,7 @@ const Badge = ({
</Motion.Text> </Motion.Text>
)} )}
</Motion.View> </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 { Image, Pressable, Text, View } from 'react-native'
import React from "reactn"; import React from 'reactn'
import { responsiveWidth } from "../actions/responsiveSizes.js"; import { responsiveWidth } from '../actions/responsiveSizes.js'
import { icons } from "../assets"; import { icons } from '../assets'
import { Fonts, Style, gutters } from "../styles"; import { Fonts, Style, gutters } from '../styles'
import { Routes } from "../navigation"; import { Routes } from '../navigation'
import { navigate } from "../navigation/NavigationService"; import { navigate } from '../navigation/NavigationService'
import useLayoutType, { sidebarWidth } from "../hooks/useLayoutType.js"; import useLayoutType, { sidebarWidth } from '../hooks/useLayoutType.js'
import useNotifications from "../hooks/useNotifications"; import useNotifications from '../hooks/useNotifications'
export default ({ title = "", containerStyle = {}, bell = false }) => { export default ({ title = '', containerStyle = {}, bell = false }) => {
const { isDesktop } = useLayoutType(); const { isDesktop } = useLayoutType()
const notificationsContext = useNotifications(); const notificationsContext = useNotifications()
const unreadCount = notificationsContext?.unreadCount || 0; const unreadCount = notificationsContext?.unreadCount || 0
const hasUnread = unreadCount > 0; const hasUnread = unreadCount > 0
return ( return (
<View <View style={[Style.containerSpaceBetween, { marginBottom: 20, ...containerStyle }]}>
style={[
Style.containerSpaceBetween,
{ marginBottom: 20, ...containerStyle },
]}
>
<View style={Style.containerRow}> <View style={Style.containerRow}>
<Text <Text
numberOfLines={1} numberOfLines={1}
style={{ style={{
...Fonts({ ...Fonts({
type: "mainTitle", type: 'mainTitle',
style: { style: {
marginRight: 10, marginRight: 10,
maxWidth: isDesktop maxWidth: isDesktop ? sidebarWidth - 4 * gutters : responsiveWidth(70),
? sidebarWidth - 4 * gutters
: responsiveWidth(70),
}, },
}), }),
}} }}
@@ -65,5 +58,5 @@ export default ({ title = "", containerStyle = {}, bell = false }) => {
</Pressable> </Pressable>
</View> </View>
</View> </View>
); )
}; }
+15 -22
View File
@@ -1,23 +1,16 @@
import { import { View, Text, Pressable, StyleSheet, Image, Platform } from 'react-native'
View, import React from 'react'
Text, import { BlurView } from 'expo-blur'
Pressable, import { Palette, Style } from '../styles'
StyleSheet, import { FONT_FAMILY } from '../styles/Fonts'
Image, import { icons } from '../assets'
Platform, import { size } from '../styles/Style'
} 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 ( return (
<Pressable onPress={onPress}> <Pressable onPress={onPress}>
<BlurView <BlurView
intensity={Platform.OS === "ios" ? 20 : 10} intensity={Platform.OS === 'ios' ? 20 : 10}
style={styles.buttonContainer} style={styles.buttonContainer}
// experimentalBlurMethod={ // experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none" // Platform.OS !== "ios" ? "dimezisBlurView" : "none"
@@ -28,16 +21,16 @@ const BlurItemButton = ({ onPress, title = "" }) => {
source={icons.chevronDown} source={icons.chevronDown}
style={{ style={{
...size({ size: 15 }), ...size({ size: 15 }),
transform: [{ rotate: "-90deg" }], transform: [{ rotate: '-90deg' }],
}} }}
resizeMode="contain" resizeMode="contain"
/> />
</BlurView> </BlurView>
</Pressable> </Pressable>
); )
}; }
export default BlurItemButton; export default BlurItemButton
const styles = StyleSheet.create({ const styles = StyleSheet.create({
buttonContainer: { buttonContainer: {
@@ -45,7 +38,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
height: 56, height: 56,
borderRadius: 14, borderRadius: 14,
overflow: "hidden", overflow: 'hidden',
backgroundColor: Palette.glass, backgroundColor: Palette.glass,
}, },
buttonText: { buttonText: {
@@ -53,4 +46,4 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular, 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 = ({ const BorderGradient = ({ children, contentContainerStyle, gradientProps, ...props }) => {
children,
contentContainerStyle,
gradientProps,
...props
}) => {
return ( return (
<GradientBorderView <GradientBorderView
gradientProps={{ gradientProps={{
@@ -26,13 +21,13 @@ const BorderGradient = ({
{children} {children}
</View> </View>
</GradientBorderView> </GradientBorderView>
); )
}; }
export default BorderGradient; export default BorderGradient
const styles = StyleSheet.create({ const styles = StyleSheet.create({
innerContainer: { innerContainer: {
flex: 1, flex: 1,
}, },
}); })
@@ -1,5 +1,5 @@
import { StyleSheet, View } from "react-native"; import { StyleSheet, View } from 'react-native'
import { useState } from "react"; import { useState } from 'react'
const BorderGradient = ({ children, gradientProps, ...props }) => { const BorderGradient = ({ children, gradientProps, ...props }) => {
const defaultGradientProps = { const defaultGradientProps = {
@@ -15,10 +15,9 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
colors: [], colors: [],
useAngle: false, useAngle: false,
angle: 0, angle: 0,
}; }
const { locations, end, start, useAngle, angle, onLayout, colors } = const { locations, end, start, useAngle, angle, onLayout, colors } = gradientProps
gradientProps;
const { const {
style, style,
@@ -32,29 +31,29 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
borderLeftWidth, borderLeftWidth,
borderRightWidth, borderRightWidth,
borderBottomWidth, borderBottomWidth,
} = props; } = props
const propStart = start ?? defaultGradientProps?.start; const propStart = start ?? defaultGradientProps?.start
const propEnd = end ?? defaultGradientProps?.end; const propEnd = end ?? defaultGradientProps?.end
const [state, setState] = useState({ const [state, setState] = useState({
width: 1, width: 1,
height: 1, height: 1,
}); })
const measure = (event) => { const measure = (event) => {
setState({ setState({
width: event.nativeEvent.layout.width, width: event.nativeEvent.layout.width,
height: event.nativeEvent.layout.height, height: event.nativeEvent.layout.height,
}); })
if (onLayout) { if (onLayout) {
onLayout(event); onLayout(event)
} }
}; }
const getAngle = () => { const getAngle = () => {
if (useAngle) { if (useAngle) {
return angle + "deg"; return angle + 'deg'
} }
// Math.atan2 handles Infinity // Math.atan2 handles Infinity
@@ -63,26 +62,26 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
state.width * (propEnd.y - propStart.y), state.width * (propEnd.y - propStart.y),
state.height * (propEnd.x - propStart.x) state.height * (propEnd.x - propStart.x)
) + ) +
Math.PI / 2; Math.PI / 2
return _angle + "rad"; return _angle + 'rad'
}; }
const getColors = () => const getColors = () =>
colors colors
.map((color, index) => { .map((color, index) => {
const location = locations?.[index] ?? defaultGradientProps.locations; const location = locations?.[index] ?? defaultGradientProps.locations
let locationStyle = ""; let locationStyle = ''
if (location) { if (location) {
locationStyle = " " + location * 100 + "%"; locationStyle = ' ' + location * 100 + '%'
} }
return color + locationStyle; return color + locationStyle
}) })
.join(","); .join(',')
return ( return (
<View <View
style={{ style={{
position: "relative", position: 'relative',
}} }}
> >
<View <View
@@ -100,35 +99,34 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
borderLeftWidth, borderLeftWidth,
borderRightWidth, borderRightWidth,
borderBottomWidth, borderBottomWidth,
borderStyle: "solid", borderStyle: 'solid',
borderColor: "transparent", borderColor: 'transparent',
// borderImage: `linear-gradient(${getAngle()},${getColors()}) 1`, // borderImage: `linear-gradient(${getAngle()},${getColors()}) 1`,
background: `linear-gradient(${getAngle()},${getColors()}) border-box`, background: `linear-gradient(${getAngle()},${getColors()}) border-box`,
WebkitMask: WebkitMask: 'linear-gradient(#fff 0 0) padding-box,linear-gradient(#fff 0 0)',
"linear-gradient(#fff 0 0) padding-box,linear-gradient(#fff 0 0)", WebkitMaskComposite: 'xor',
WebkitMaskComposite: "xor", maskComposite: 'exclude',
maskComposite: "exclude", overflow: 'hidden',
overflow: "hidden",
}, },
]} ]}
></View> ></View>
<View style={styles.innerContainer}>{children}</View> <View style={styles.innerContainer}>{children}</View>
</View> </View>
); )
}; }
export default BorderGradient; export default BorderGradient
const styles = StyleSheet.create({ const styles = StyleSheet.create({
innerContainer: { innerContainer: {
flex: 1, flex: 1,
position: "absolute", position: 'absolute',
top: 0, top: 0,
bottom: 0, bottom: 0,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
right: 0, right: 0,
left: 0, left: 0,
overflow: "hidden", overflow: 'hidden',
}, },
}); })
+27 -30
View File
@@ -1,40 +1,39 @@
import { BlurView } from "expo-blur"; import { BlurView } from 'expo-blur'
import React from "react"; import React from 'react'
import { Image, Pressable, Text, View } from "react-native"; import { Image, Pressable, Text, View } from 'react-native'
import { Palette, Style } from "../styles"; import { Palette, Style } from '../styles'
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from '../styles/Fonts'
import { size as sizeStyle } from "../styles/Style"; import { size as sizeStyle } from '../styles/Style'
import BorderGradient from "./BorderGradient/BorderGradient"; import BorderGradient from './BorderGradient/BorderGradient'
const HEIGHT_BY_SIZE = { const HEIGHT_BY_SIZE = {
small: 40, small: 40,
medium: 50, medium: 50,
large: 58, large: 58,
}; }
const FONT_SIZE_BY_SIZE = { const FONT_SIZE_BY_SIZE = {
small: 13, small: 13,
medium: 15, medium: 15,
large: 17, large: 17,
}; }
const BorderGradientButton = ({ const BorderGradientButton = ({
title = "Jai déjà mes paroles", title = 'Jai déjà mes paroles',
onPress, onPress,
icon, icon,
titleStyle, titleStyle,
containerStyle = {}, containerStyle = {},
tint = "dark", tint = 'dark',
disabled = false, disabled = false,
maxWidth = null, maxWidth = null,
size = "medium", size = 'medium',
height = null, height = null,
}) => { }) => {
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium"; const resolvedSize = HEIGHT_BY_SIZE[size] ? size : 'medium'
const buttonHeight = const buttonHeight = typeof height === 'number' ? height : HEIGHT_BY_SIZE[resolvedSize]
typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize]; const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]; const iconSize = resolvedSize === 'small' ? 14 : 16
const iconSize = resolvedSize === "small" ? 14 : 16;
return ( return (
<Pressable <Pressable
@@ -51,7 +50,7 @@ const BorderGradientButton = ({
> >
<BorderGradient <BorderGradient
gradientProps={{ gradientProps={{
colors: ["#F94697", "#7023F7"], colors: ['#F94697', '#7023F7'],
start: { x: 0, y: 0 }, start: { x: 0, y: 0 },
end: { x: 1, y: 0 }, end: { x: 1, y: 0 },
locations: [0, 1], locations: [0, 1],
@@ -66,27 +65,25 @@ const BorderGradientButton = ({
<View <View
style={{ style={{
borderRadius: 14, borderRadius: 14,
overflow: "hidden", overflow: 'hidden',
backgroundColor: "#73737324", backgroundColor: '#73737324',
width: "100%", width: '100%',
height: "100%", height: '100%',
}} }}
> >
<BlurView <BlurView
intensity={40} intensity={40}
tint={tint} tint={tint}
style={{ style={{
width: "100%", width: '100%',
height: "100%", height: '100%',
...Style.containerCenter, ...Style.containerCenter,
...Style.containerRow, ...Style.containerRow,
borderRadius: 14, borderRadius: 14,
gap: 11, gap: 11,
}} }}
> >
{icon && ( {icon && <Image source={icon} style={sizeStyle({ size: iconSize })} />}
<Image source={icon} style={sizeStyle({ size: iconSize })} />
)}
<Text <Text
style={{ style={{
fontSize, fontSize,
@@ -101,7 +98,7 @@ const BorderGradientButton = ({
</View> </View>
</BorderGradient> </BorderGradient>
</Pressable> </Pressable>
); )
}; }
export default BorderGradientButton; export default BorderGradientButton
+21 -21
View File
@@ -1,18 +1,18 @@
import { BlurView } from "expo-blur"; import { BlurView } from 'expo-blur'
import React from "react"; import React from 'react'
import { Image, Pressable, Text, View } from "react-native"; import { Image, Pressable, Text, View } from 'react-native'
import { Palette, Style } from "../styles"; import { Palette, Style } from '../styles'
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from '../styles/Fonts'
import { size } from "../styles/Style"; import { size } from '../styles/Style'
import RotationBorder from "./RotationBorder/RotationBorder"; import RotationBorder from './RotationBorder/RotationBorder'
const BorderGradientButton = ({ const BorderGradientButton = ({
title = "Jai déjà mes paroles", title = 'Jai déjà mes paroles',
onPress, onPress,
icon, icon,
titleStyle, titleStyle,
containerStyle = {}, containerStyle = {},
tint = "dark", tint = 'dark',
disabled = false, disabled = false,
maxWidth = null, maxWidth = null,
}) => { }) => {
@@ -20,35 +20,35 @@ const BorderGradientButton = ({
...(maxWidth ? { maxWidth } : {}), ...(maxWidth ? { maxWidth } : {}),
...containerStyle, ...containerStyle,
opacity: disabled ? 0.6 : 1, opacity: disabled ? 0.6 : 1,
}; }
return ( return (
<Pressable onPress={onPress} disabled={disabled} style={pressableStyle}> <Pressable onPress={onPress} disabled={disabled} style={pressableStyle}>
<RotationBorder <RotationBorder
borderWidth={2} borderWidth={2}
borderRadius={14} borderRadius={14}
colors={["#F94697", "#7023F7"]} colors={['#F94697', '#7023F7']}
style={{ style={{
height: 50, height: 50,
width: "100%", width: '100%',
zIndex: 1, zIndex: 1,
}} }}
> >
<View <View
style={{ style={{
borderRadius: 14, borderRadius: 14,
overflow: "hidden", overflow: 'hidden',
backgroundColor: "#000000b8", backgroundColor: '#000000b8',
width: "100%", width: '100%',
height: "100%", height: '100%',
}} }}
> >
<BlurView <BlurView
intensity={40} intensity={40}
tint={tint} tint={tint}
style={{ style={{
width: "100%", width: '100%',
height: "100%", height: '100%',
...Style.containerCenter, ...Style.containerCenter,
...Style.containerRow, ...Style.containerRow,
borderRadius: 14, borderRadius: 14,
@@ -70,7 +70,7 @@ const BorderGradientButton = ({
</View> </View>
</RotationBorder> </RotationBorder>
</Pressable> </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 React, { useImperativeHandle, useState, useRef } from 'react'
import { View, TouchableOpacity, ScrollView } from "react-native"; import { View, TouchableOpacity, ScrollView } from 'react-native'
import { Portal } from "@gorhom/portal"; import { Portal } from '@gorhom/portal'
import { Motion } from "@legendapp/motion"; import { Motion } from '@legendapp/motion'
import { Palette } from "../../styles"; import { Palette } from '../../styles'
import { import { isDesktop, isLargeDesktop, sidebarWidth } from '../../hooks/useLayoutType'
isDesktop,
isLargeDesktop,
sidebarWidth,
} from "../../hooks/useLayoutType";
export const SheetScrollView = ScrollView; export const SheetScrollView = ScrollView
export const SheetBackdrop = View; export const SheetBackdrop = View
const BottomSheet = React.forwardRef((props, ref) => { const BottomSheet = React.forwardRef((props, ref) => {
const [showSheet, setShowSheet] = useState(false); const [showSheet, setShowSheet] = useState(false)
const bottomSheetRef = useRef(); const bottomSheetRef = useRef()
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
snapToIndex: () => { snapToIndex: () => {
setShowSheet(true); setShowSheet(true)
}, },
expand: () => { expand: () => {
setShowSheet(true); setShowSheet(true)
}, },
collapse: () => closeBottomSheet(), collapse: () => closeBottomSheet(),
close: () => closeBottomSheet(), close: () => closeBottomSheet(),
})); }))
const closeBottomSheet = () => { const closeBottomSheet = () => {
setShowSheet(false); setShowSheet(false)
props.onChange(-1); props.onChange(-1)
}; }
if (!showSheet) { if (!showSheet) {
return null; return null
} }
return ( return (
@@ -44,51 +40,49 @@ const BottomSheet = React.forwardRef((props, ref) => {
initial={{ right: -500, opacity: 0 }} initial={{ right: -500, opacity: 0 }}
animate={{ right: 0, opacity: 1 }} animate={{ right: 0, opacity: 1 }}
style={{ style={{
position: "fixed", position: 'fixed',
right: 0, right: 0,
top: 0, top: 0,
left: 0, left: 0,
bottom: 0, bottom: 0,
flex: 1, flex: 1,
zIndex: 1000000, zIndex: 1000000,
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
}} }}
> >
<TouchableOpacity <TouchableOpacity
onPress={closeBottomSheet} onPress={closeBottomSheet}
ref={bottomSheetRef} ref={bottomSheetRef}
style={{ style={{
position: "absolute", position: 'absolute',
right: 0, right: 0,
top: 0, top: 0,
left: 0, left: 0,
bottom: 0, bottom: 0,
flex: 1, flex: 1,
backgroundColor: "rgba(0,0,0,0.4)", backgroundColor: 'rgba(0,0,0,0.4)',
}} }}
/> />
<View <View
style={{ style={{
position: "absolute", position: 'absolute',
top: 0, top: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
height: "100%", height: '100%',
width: isDesktop width: isDesktop ? sidebarWidth * (isLargeDesktop ? 2 : 1.5) : '100%',
? sidebarWidth * (isLargeDesktop ? 2 : 1.5)
: "100%",
backgroundColor: Palette.lightPurple, backgroundColor: Palette.lightPurple,
overflow: "scroll", overflow: 'scroll',
}} }}
> >
{props.children} {props.children}
</View> </View>
</Motion.View> </Motion.View>
</Portal> </Portal>
); )
}); })
// TODO une croix pour fermer sur mobile web // 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 { AnimatePresence, Motion } from '@legendapp/motion'
import { useKeyboard } from "@react-native-community/hooks"; import { useKeyboard } from '@react-native-community/hooks'
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from 'react'
import { Keyboard, StyleSheet } from "react-native"; import { Keyboard, StyleSheet } from 'react-native'
import BottomSheet from "./BottomSheet"; import BottomSheet from './BottomSheet'
import useLayoutType from "../hooks/useLayoutType"; import useLayoutType from '../hooks/useLayoutType'
import { Palette } from "../styles"; import { Palette } from '../styles'
export default ({ export default ({
children, children,
bottomSheetRef, bottomSheetRef,
snapPoints = ["25%", "50%"], snapPoints = ['25%', '50%'],
handleStyle = {}, handleStyle = {},
...rest ...rest
}) => { }) => {
const [currentSnapPointIndex, setCurrentSnapPointIndex] = useState(0); const [currentSnapPointIndex, setCurrentSnapPointIndex] = useState(0)
const { keyboardShown = false } = useKeyboard(); const { keyboardShown = false } = useKeyboard()
const { isWeb } = useLayoutType(); const { isWeb } = useLayoutType()
const handleSheetChanges = useCallback((index) => { const handleSheetChanges = useCallback((index) => {
setCurrentSnapPointIndex(index); setCurrentSnapPointIndex(index)
if (index <= 0) { if (index <= 0) {
Keyboard.dismiss(); Keyboard.dismiss()
} }
}, []); }, [])
useEffect(() => { useEffect(() => {
if (bottomSheetRef.current && !isWeb && currentSnapPointIndex > 0) { if (bottomSheetRef.current && !isWeb && currentSnapPointIndex > 0) {
if (keyboardShown) { if (keyboardShown) {
bottomSheetRef.current.expand(); bottomSheetRef.current.expand()
} else { } else {
bottomSheetRef.current.snapToIndex(1); bottomSheetRef.current.snapToIndex(1)
} }
} }
}, [keyboardShown]); }, [keyboardShown])
return ( return (
<> <>
@@ -47,7 +47,7 @@ export default ({
onPress={() => bottomSheetRef?.current?.close()} onPress={() => bottomSheetRef?.current?.close()}
> >
<Motion.View <Motion.View
key={"A"} key={'A'}
style={{ style={{
flex: 1, flex: 1,
backgroundColor: Palette.black, backgroundColor: Palette.black,
@@ -57,10 +57,10 @@ export default ({
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
default: { default: {
type: "spring", type: 'spring',
}, },
opacity: { opacity: {
type: "timing", type: 'timing',
}, },
}} }}
></Motion.View> ></Motion.View>
@@ -83,5 +83,5 @@ export default ({
{children} {children}
</BottomSheet> </BottomSheet>
</> </>
); )
}; }
+33 -36
View File
@@ -1,17 +1,17 @@
import { Motion } from "@legendapp/motion"; import { Motion } from '@legendapp/motion'
import * as Haptics from "expo-haptics"; import * as Haptics from 'expo-haptics'
import { Text, View } from "react-native"; import { Text, View } from 'react-native'
import { isDesktop, isMobile, isNative } from "../hooks/useLayoutType"; import { isDesktop, isMobile, isNative } from '../hooks/useLayoutType'
import { Fonts, Palette } from "../styles"; import { Fonts, Palette } from '../styles'
import Style, { gutters, mainBorderRadius } from "../styles/Style"; import Style, { gutters, mainBorderRadius } from '../styles/Style'
export default ({ export default ({
type = "primary", type = 'primary',
theme = "default", // "default" | "radioactiv" theme = 'default', // "default" | "radioactiv"
text, text,
onPress = () => console.log("null"), onPress = () => console.log('null'),
isAbsoluteBottom = false, isAbsoluteBottom = false,
alternateAction = {}, alternateAction = {},
@@ -23,39 +23,39 @@ export default ({
isMainDesktopPanel = false, isMainDesktopPanel = false,
}) => { }) => {
const buttonWidth = alternateAction?.text ? "49%" : "100%"; const buttonWidth = alternateAction?.text ? '49%' : '100%'
return ( return (
<View <View
style={{ style={{
flexDirection: "row", flexDirection: 'row',
justifyContent: alternateAction?.text ? "space-between" : "center", justifyContent: alternateAction?.text ? 'space-between' : 'center',
...(isAbsoluteBottom ...(isAbsoluteBottom
? { ? {
position: "absolute", position: 'absolute',
bottom: gutters * (isMobile ? 2 : 1), bottom: gutters * (isMobile ? 2 : 1),
alignItems: "flex-end", alignItems: 'flex-end',
...(isDesktop && !isMainDesktopPanel ...(isDesktop && !isMainDesktopPanel
? { ? {
maxWidth: 600, maxWidth: 600,
minWidth: 400, minWidth: 400,
alignSelf: "center", alignSelf: 'center',
} }
: { : {
right: gutters, right: gutters,
left: gutters, left: gutters,
alignSelf: "center", alignSelf: 'center',
}), }),
} }
: { : {
width: "100%", width: '100%',
}), }),
...contentContainerStyle, ...contentContainerStyle,
}} }}
> >
{alternateAction?.text && alternateAction?.onPress ? ( {alternateAction?.text && alternateAction?.onPress ? (
<BaseButton <BaseButton
type={"secondary"} type={'secondary'}
theme={alternateAction?.theme || theme} theme={alternateAction?.theme || theme}
text={alternateAction.text} text={alternateAction.text}
onPress={alternateAction.onPress} onPress={alternateAction.onPress}
@@ -83,45 +83,42 @@ export default ({
}} }}
/> />
</View> </View>
); )
}; }
export const BaseButton = ({ export const BaseButton = ({
type = "primary", type = 'primary',
theme = "default", theme = 'default',
text, text,
onPress = () => console.log("null"), onPress = () => console.log('null'),
containerStyle = {}, containerStyle = {},
textStyle = {}, textStyle = {},
hasShadow = false, hasShadow = false,
}) => { }) => {
const primaryColor = const primaryColor = theme === 'radioactiv' ? Palette.radioactivGreen : Palette.primary
theme === "radioactiv" ? Palette.radioactivGreen : Palette.primary;
const primaryTransparentColor = const primaryTransparentColor =
theme === "radioactiv" theme === 'radioactiv' ? Palette.transparentRadioactivGreen : Palette.transparentPrimary
? Palette.transparentRadioactivGreen
: Palette.transparentPrimary;
const textColor = type === "secondary" ? primaryColor : Palette.darkPurple; const textColor = type === 'secondary' ? primaryColor : Palette.darkPurple
return ( return (
<Motion.Pressable <Motion.Pressable
whileTap={{ scale: 0.8 }} whileTap={{ scale: 0.8 }}
onPress={() => { onPress={() => {
if (isNative) { if (isNative) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
} }
onPress(); onPress()
}} }}
style={{ style={{
width: "100%", width: '100%',
height: 50, height: 50,
marginTop: gutters / 2, marginTop: gutters / 2,
...Style.containerRow, ...Style.containerRow,
...Style.containerCenter, ...Style.containerCenter,
backgroundColor: primaryColor, backgroundColor: primaryColor,
borderRadius: mainBorderRadius, borderRadius: mainBorderRadius,
...(type === "secondary" ...(type === 'secondary'
? { ? {
backgroundColor: primaryTransparentColor, backgroundColor: primaryTransparentColor,
} }
@@ -134,7 +131,7 @@ export const BaseButton = ({
<Text <Text
style={{ style={{
...Fonts({ ...Fonts({
type: "default", type: 'default',
color: textColor, color: textColor,
}), }),
...textStyle, ...textStyle,
@@ -143,5 +140,5 @@ export const BaseButton = ({
{text} {text}
</Text> </Text>
</Motion.Pressable> </Motion.Pressable>
); )
}; }
+36 -36
View File
@@ -1,32 +1,32 @@
import { useState, useEffect } from "reactn"; import { useState, useEffect } from 'reactn'
import { Pressable, TextInput, View, Image, Platform } from "react-native"; import { Pressable, TextInput, View, Image, Platform } from 'react-native'
import { BlurView } from "expo-blur"; import { BlurView } from 'expo-blur'
import { Fonts, gutters, Palette } from "../styles"; import { Fonts, gutters, Palette } from '../styles'
import Style from "../styles/Style"; 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 DocumentDropZone from './DocumentDropZone.js'
import FilesPreview from "./FilesPreview.js"; import FilesPreview from './FilesPreview.js'
const ChatInput = ({ const ChatInput = ({
message, message,
setMessage, setMessage,
onSendMessage, onSendMessage,
containerStyle = {}, containerStyle = {},
placeholder = "", placeholder = '',
chatID = null, chatID = null,
}) => { }) => {
const [files, setFiles] = useState([]); const [files, setFiles] = useState([])
useEffect(() => { useEffect(() => {
setFiles([]); setFiles([])
}, [chatID]); }, [chatID])
const handleMessageObject = () => { const handleMessageObject = () => {
if (message.length > 0 || files.length > 0) { if (message.length > 0 || files.length > 0) {
@@ -34,37 +34,37 @@ const ChatInput = ({
customPayload: { customPayload: {
files, files,
}, },
}); })
setMessage(""); setMessage('')
setFiles([]); setFiles([])
} }
}; }
const handleKeyPress = (e) => { const handleKeyPress = (e) => {
if (e?.nativeEvent?.key?.toLowerCase() === "enter") { if (e?.nativeEvent?.key?.toLowerCase() === 'enter') {
handleMessageObject(); handleMessageObject()
} }
}; }
return ( return (
<View <View
style={{ style={{
...Style.containerItem, ...Style.containerItem,
backgroundColor: Palette.transparentDarkPurple, backgroundColor: Palette.transparentDarkPurple,
justifyContent: "center", justifyContent: 'center',
borderWidth: 1, borderWidth: 1,
borderColor: Palette.ultraLightWhite, borderColor: Palette.ultraLightWhite,
overflow: "hidden", overflow: 'hidden',
width: "100%", width: '100%',
height: "auto", height: 'auto',
padding: 0, padding: 0,
...containerStyle, ...containerStyle,
}} }}
> >
<BlurView <BlurView
intensity={Platform.OS !== "ios" ? 10 : 30} intensity={Platform.OS !== 'ios' ? 10 : 30}
tint="dark" tint="dark"
style={{ flex: 1, justifyContent: "center" }} style={{ flex: 1, justifyContent: 'center' }}
// experimentalBlurMethod={ // experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none" // Platform.OS !== "ios" ? "dimezisBlurView" : "none"
// } // }
@@ -101,13 +101,13 @@ const ChatInput = ({
value={message} value={message}
onChangeText={setMessage} onChangeText={setMessage}
style={{ style={{
width: "85%", width: '85%',
...Fonts({ type: "default", style: {} }), ...Fonts({ type: 'default', style: {} }),
}} }}
keyboardAppearance="dark" keyboardAppearance="dark"
{...(!isWeb {...(!isWeb
? { ? {
returnKeyType: "send", returnKeyType: 'send',
onSubmitEditing: onSendMessage, onSubmitEditing: onSendMessage,
} }
: { : {
@@ -118,13 +118,13 @@ const ChatInput = ({
<Pressable <Pressable
onPress={handleMessageObject} onPress={handleMessageObject}
style={{ style={{
position: "absolute", position: 'absolute',
top: 0, top: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
width: 50, width: 50,
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
}} }}
> >
<Image <Image
@@ -138,7 +138,7 @@ const ChatInput = ({
</View> </View>
</BlurView> </BlurView>
</View> </View>
); )
}; }
export default ChatInput; export default ChatInput
+70 -94
View File
@@ -1,78 +1,66 @@
import { useState, useRef, useEffect, useGlobal, getGlobal } from "reactn"; import { useState, useRef, useEffect, useGlobal, getGlobal } from 'reactn'
import { import { Pressable, TextInput, View, Image, Text, Keyboard, FlatList } from 'react-native'
Pressable, import { responsiveHeight } from '../actions/responsiveSizes.js'
TextInput, import { useDataFromRef } from 'react-native-minuit/src/hooks'
View, import { useKeyboard } from '@react-native-community/hooks'
Image, import moment from 'moment'
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 { Fonts, gutters, Palette } from '../styles'
import Style, { bubbleStyle } from "../styles/Style"; 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 { formatNameForConfidentiality } from '../helpers/index.js'
import useLayoutType from "../hooks/useLayoutType.js"; import useLayoutType from '../hooks/useLayoutType.js'
import TypingLoader from "./TypingLoader"; import TypingLoader from './TypingLoader'
import Avatar from "./Avatar"; import Avatar from './Avatar'
import RenderChatFile from "./RenderChatFile.js"; import RenderChatFile from './RenderChatFile.js'
import HyperlinkContainer from "./HyperlinkContainer.js"; import HyperlinkContainer from './HyperlinkContainer.js'
export default ({ export default ({
chatID = null, chatID = null,
layout = "default", // default | taskSideBar layout = 'default', // default | taskSideBar
containerStyle = {}, containerStyle = {},
messageListContainerStyle = {}, messageListContainerStyle = {},
}) => { }) => {
const [currentUID] = useGlobal("currentUID"); const [currentUID] = useGlobal('currentUID')
const [currentProjectData] = useGlobal("currentProjectData"); const [currentProjectData] = useGlobal('currentProjectData')
const [isTyping] = useState(false); const [isTyping] = useState(false)
const flatListRef = useRef(); const flatListRef = useRef()
const { isNative } = useLayoutType(); const { isNative } = useLayoutType()
const { keyboardShown = false } = useKeyboard(); const { keyboardShown = false } = useKeyboard()
const { data: messageList } = useDataFromRef({ const { data: messageList } = useDataFromRef({
ref: chatID ref: chatID
? chatsRef ? chatsRef.doc(chatID).collection('messages').orderBy('createdAt', 'desc').limit(50)
.doc(chatID)
.collection("messages")
.orderBy("createdAt", "desc")
.limit(50)
: null, : null,
simpleRef: false, simpleRef: false,
listener: true, listener: true,
condition: chatID, condition: chatID,
refreshArray: [chatID], refreshArray: [chatID],
documentID: "messageID", documentID: 'messageID',
}); })
let conversation = [ let conversation = [
isTyping ? { senderID: "minuit.ai", userTyping: true } : null, isTyping ? { senderID: 'minuit.ai', userTyping: true } : null,
...(messageList || []), ...(messageList || []),
].filter((item) => item); ].filter((item) => item)
if (layout === "default") { if (layout === 'default') {
conversation = conversation.reverse(); conversation = conversation.reverse()
} }
useEffect(() => { useEffect(() => {
if (flatListRef?.current && isNative && keyboardShown) { if (flatListRef?.current && isNative && keyboardShown) {
flatListRef?.current?.scrollToEnd?.({ animated: true }); flatListRef?.current?.scrollToEnd?.({ animated: true })
} }
}, [keyboardShown, isNative]); }, [keyboardShown, isNative])
return ( return (
<View <View
@@ -92,33 +80,30 @@ export default ({
}} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyExtractor={(item, index) => keyExtractor={(item, index) =>
item?.messageID item?.messageID ? `${item?.messageID?.toString()}-${index}` : `no-messageID-${index}`
? `${item?.messageID?.toString()}-${index}`
: `no-messageID-${index}`
} }
renderItem={({ renderItem={({
item: { item: {
createdAt = null, createdAt = null,
senderID, senderID,
senderName = "", senderName = '',
senderProfilePicture = null, senderProfilePicture = null,
text = "", text = '',
userTyping = false, userTyping = false,
files = [], files = [],
}, },
index, index,
}) => { }) => {
const isCurrentUser = senderID === currentUID; const isCurrentUser = senderID === currentUID
const isChatbot = senderID === "minuit.ai"; const isChatbot = senderID === 'minuit.ai'
const senderData = const senderData = currentProjectData?.teamMembers?.[senderID] || {}
currentProjectData?.teamMembers?.[senderID] || {};
const isDayChange = const isDayChange =
moment(createdAt?.toDate()).format("DD/MM/YYYY") !== moment(createdAt?.toDate()).format('DD/MM/YYYY') !==
moment( moment(conversation[index - 1]?.createdAt?.toDate() || new Date()).format(
conversation[index - 1]?.createdAt?.toDate() || new Date() 'DD/MM/YYYY'
).format("DD/MM/YYYY") || !conversation[index - 1]; ) || !conversation[index - 1]
return ( return (
<> <>
@@ -132,21 +117,19 @@ export default ({
<Text <Text
style={{ style={{
...Fonts({ ...Fonts({
type: "default", type: 'default',
color: Palette.white, color: Palette.white,
style: { textAlign: "center", opacity: 0.5 }, style: { textAlign: 'center', opacity: 0.5 },
}), }),
}} }}
> >
{moment(createdAt?.toDate()).format( {moment(createdAt?.toDate()).format('[Le] DD/MM/YYYY [à] HH:mm')}
"[Le] DD/MM/YYYY [à] HH:mm"
)}
</Text> </Text>
<View <View
style={{ style={{
...Style.separatorHorizontal, ...Style.separatorHorizontal,
width: "100%", width: '100%',
}} }}
/> />
</View> </View>
@@ -156,9 +139,9 @@ export default ({
style={[ style={[
Style.containerRow, Style.containerRow,
{ {
flexDirection: isCurrentUser ? "row-reverse" : "row", flexDirection: isCurrentUser ? 'row-reverse' : 'row',
alignItems: "flex-end", alignItems: 'flex-end',
width: "100%", width: '100%',
marginBottom: gutters / 2, marginBottom: gutters / 2,
}, },
]} ]}
@@ -175,21 +158,15 @@ export default ({
} }
: { : {
marginRight: gutters / 2, marginRight: gutters / 2,
backgroundColor: "transparent", backgroundColor: 'transparent',
borderColor: Palette.primary, borderColor: Palette.primary,
borderWidth: 1, borderWidth: 1,
}), }),
}} }}
> >
<Avatar <Avatar
name={ name={isChatbot ? 'm' : senderData?.name || senderName || ''}
isChatbot ? "m" : senderData?.name || senderName || "" url={senderData?.profilePictureURL || senderProfilePicture || null}
}
url={
senderData?.profilePictureURL ||
senderProfilePicture ||
null
}
size={35} size={35}
/> />
</View> </View>
@@ -197,7 +174,7 @@ export default ({
<View <View
style={{ style={{
alignItems: isCurrentUser ? "flex-end" : "flex-start", alignItems: isCurrentUser ? 'flex-end' : 'flex-start',
}} }}
> >
{files?.map((props, index) => ( {files?.map((props, index) => (
@@ -209,8 +186,8 @@ export default ({
index !== files?.length - 1 index !== files?.length - 1
? gutters / 2 ? gutters / 2
: text?.length > 0 || userTyping : text?.length > 0 || userTyping
? gutters / 2 ? gutters / 2
: 0, : 0,
}} }}
/> />
)) || null} )) || null}
@@ -232,11 +209,11 @@ export default ({
<HyperlinkContainer> <HyperlinkContainer>
<Text <Text
style={Fonts({ style={Fonts({
type: "default", type: 'default',
style: { style: {
color: Palette.white, color: Palette.white,
textAlign: isCurrentUser ? "right" : "left", textAlign: isCurrentUser ? 'right' : 'left',
width: "100%", width: '100%',
}, },
})} })}
> >
@@ -249,30 +226,29 @@ export default ({
</View> </View>
</View> </View>
</> </>
); )
}} }}
/> />
</View> </View>
</View> </View>
); )
}; }
export const onSendMessage = async ({ export const onSendMessage = async ({
chatID = null, chatID = null,
projectID = null, projectID = null,
message = "", message = '',
setMessage = () => {}, setMessage = () => {},
setIsTyping = () => {}, setIsTyping = () => {},
customPayload = {}, customPayload = {},
}) => { }) => {
try { try {
const currentUID = getGlobal()?.currentUID || null; const currentUID = getGlobal()?.currentUID || null
const { name = "", profilePictureURL = null } = const { name = '', profilePictureURL = null } = getGlobal()?.currentUserData || {}
getGlobal()?.currentUserData || {};
if (message.length > 0 || customPayload?.files?.length > 0) { if (message.length > 0 || customPayload?.files?.length > 0) {
Keyboard.dismiss(); Keyboard.dismiss()
setMessage(""); setMessage('')
const messageData = { const messageData = {
projectID, projectID,
@@ -282,13 +258,13 @@ export const onSendMessage = async ({
senderProfilePicture: profilePictureURL || null, senderProfilePicture: profilePictureURL || null,
text: message, text: message,
...customPayload, ...customPayload,
}; }
await chatsRef.doc(chatID).collection("messages").add(messageData); await chatsRef.doc(chatID).collection('messages').add(messageData)
} }
} catch (error) { } catch (error) {
console.log(error); console.log(error)
} finally { } finally {
setIsTyping(false); setIsTyping(false)
} }
}; }
+7 -7
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from 'react'
import { Image } from "react-native"; import { Image } from 'react-native'
import { icons } from "../assets"; import { icons } from '../assets'
const CoinIcon = ({ size = 22, style }) => { const CoinIcon = ({ size = 22, style }) => {
return ( return (
@@ -10,12 +10,12 @@ const CoinIcon = ({ size = 22, style }) => {
{ {
width: size, width: size,
height: size, height: size,
resizeMode: "contain", resizeMode: 'contain',
}, },
style, style,
]} ]}
/> />
); )
}; }
export default CoinIcon; export default CoinIcon
+14 -14
View File
@@ -1,14 +1,14 @@
import { BlurView } from "expo-blur"; import { BlurView } from 'expo-blur'
import React from "react"; import React from 'react'
import { Pressable, Text } from "react-native"; import { Pressable, Text } from 'react-native'
import { Routes } from "../navigation"; import { Routes } from '../navigation'
import { navigate } from "../navigation/NavigationService"; import { navigate } from '../navigation/NavigationService'
import { Palette } from "../styles"; import { Palette } from '../styles'
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from '../styles/Fonts'
export default function ConnectBtn({ style }) { export default function ConnectBtn({ style }) {
const handlePress = () => { const handlePress = () => {
navigate(Routes.Login); navigate(Routes.Login)
}; }
return ( return (
<Pressable <Pressable
@@ -22,9 +22,9 @@ export default function ConnectBtn({ style }) {
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 5, paddingVertical: 5,
borderRadius: 15, borderRadius: 15,
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
}} }}
> >
<Text <Text
@@ -35,9 +35,9 @@ export default function ConnectBtn({ style }) {
marginRight: 5, marginRight: 5,
}} }}
> >
{"Se connecter"} {'Se connecter'}
</Text> </Text>
</BlurView> </BlurView>
</Pressable> </Pressable>
); )
} }
+40 -56
View File
@@ -1,42 +1,42 @@
import React from "react"; import React from 'react'
import { StyleSheet, Text, View } from "react-native"; import { StyleSheet, Text, View } from 'react-native'
import CoinIcon from "./CoinIcon"; import CoinIcon from './CoinIcon'
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from '../styles/Fonts'
const defaultFormatOptions = { const defaultFormatOptions = {
minimumFractionDigits: 0, minimumFractionDigits: 0,
maximumFractionDigits: 0, maximumFractionDigits: 0,
}; }
const formatAmount = (value, options = defaultFormatOptions) => { const formatAmount = (value, options = defaultFormatOptions) => {
if (typeof value !== "number" || !Number.isFinite(value)) { if (typeof value !== 'number' || !Number.isFinite(value)) {
return null; return null
} }
try { try {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat('fr-FR', {
...defaultFormatOptions, ...defaultFormatOptions,
...options, ...options,
}).format(value); }).format(value)
} catch (_error) { } catch (_error) {
return `${value}`; return `${value}`
} }
}; }
const extractNumericValue = (value) => { const extractNumericValue = (value) => {
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
return value; return value
} }
if (typeof value === "string") { if (typeof value === 'string') {
const parsed = Number(value); const parsed = Number(value)
if (Number.isFinite(parsed)) { if (Number.isFinite(parsed)) {
return parsed; return parsed
} }
} }
return null; return null
}; }
const CreditAmount = ({ const CreditAmount = ({
value, value,
@@ -44,78 +44,62 @@ const CreditAmount = ({
textStyle, textStyle,
iconSize = 18, iconSize = 18,
iconStyle, iconStyle,
iconPosition = "right", iconPosition = 'right',
gap = 6, gap = 6,
showPlus = false, showPlus = false,
formatterOptions, formatterOptions,
accessibilityLabel, accessibilityLabel,
}) => { }) => {
const numericValue = React.useMemo( const numericValue = React.useMemo(() => extractNumericValue(value), [value])
() => extractNumericValue(value),
[value],
);
const resolvedValue = const resolvedValue = numericValue !== null ? numericValue : (value ?? 0)
numericValue !== null
? numericValue
: value ?? 0;
const formattedValue = const formattedValue =
numericValue !== null numericValue !== null
? formatAmount(resolvedValue, formatterOptions) ?? `${resolvedValue}` ? (formatAmount(resolvedValue, formatterOptions) ?? `${resolvedValue}`)
: typeof resolvedValue === "string" : typeof resolvedValue === 'string'
? resolvedValue ? resolvedValue
: `${resolvedValue}`; : `${resolvedValue}`
const prefix = const prefix = numericValue !== null && showPlus && numericValue > 0 ? '+' : ''
numericValue !== null && showPlus && numericValue > 0 ? "+" : "";
const a11yLabel = const a11yLabel = accessibilityLabel || `${prefix}${formattedValue} pièces`
accessibilityLabel || `${prefix}${formattedValue} pièces`;
const containerStyles = Array.isArray(style) const containerStyles = Array.isArray(style)
? [styles.container, { gap }, ...style] ? [styles.container, { gap }, ...style]
: [styles.container, { gap }, style]; : [styles.container, { gap }, style]
const textStyles = Array.isArray(textStyle) const textStyles = Array.isArray(textStyle)
? [styles.value, ...textStyle] ? [styles.value, ...textStyle]
: [styles.value, textStyle]; : [styles.value, textStyle]
const iconStyles = Array.isArray(iconStyle) const iconStyles = Array.isArray(iconStyle)
? [styles.icon, ...iconStyle] ? [styles.icon, ...iconStyle]
: [styles.icon, iconStyle]; : [styles.icon, iconStyle]
return ( return (
<View <View style={containerStyles} accessibilityRole="text" accessibilityLabel={a11yLabel}>
style={containerStyles} {iconPosition === 'left' ? <CoinIcon size={iconSize} style={iconStyles} /> : null}
accessibilityRole="text"
accessibilityLabel={a11yLabel}
>
{iconPosition === "left" ? (
<CoinIcon size={iconSize} style={iconStyles} />
) : null}
<Text style={textStyles}>{`${prefix}${formattedValue}`}</Text> <Text style={textStyles}>{`${prefix}${formattedValue}`}</Text>
{iconPosition === "right" ? ( {iconPosition === 'right' ? <CoinIcon size={iconSize} style={iconStyles} /> : null}
<CoinIcon size={iconSize} style={iconStyles} />
) : null}
</View> </View>
); )
}; }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
}, },
value: { value: {
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 16, fontSize: 16,
color: "#fff", color: '#fff',
}, },
icon: { icon: {
width: 18, width: 18,
height: 18, height: 18,
}, },
}); })
export default CreditAmount; export default CreditAmount
+6 -7
View File
@@ -1,8 +1,7 @@
import Dialog from "react-native-dialog"; 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;
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 React from 'react'
import { Text, View } from "react-native"; import { Text, View } from 'react-native'
import { Input as MinuitInput } from "../Input"; import { Input as MinuitInput } from '../Input'
import { BaseButton as MinuitButton } from "../Button"; import { BaseButton as MinuitButton } from '../Button'
import Overlay from "../Overlay"; import Overlay from '../Overlay'
import { Fonts, Palette, gutters } from "../../styles"; import { Fonts, Palette, gutters } from '../../styles'
import Style from "../../styles/Style"; import Style from '../../styles/Style'
export const Container = ({ visible, setVisible = () => null, children }) => { export const Container = ({ visible, setVisible = () => null, children }) => {
return ( return (
<Overlay isVisible={visible}> <Overlay isVisible={visible}>
<View style={{ ...Style.containerModal }}>{children}</View> <View style={{ ...Style.containerModal }}>{children}</View>
</Overlay> </Overlay>
); )
}; }
export const Title = ({ children }) => { export const Title = ({ children }) => {
return ( return (
<Text <Text
style={{ style={{
...Fonts({ ...Fonts({
type: "title", type: 'title',
style: { style: {
textAlign: "center", textAlign: 'center',
marginBottom: gutters, marginBottom: gutters,
}, },
}), }),
@@ -31,17 +31,17 @@ export const Title = ({ children }) => {
> >
{children} {children}
</Text> </Text>
); )
}; }
export const Description = ({ children }) => { export const Description = ({ children }) => {
return ( return (
<Text <Text
style={{ style={{
...Fonts({ ...Fonts({
type: "default", type: 'default',
style: { style: {
textAlign: "center", textAlign: 'center',
marginBottom: gutters / 2, marginBottom: gutters / 2,
}, },
}), }),
@@ -49,29 +49,24 @@ export const Description = ({ children }) => {
> >
{children} {children}
</Text> </Text>
); )
}; }
export const Button = ({ export const Button = ({ label, onPress, type = 'primary', containerStyle = {} }) => {
label,
onPress,
type = "primary",
containerStyle = {},
}) => {
return ( return (
<MinuitButton <MinuitButton
containerStyle={{ containerStyle={{
width: "100%", width: '100%',
alignSelf: "center", alignSelf: 'center',
...containerStyle, ...containerStyle,
}} }}
text={label} text={label}
onPress={onPress} onPress={onPress}
type={type} type={type}
/> />
); )
}; }
export const Input = (props) => { 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 { useActionSheet } from '@expo/react-native-action-sheet'
import * as DocumentPicker from "expo-document-picker"; import * as DocumentPicker from 'expo-document-picker'
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from 'expo-image-picker'
import moment from "moment"; import moment from 'moment'
import { Image, Pressable, Text, View } from "react-native"; import { Image, Pressable, Text, View } from 'react-native'
import Compressor from "react-native-compressor"; import Compressor from 'react-native-compressor'
import useMinuit from "react-native-minuit/src/hooks/useMinuit"; import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
import React, { useCallback, useEffect, useRef, useState } from "reactn"; import React, { useCallback, useEffect, useRef, useState } from 'reactn'
import { arrayUnion } from "../config/firebase"; import { arrayUnion } from '../config/firebase'
import { icons } from "../assets"; import { icons } from '../assets'
import { Fonts, Palette } from "../styles"; import { Fonts, Palette } from '../styles'
import Style, { gutterConstant, mainBorderRadius } from "../styles/Style"; import Style, { gutterConstant, mainBorderRadius } from '../styles/Style'
import { uploadFileToFirebase } from "../helpers/uploadToFirebase"; import { uploadFileToFirebase } from '../helpers/uploadToFirebase'
import useLayoutType from "../hooks/useLayoutType"; import useLayoutType from '../hooks/useLayoutType'
const NATIVE_OPTIONS = [ const NATIVE_OPTIONS = [
"Importer depuis la galerie", 'Importer depuis la galerie',
"Ajouter un fichier", 'Ajouter un fichier',
"Prendre une photo ou une vidéo", 'Prendre une photo ou une vidéo',
"Annuler", 'Annuler',
]; ]
const DocumentDropZone = ({ const DocumentDropZone = ({
containerStyle = {}, containerStyle = {},
@@ -35,162 +35,162 @@ const DocumentDropZone = ({
customElement = null, customElement = null,
shouldReturnObject = false, 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 { setIsLoading, setTooltip } = useMinuit()
const { isDesktop, isWeb, isNative } = useLayoutType(); const { isDesktop, isWeb, isNative } = useLayoutType()
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet()
useEffect(() => { useEffect(() => {
if (isWeb && dropRef.current) { if (isWeb && dropRef.current) {
const el = dropRef.current; const el = dropRef.current
const handleDragIn = (e) => { const handleDragIn = (e) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
setIsDragging(true); setIsDragging(true)
}; }
const handleDragOut = (e) => { const handleDragOut = (e) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
if (!dropRef.current.contains(e.relatedTarget)) { if (!dropRef.current.contains(e.relatedTarget)) {
console.log("drag left"); console.log('drag left')
setIsDragging(false); setIsDragging(false)
} }
}; }
const handleDrop = (e) => { const handleDrop = (e) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
setIsDragging(false); setIsDragging(false)
let files = [...e.dataTransfer.files]; let files = [...e.dataTransfer.files]
if (files.length > 0) { if (files.length > 0) {
for (const file of files) { for (const file of files) {
handleWebFile({ file }); handleWebFile({ file })
} }
} }
}; }
el.addEventListener("dragenter", handleDragIn); el.addEventListener('dragenter', handleDragIn)
el.addEventListener("dragleave", handleDragOut); el.addEventListener('dragleave', handleDragOut)
el.addEventListener("dragover", (e) => e.preventDefault()); el.addEventListener('dragover', (e) => e.preventDefault())
el.addEventListener("drop", handleDrop); el.addEventListener('drop', handleDrop)
return () => { return () => {
el.removeEventListener("dragenter", handleDragIn); el.removeEventListener('dragenter', handleDragIn)
el.removeEventListener("dragleave", handleDragOut); el.removeEventListener('dragleave', handleDragOut)
el.removeEventListener("dragover", (e) => e.preventDefault()); el.removeEventListener('dragover', (e) => e.preventDefault())
el.removeEventListener("drop", handleDrop); el.removeEventListener('drop', handleDrop)
}; }
} }
}, [dropRef?.current]); }, [dropRef?.current])
const handleWebFile = ({ file }) => { const handleWebFile = ({ file }) => {
try { try {
const { type = "" } = file; const { type = '' } = file
const reader = new FileReader(); const reader = new FileReader()
reader.onloadend = () => { reader.onloadend = () => {
const base64 = reader.result.split(",")[1]; // Vous pourriez avoir besoin de cette valeur base64 pour un upload direct 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 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) => { 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) { } catch (error) {
console.log(error); console.log(error)
setTooltip({ text: error.message, type: "error" }); setTooltip({ text: error.message, type: 'error' })
} }
}; }
const onAddFile = async ({} = {}) => { const onAddFile = async ({} = {}) => {
try { try {
if (isNative) { if (isNative) {
const cancelButtonIndex = NATIVE_OPTIONS.length - 1; const cancelButtonIndex = NATIVE_OPTIONS.length - 1
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
options: NATIVE_OPTIONS, options: NATIVE_OPTIONS,
cancelButtonIndex, cancelButtonIndex,
userInterfaceStyle: "dark", userInterfaceStyle: 'dark',
...Style.actionSheet, ...Style.actionSheet,
}, },
async (selectedIndex) => { async (selectedIndex) => {
if (selectedIndex !== cancelButtonIndex) { if (selectedIndex !== cancelButtonIndex) {
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
onChooseLibrary(); onChooseLibrary()
break; break
case 1: case 1:
onChooseDocumentPicker(); onChooseDocumentPicker()
break; break
case 2: case 2:
onTakePicture(); onTakePicture()
break; break
default: default:
break; break
} }
} }
} }
); )
} else { } else {
onChooseDocumentPicker(); onChooseDocumentPicker()
} }
} catch (error) { } catch (error) {
console.log(error); console.log(error)
setTooltip({ text: error.message, type: "error" }); setTooltip({ text: error.message, type: 'error' })
} finally { } finally {
setIsLoading(false); setIsLoading(false)
} }
}; }
const onChooseLibrary = async ({} = {}) => { const onChooseLibrary = async ({} = {}) => {
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.All, mediaTypes: ImagePicker.MediaTypeOptions.All,
allowsEditing: false, allowsEditing: false,
quality: 3, quality: 3,
}); })
if (result?.assets?.[0]?.uri) { if (result?.assets?.[0]?.uri) {
const { uri, fileName = "" } = result?.assets?.[0]; const { uri, fileName = '' } = result?.assets?.[0]
onUploadDocument({ onUploadDocument({
files: [ files: [
{ {
name: getAssetName({ fileName, uri }), name: getAssetName({ fileName, uri }),
uri, uri,
type: "IMAGE", type: 'IMAGE',
}, },
], ],
}); })
} else { } else {
throw new Error("Aucune image sélectionnée"); throw new Error('Aucune image sélectionnée')
} }
}; }
const onChooseDocumentPicker = async ({} = {}) => { const onChooseDocumentPicker = async ({} = {}) => {
const result = await DocumentPicker.getDocumentAsync({ const result = await DocumentPicker.getDocumentAsync({
type: "*/*", type: '*/*',
copyToCacheDirectory: false, copyToCacheDirectory: false,
}); })
if (result?.assets?.length > 0) { if (result?.assets?.length > 0) {
const { name = "", uri = null } = result?.assets[0] || {}; const { name = '', uri = null } = result?.assets[0] || {}
if (!uri) { if (!uri) {
throw new Error("Erreur lors de l'ajout du document"); throw new Error("Erreur lors de l'ajout du document")
} }
onUploadDocument({ onUploadDocument({
@@ -200,117 +200,117 @@ const DocumentDropZone = ({
uri, uri,
}, },
], ],
}); })
} else { } else {
console.log(result); console.log(result)
throw new Error("Erreur lors de l'ajout du document"); throw new Error("Erreur lors de l'ajout du document")
} }
}; }
const onTakePicture = useCallback(async () => { const onTakePicture = useCallback(async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync(); const { status } = await ImagePicker.requestCameraPermissionsAsync()
if (status !== "granted") { if (status !== 'granted') {
throw new Error("Permissions caméra non accordées"); throw new Error('Permissions caméra non accordées')
} }
const result = await ImagePicker.launchCameraAsync({ const result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.All, mediaTypes: ImagePicker.MediaTypeOptions.All,
allowsEditing: true, allowsEditing: true,
quality: 1, quality: 1,
}); })
if (result?.assets?.[0]?.uri) { if (result?.assets?.[0]?.uri) {
const { uri, fileName = "" } = result?.assets?.[0]; const { uri, fileName = '' } = result?.assets?.[0]
onUploadDocument({ onUploadDocument({
files: [ files: [
{ {
name: getAssetName({ fileName, uri }), name: getAssetName({ fileName, uri }),
uri, uri,
type: "IMAGE", type: 'IMAGE',
}, },
], ],
}); })
} else { } else {
throw new Error("Aucune image sélectionnée"); throw new Error('Aucune image sélectionnée')
} }
}, []); }, [])
const getDefaultFileName = () => { const getDefaultFileName = () => {
const randomID = Math.random().toString(36).substring(7); const randomID = Math.random().toString(36).substring(7)
return `${moment().format(`DD_MM_YYYY_HH_mm_ss`)}_${randomID}`; return `${moment().format(`DD_MM_YYYY_HH_mm_ss`)}_${randomID}`
}; }
const getAssetName = ({ fileName = "", uri = "" }) => { const getAssetName = ({ fileName = '', uri = '' }) => {
let name = fileName; let name = fileName
if (!name && uri?.startsWith("file://")) { if (!name && uri?.startsWith('file://')) {
const splittedArray = uri?.split("/") || []; const splittedArray = uri?.split('/') || []
name = splittedArray?.[splittedArray?.length - 1] || ""; name = splittedArray?.[splittedArray?.length - 1] || ''
} }
if (!name?.length) { 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) { if (extension?.length) {
name = `${defaultFileName}.${extension}`; name = `${defaultFileName}.${extension}`
} else { } else {
name = `${defaultFileName}`; name = `${defaultFileName}`
} }
} }
return name; return name
}; }
const onUploadDocument = async ({ files = [] } = {}) => { const onUploadDocument = async ({ files = [] } = {}) => {
try { try {
setIsLoading(true); setIsLoading(true)
const filesToUpdate = []; const filesToUpdate = []
await Promise.all( await Promise.all(
files.map(async (file) => { files.map(async (file) => {
const { name = "", uri = null } = file; const { name = '', uri = null } = file
console.log(file); console.log(file)
if (!uri) { 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(); const fileName = name || getDefaultFileName()
let type = "FILE"; let type = 'FILE'
if (name?.toLowerCase()?.match(/\.(jpeg|jpg|gif|png)$/) != null) { if (name?.toLowerCase()?.match(/\.(jpeg|jpg|gif|png)$/) != null) {
type = "IMAGE"; type = 'IMAGE'
} }
if (name?.toLowerCase()?.match(/\.(mp4|mov|avi|mkv)$/) != null) { if (name?.toLowerCase()?.match(/\.(mp4|mov|avi|mkv)$/) != null) {
type = "VIDEO"; type = 'VIDEO'
} }
let compressedURI = uri; let compressedURI = uri
let thumbnailURI = null; let thumbnailURI = null
if (isNative) { if (isNative) {
if (type === "IMAGE") { if (type === 'IMAGE') {
compressedURI = await Compressor.Image.compress(uri, { compressedURI = await Compressor.Image.compress(uri, {
compressionMethod: "manual", compressionMethod: 'manual',
maxWidth: 1000, maxWidth: 1000,
quality: 0.8, quality: 0.8,
}); })
} else if (type === "VIDEO") { } else if (type === 'VIDEO') {
compressedURI = await Compressor.Video.compress(uri); compressedURI = await Compressor.Video.compress(uri)
} }
} }
const { resultURI = null } = await uploadFileToFirebase({ const { resultURI = null } = await uploadFileToFirebase({
uri: compressedURI, uri: compressedURI,
path: `documents/${documentID}/files/${fileName}`, path: `documents/${documentID}/files/${fileName}`,
}); })
if (resultURI) { if (resultURI) {
if (shouldReturnObject) { if (shouldReturnObject) {
@@ -319,39 +319,39 @@ const DocumentDropZone = ({
uri: resultURI, uri: resultURI,
type, type,
thumbnailURI, thumbnailURI,
}); })
} else { } else {
filesToUpdate.push(resultURI); filesToUpdate.push(resultURI)
} }
} else { } else {
console.log("resultURI is null"); console.log('resultURI is null')
} }
}) })
); )
if (filesToUpdate.length > 0) { if (filesToUpdate.length > 0) {
if (documentExists) { if (documentExists) {
await collectionRef.doc(documentID).update({ await collectionRef.doc(documentID).update({
files: arrayUnion(...filesToUpdate), files: arrayUnion(...filesToUpdate),
}); })
} }
if (setFiles) { 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) { } catch (error) {
console.log(error); console.log(error)
setTooltip({ text: error.message, type: "error" }); setTooltip({ text: error.message, type: 'error' })
} finally { } finally {
setIsLoading(false); setIsLoading(false)
} }
}; }
if (!documentID) { if (!documentID) {
return null; return null
} }
return ( return (
@@ -361,11 +361,9 @@ const DocumentDropZone = ({
<View <View
style={{ style={{
...Style.containerCenter, ...Style.containerCenter,
backgroundColor: isDragging backgroundColor: isDragging ? Palette.transparentGreen : Palette.transparentPrimary,
? Palette.transparentGreen
: Palette.transparentPrimary,
borderRadius: mainBorderRadius, borderRadius: mainBorderRadius,
borderStyle: "dashed", borderStyle: 'dashed',
borderWidth: 2, borderWidth: 2,
borderColor: isDragging ? Palette.green : Palette.primary, borderColor: isDragging ? Palette.green : Palette.primary,
...containerStyle, ...containerStyle,
@@ -380,20 +378,20 @@ const DocumentDropZone = ({
/> />
<Text <Text
style={{ style={{
...Fonts({ type: "default" }), ...Fonts({ type: 'default' }),
textAlign: "center", textAlign: 'center',
color: Palette.white, color: Palette.white,
}} }}
> >
{isWeb && isDesktop {isWeb && isDesktop
? `Glissez-déposez ici\nles fichiers, images et vidéos\nà ajouter.` ? `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> </Text>
</View> </View>
)} )}
</Pressable> </Pressable>
</View> </View>
); )
}; }
export default DocumentDropZone; export default DocumentDropZone
+16 -22
View File
@@ -1,40 +1,34 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from 'react'
import { View } from "react-native"; import { View } from 'react-native'
import { Image } from "expo-image"; import { Image } from 'expo-image'
const DynamicImage = ({ uri, children, ...props }) => { const DynamicImage = ({ uri, children, ...props }) => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true)
const [numRetries, setNumRetries] = useState(0); const [numRetries, setNumRetries] = useState(0)
useEffect(() => { useEffect(() => {
if (numRetries < 15) { if (numRetries < 15) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setLoading(true); setLoading(true)
}, 2500); }, 2500)
return () => clearTimeout(timer); return () => clearTimeout(timer)
} }
}, [numRetries]); }, [numRetries])
const handleError = () => { const handleError = () => {
setLoading(false); setLoading(false)
setNumRetries(numRetries + 1); setNumRetries(numRetries + 1)
}; }
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
{loading && numRetries < 15 && uri ? ( {loading && numRetries < 15 && uri ? (
<Image <Image source={uri} contentFit="cover" transition={500} {...props} onError={handleError} />
source={uri}
contentFit="cover"
transition={500}
{...props}
onError={handleError}
/>
) : ( ) : (
<View {...props}>{children}</View> <View {...props}>{children}</View>
)} )}
</View> </View>
); )
}; }
export default DynamicImage; export default DynamicImage
+22 -28
View File
@@ -1,60 +1,54 @@
import React, { useGlobal } from "reactn"; import React, { useGlobal } from 'reactn'
import { View, Text } from "react-native"; 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 { Style, Fonts } from '../styles'
import { responsiveScreenHeight } from "react-native-responsive-dimensions"; import { responsiveScreenHeight } from 'react-native-responsive-dimensions'
const EmptyFlashListPlaceholder = ({ const EmptyFlashListPlaceholder = ({ loading = false, text = '-', buttonData = {} }) => {
loading = false, const [, setShowSearch] = useGlobal('showSearch')
text = "-",
buttonData = {},
}) => {
const [, setShowSearch] = useGlobal("showSearch");
const { isMobile } = useLayoutType; const { isMobile } = useLayoutType
if (loading) { if (loading) {
return ( return (
<Text style={Fonts({ type: "default", style: { textAlign: "center" } })}> <Text style={Fonts({ type: 'default', style: { textAlign: 'center' } })}>
Chargement en cours... Chargement en cours...
</Text> </Text>
); )
} }
return ( return (
<View <View
style={{ style={{
...Style.containerCenter, ...Style.containerCenter,
alignSelf: "center", alignSelf: 'center',
width: isMobile ? "100%" : "50%", width: isMobile ? '100%' : '50%',
height: responsiveScreenHeight(50), height: responsiveScreenHeight(50),
}} }}
> >
<Text style={Fonts({ type: "default", style: { textAlign: "center" } })}> <Text style={Fonts({ type: 'default', style: { textAlign: 'center' } })}>{text}</Text>
{text}
</Text>
{buttonData?.text?.length > 0 && ( {buttonData?.text?.length > 0 && (
<Button <Button
text={buttonData?.text || "Ajouter une tâche"} text={buttonData?.text || 'Ajouter une tâche'}
type="secondary" type="secondary"
onPress={() => { onPress={() => {
setShowSearch(false); setShowSearch(false)
buttonData?.onPress?.() || (() => console.log("null")); buttonData?.onPress?.() || (() => console.log('null'))
}} }}
containerStyle={{ containerStyle={{
alignSelf: "center", alignSelf: 'center',
width: "100%", width: '100%',
}} }}
/> />
)} )}
</View> </View>
); )
}; }
export default EmptyFlashListPlaceholder; export default EmptyFlashListPlaceholder
+158 -188
View File
@@ -1,54 +1,41 @@
import { BlurView } from "expo-blur"; import { BlurView } from 'expo-blur'
import React, { import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
useCallback, import { Animated, FlatList, Platform, StyleSheet, View, useWindowDimensions } from 'react-native'
useEffect, import { responsiveHeight } from '../../actions/responsiveSizes'
useMemo, import { ai, cardsImg } from '../../assets'
useRef, import { gutters } from '../../styles'
useState, import { getCreationStageStates } from '../../utils/projectStages'
} from "react"; import AnimatedPaginationDot from '../AnimatedPaginationDot/AnimatedPaginationDot'
import { import PersonaCard from '../cards/PersonaCard/PersonaCard'
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 = [ const STAGE_CARD_CONTENT = [
{ {
key: "songwriter", key: 'songwriter',
title: "Céline", title: 'Céline',
description: "Lets write lyrics together !", description: 'Lets write lyrics together !',
image: ai.leftIcon, image: ai.leftIcon,
}, },
{ {
key: "beatmaker", key: 'beatmaker',
title: "Theo", title: 'Theo',
description: "Come back, when you'll have lyrics!", description: "Come back, when you'll have lyrics!",
image: ai.rightIcon, image: ai.rightIcon,
}, },
{ {
key: "director", key: 'director',
title: "Theo", title: 'Theo',
description: "Theo t'accompagne pour créer ton playback.", description: "Theo t'accompagne pour créer ton playback.",
image: ai.rightIcon, image: ai.rightIcon,
}, },
{ {
key: "publisher", key: 'publisher',
title: "Publication", title: 'Publication',
description: "Ta vidéo est prête ? Direction YouTube !", description: 'Ta vidéo est prête ? Direction YouTube !',
image: cardsImg.production, image: cardsImg.production,
}, },
]; ]
const WEB_SCROLL_INACTIVE_DELTA = 0.05; const WEB_SCROLL_INACTIVE_DELTA = 0.05
const FeatureCarousel = ({ const FeatureCarousel = ({
style, style,
@@ -59,231 +46,225 @@ const FeatureCarousel = ({
}) => { }) => {
const stageStates = useMemo(() => { const stageStates = useMemo(() => {
if (stageStatesProp) { if (stageStatesProp) {
return stageStatesProp; return stageStatesProp
} }
return getCreationStageStates(selectedProject); return getCreationStageStates(selectedProject)
}, [stageStatesProp, selectedProject]); }, [stageStatesProp, selectedProject])
const stageStatesByKey = useMemo(() => { const stageStatesByKey = useMemo(() => {
if (!Array.isArray(stageStates)) { if (!Array.isArray(stageStates)) {
return {}; return {}
} }
return stageStates.reduce((acc, stage) => { return stageStates.reduce((acc, stage) => {
if (stage?.key) { if (stage?.key) {
acc[stage.key] = stage; acc[stage.key] = stage
} }
return acc; return acc
}, {}); }, {})
}, [stageStates]); }, [stageStates])
const carouselItems = useMemo( const carouselItems = useMemo(
() => () =>
STAGE_CARD_CONTENT.map((item) => { STAGE_CARD_CONTENT.map((item) => {
const state = stageStatesByKey[item.key]; const state = stageStatesByKey[item.key]
return { return {
...item, ...item,
isLocked: state?.isLocked ?? true, isLocked: state?.isLocked ?? true,
description: state?.description ?? item.description, description: state?.description ?? item.description,
}; }
}), }),
[stageStatesByKey] [stageStatesByKey]
); )
const { height: windowHeight } = useWindowDimensions(); const { height: windowHeight } = useWindowDimensions()
const isWeb = Platform.OS === "web"; const isWeb = Platform.OS === 'web'
const [viewportHeight, setViewportHeight] = useState(() => const [viewportHeight, setViewportHeight] = useState(() => Math.max(windowHeight, 1))
Math.max(windowHeight, 1)
);
const updateSnapHeight = useCallback((height) => { const updateSnapHeight = useCallback((height) => {
if (!height || Number.isNaN(height)) { if (!height || Number.isNaN(height)) {
return; return
} }
setViewportHeight((prev) => { setViewportHeight((prev) => {
if (prev == null || Math.abs(prev - height) > 0.5) { if (prev == null || Math.abs(prev - height) > 0.5) {
return height; return height
} }
return prev; return prev
}); })
}, []); }, [])
useEffect(() => { useEffect(() => {
updateSnapHeight(Math.max(windowHeight, 1)); updateSnapHeight(Math.max(windowHeight, 1))
}, [updateSnapHeight, windowHeight]); }, [updateSnapHeight, windowHeight])
const itemHeight = Math.max(viewportHeight, 1); const itemHeight = Math.max(viewportHeight, 1)
const listRef = useRef(null); const listRef = useRef(null)
const pendingScrollRef = useRef(false); const pendingScrollRef = useRef(false)
const alignTimeoutRef = useRef(null); const alignTimeoutRef = useRef(null)
const activeIndexRef = useRef( const activeIndexRef = useRef(typeof activeIndex === 'number' ? activeIndex : 0)
typeof activeIndex === "number" ? activeIndex : 0 const onActiveIndexChangeRef = useRef(onActiveIndexChange)
); const scrollY = useRef(new Animated.Value(0)).current
const onActiveIndexChangeRef = useRef(onActiveIndexChange);
const scrollY = useRef(new Animated.Value(0)).current;
useEffect(() => { useEffect(() => {
onActiveIndexChangeRef.current = onActiveIndexChange; onActiveIndexChangeRef.current = onActiveIndexChange
}, [onActiveIndexChange]); }, [onActiveIndexChange])
useEffect(() => { useEffect(() => {
if (typeof activeIndex === "number") { if (typeof activeIndex === 'number') {
activeIndexRef.current = activeIndex; activeIndexRef.current = activeIndex
} }
}, [activeIndex]); }, [activeIndex])
const clampIndex = useCallback( const clampIndex = useCallback(
(index) => { (index) => {
if (!carouselItems.length) { if (!carouselItems.length) {
return 0; return 0
} }
if (index < 0) { if (index < 0) {
return 0; return 0
} }
if (index >= carouselItems.length) { if (index >= carouselItems.length) {
return carouselItems.length - 1; return carouselItems.length - 1
} }
return index; return index
}, },
[carouselItems.length] [carouselItems.length]
); )
const clearPendingAlignment = useCallback(() => { const clearPendingAlignment = useCallback(() => {
if (alignTimeoutRef.current != null) { if (alignTimeoutRef.current != null) {
globalThis.clearTimeout(alignTimeoutRef.current); globalThis.clearTimeout(alignTimeoutRef.current)
alignTimeoutRef.current = null; alignTimeoutRef.current = null
} }
}, []); }, [])
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]); useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment])
const scrollToIndex = useCallback( const scrollToIndex = useCallback(
(index, animated = true, heightOverride) => { (index, animated = true, heightOverride) => {
const ref = listRef.current; const ref = listRef.current
if (!ref) { if (!ref) {
return; return
} }
const clamped = clampIndex(index); const clamped = clampIndex(index)
const height = const height = heightOverride && heightOverride > 0 ? heightOverride : itemHeight
heightOverride && heightOverride > 0 ? heightOverride : itemHeight;
if (isWeb) { if (isWeb) {
if (!height) { if (!height) {
return; return
} }
updateSnapHeight(height); updateSnapHeight(height)
try { try {
ref.scrollToOffset({ offset: clamped * height, animated }); ref.scrollToOffset({ offset: clamped * height, animated })
} catch (_error) { } catch (_error) {
// Ignore scroll errors when list is not ready yet. // Ignore scroll errors when list is not ready yet.
} }
activeIndexRef.current = clamped; activeIndexRef.current = clamped
return; return
} }
try { try {
pendingScrollRef.current = !!animated; pendingScrollRef.current = !!animated
ref.scrollToIndex({ index: clamped, animated }); ref.scrollToIndex({ index: clamped, animated })
activeIndexRef.current = clamped; activeIndexRef.current = clamped
} catch (_error) { } catch (_error) {
pendingScrollRef.current = false; pendingScrollRef.current = false
} }
}, },
[clampIndex, isWeb, itemHeight, updateSnapHeight] [clampIndex, isWeb, itemHeight, updateSnapHeight]
); )
useEffect(() => { useEffect(() => {
if ( if (
listRef.current == null || listRef.current == null ||
typeof activeIndex !== "number" || typeof activeIndex !== 'number' ||
activeIndex < 0 || activeIndex < 0 ||
activeIndex >= carouselItems.length || activeIndex >= carouselItems.length ||
(isWeb && !itemHeight) (isWeb && !itemHeight)
) { ) {
return; return
} }
scrollToIndex(activeIndex); scrollToIndex(activeIndex)
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]); }, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex])
useEffect(() => { useEffect(() => {
if (listRef.current == null || (isWeb && !itemHeight)) { if (listRef.current == null || (isWeb && !itemHeight)) {
return; return
} }
scrollToIndex(activeIndexRef.current, false); scrollToIndex(activeIndexRef.current, false)
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex]); }, [carouselItems.length, isWeb, itemHeight, scrollToIndex])
const alignToOffset = useCallback( const alignToOffset = useCallback(
(offset, layoutHeight) => { (offset, layoutHeight) => {
const height = const height = layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight
layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight;
if (!height) { if (!height) {
return; return
} }
updateSnapHeight(height); updateSnapHeight(height)
const currentIndex = activeIndexRef.current; const currentIndex = activeIndexRef.current
const rawIndex = height ? offset / height : currentIndex; const rawIndex = height ? offset / height : currentIndex
let nextIndex = currentIndex; let nextIndex = currentIndex
if (isWeb) { if (isWeb) {
const delta = rawIndex - currentIndex; const delta = rawIndex - currentIndex
if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) { if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) {
if (Math.abs(delta) <= 1) { if (Math.abs(delta) <= 1) {
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1)); nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1))
} else { } else {
nextIndex = clampIndex(currentIndex + Math.round(delta)); nextIndex = clampIndex(currentIndex + Math.round(delta))
} }
} }
} else { } else {
nextIndex = clampIndex(Math.round(rawIndex)); nextIndex = clampIndex(Math.round(rawIndex))
} }
const hasChanged = nextIndex !== activeIndexRef.current; const hasChanged = nextIndex !== activeIndexRef.current
if (hasChanged) { if (hasChanged) {
activeIndexRef.current = nextIndex; activeIndexRef.current = nextIndex
const callback = onActiveIndexChangeRef.current; const callback = onActiveIndexChangeRef.current
if (callback) { if (callback) {
callback(nextIndex); callback(nextIndex)
} }
} }
if (isWeb || hasChanged) { if (isWeb || hasChanged) {
const shouldAnimate = isWeb ? true : !isWeb; const shouldAnimate = isWeb ? true : !isWeb
scrollToIndex(nextIndex, shouldAnimate, height); scrollToIndex(nextIndex, shouldAnimate, height)
} }
}, },
[clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight] [clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight]
); )
const handleScrollEnd = useCallback( const handleScrollEnd = useCallback(
(event) => { (event) => {
clearPendingAlignment(); clearPendingAlignment()
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0; const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height; const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
alignToOffset(offsetY, layoutHeight); alignToOffset(offsetY, layoutHeight)
}, },
[alignToOffset, clearPendingAlignment] [alignToOffset, clearPendingAlignment]
); )
const handleScroll = useCallback( const handleScroll = useCallback(
(event) => { (event) => {
if (!isWeb) { if (!isWeb) {
return; return
} }
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0; const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height; const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
clearPendingAlignment(); clearPendingAlignment()
alignTimeoutRef.current = globalThis.setTimeout(() => { alignTimeoutRef.current = globalThis.setTimeout(() => {
alignToOffset(offsetY, layoutHeight); alignToOffset(offsetY, layoutHeight)
alignTimeoutRef.current = null; alignTimeoutRef.current = null
}, 80); }, 80)
}, },
[alignToOffset, clearPendingAlignment, isWeb] [alignToOffset, clearPendingAlignment, isWeb]
); )
const animatedScrollHandler = useMemo( const animatedScrollHandler = useMemo(
() => () =>
@@ -292,31 +273,26 @@ const FeatureCarousel = ({
listener: isWeb ? handleScroll : undefined, listener: isWeb ? handleScroll : undefined,
}), }),
[handleScroll, isWeb, scrollY] [handleScroll, isWeb, scrollY]
); )
const handleLayout = useCallback( const handleLayout = useCallback(
(event) => { (event) => {
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0; const layoutHeight = event?.nativeEvent?.layout?.height ?? 0
if (layoutHeight > 0) { if (layoutHeight > 0) {
updateSnapHeight(layoutHeight); updateSnapHeight(layoutHeight)
} }
}, },
[updateSnapHeight] [updateSnapHeight]
); )
const keyExtractor = useCallback((item) => item.key, []); const keyExtractor = useCallback((item) => item.key, [])
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }) => ( ({ item, index }) => (
<PersonaCard <PersonaCard item={item} index={index} isLock={item.isLocked} height={itemHeight} />
item={item}
index={index}
isLock={item.isLocked}
height={itemHeight}
/>
), ),
[itemHeight] [itemHeight]
); )
const getItemLayout = useCallback( const getItemLayout = useCallback(
(_data, index) => ({ (_data, index) => ({
@@ -325,50 +301,44 @@ const FeatureCarousel = ({
index, index,
}), }),
[itemHeight] [itemHeight]
); )
const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 }); const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 })
const handleViewableItemsChangedRef = useRef(); const handleViewableItemsChangedRef = useRef()
if (!handleViewableItemsChangedRef.current) { if (!handleViewableItemsChangedRef.current) {
handleViewableItemsChangedRef.current = ({ viewableItems }) => { handleViewableItemsChangedRef.current = ({ viewableItems }) => {
if (!viewableItems?.length) return; if (!viewableItems?.length) return
const firstVisible = viewableItems.find((item) => item?.isViewable); const firstVisible = viewableItems.find((item) => item?.isViewable)
if (!firstVisible || firstVisible.index == null) return; if (!firstVisible || firstVisible.index == null) return
if (pendingScrollRef.current) { if (pendingScrollRef.current) {
if (firstVisible.index === activeIndexRef.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) { if (callback && firstVisible.index !== activeIndexRef.current) {
callback(firstVisible.index); callback(firstVisible.index)
} }
}; }
} }
const snapOffsets = useMemo(() => { const snapOffsets = useMemo(() => {
if (!itemHeight || !isWeb) { if (!itemHeight || !isWeb) {
return undefined; return undefined
} }
return carouselItems.map((_, index) => index * itemHeight); return carouselItems.map((_, index) => index * itemHeight)
}, [carouselItems, isWeb, itemHeight]); }, [carouselItems, isWeb, itemHeight])
const blurIntensity = isWeb ? 80 : 30; const blurIntensity = isWeb ? 80 : 30
const dotsWrapperStyle = useMemo( const dotsWrapperStyle = useMemo(
() => [ () => [styles.dotsWrapperBase, isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative],
styles.dotsWrapperBase,
isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative,
],
[isWeb] [isWeb]
); )
return ( return (
<View <View style={[styles.container, isWeb && styles.containerWeb, style]} onLayout={handleLayout}>
style={[styles.container, isWeb && styles.containerWeb, style]}
onLayout={handleLayout}
>
<FlatList <FlatList
ref={listRef} ref={listRef}
data={carouselItems} data={carouselItems}
@@ -385,11 +355,11 @@ const FeatureCarousel = ({
maxToRenderPerBatch={2} maxToRenderPerBatch={2}
windowSize={3} windowSize={3}
scrollEventThrottle={16} scrollEventThrottle={16}
snapToAlignment={isWeb ? undefined : "start"} snapToAlignment={isWeb ? undefined : 'start'}
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined} snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
snapToOffsets={snapOffsets} snapToOffsets={snapOffsets}
disableIntervalMomentum={!isWeb} disableIntervalMomentum={!isWeb}
decelerationRate={!isWeb ? "fast" : undefined} decelerationRate={!isWeb ? 'fast' : undefined}
style={styles.list} style={styles.list}
onScroll={animatedScrollHandler} onScroll={animatedScrollHandler}
onMomentumScrollEnd={handleScrollEnd} onMomentumScrollEnd={handleScrollEnd}
@@ -397,7 +367,7 @@ const FeatureCarousel = ({
/> />
<BlurView <BlurView
intensity={blurIntensity} intensity={blurIntensity}
tint={Platform.OS === "web" ? undefined : "dark"} tint={Platform.OS === 'web' ? undefined : 'dark'}
style={dotsWrapperStyle} style={dotsWrapperStyle}
pointerEvents="none" pointerEvents="none"
> >
@@ -414,16 +384,16 @@ const FeatureCarousel = ({
/> />
</BlurView> </BlurView>
</View> </View>
); )
}; }
export default FeatureCarousel; export default FeatureCarousel
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
width: "100%", width: '100%',
flexDirection: "row", flexDirection: 'row',
paddingBottom: responsiveHeight(5), paddingBottom: responsiveHeight(5),
paddingHorizontal: gutters, paddingHorizontal: gutters,
}, },
@@ -434,28 +404,28 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
dotsWrapperBase: { dotsWrapperBase: {
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
borderRadius: 16, borderRadius: 16,
paddingVertical: 12, paddingVertical: 12,
paddingHorizontal: 8, paddingHorizontal: 8,
overflow: "hidden", overflow: 'hidden',
backgroundColor: "rgba(18, 18, 18, 0.2)", backgroundColor: 'rgba(18, 18, 18, 0.2)',
}, },
dotsWrapperWeb: { dotsWrapperWeb: {
position: "absolute", position: 'absolute',
right: 12, right: 12,
top: 0, top: 0,
bottom: 0, bottom: 0,
maxHeight: "75%", maxHeight: '75%',
alignSelf: "center", alignSelf: 'center',
}, },
dotsWrapperNative: { dotsWrapperNative: {
marginLeft: 12, marginLeft: 12,
alignSelf: "center", alignSelf: 'center',
}, },
dotsContainer: { dotsContainer: {
flexDirection: "column", flexDirection: 'column',
}, },
dot: { dot: {
width: 8, width: 8,
@@ -463,6 +433,6 @@ const styles = StyleSheet.create({
marginHorizontal: 0, marginHorizontal: 0,
marginVertical: 6, marginVertical: 6,
borderRadius: 999, 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 { BlurView } from 'expo-blur'
import React, { import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { import {
Animated, Animated,
ImageBackground, ImageBackground,
@@ -13,42 +7,42 @@ import {
StyleSheet, StyleSheet,
View, View,
useWindowDimensions, useWindowDimensions,
} from "react-native"; } from 'react-native'
import { ai, cardsImg } from "../../assets"; import { ai, cardsImg } from '../../assets'
import { getCreationStageStates } from "../../utils/projectStages"; import { getCreationStageStates } from '../../utils/projectStages'
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot"; import AnimatedPaginationDot from '../AnimatedPaginationDot/AnimatedPaginationDot'
import PersonaCard from "../cards/PersonaCard/PersonaCard"; import PersonaCard from '../cards/PersonaCard/PersonaCard'
import { LinearGradient } from "../LinearGradient/LinearGradient"; import { LinearGradient } from '../LinearGradient/LinearGradient'
const STAGE_CARD_CONTENT = [ const STAGE_CARD_CONTENT = [
{ {
key: "songwriter", key: 'songwriter',
title: "Céline", title: 'Céline',
description: "Lets write lyrics together !", description: 'Lets write lyrics together !',
image: ai.leftIcon, image: ai.leftIcon,
}, },
{ {
key: "beatmaker", key: 'beatmaker',
title: "Theo", title: 'Theo',
description: "Come back, when you'll have lyrics!", description: "Come back, when you'll have lyrics!",
image: ai.rightIcon, image: ai.rightIcon,
}, },
{ {
key: "director", key: 'director',
title: "Theo", title: 'Theo',
description: "Theo t'accompagne pour créer ton playback.", description: "Theo t'accompagne pour créer ton playback.",
image: ai.rightIcon, image: ai.rightIcon,
}, },
{ {
key: "publisher", key: 'publisher',
title: "Publication", title: 'Publication',
description: "Ta vidéo est prête ? Direction YouTube !", description: 'Ta vidéo est prête ? Direction YouTube !',
image: cardsImg.production, image: cardsImg.production,
}, },
]; ]
const WEB_SCROLL_INACTIVE_DELTA = 0.05; const WEB_SCROLL_INACTIVE_DELTA = 0.05
const HOME_BACKGROUND_COLOR = "#425B87"; // Derived from the bottom color of the home hero image const HOME_BACKGROUND_COLOR = '#425B87' // Derived from the bottom color of the home hero image
const FeatureCarousel = ({ const FeatureCarousel = ({
style, style,
selectedProject, selectedProject,
@@ -60,257 +54,245 @@ const FeatureCarousel = ({
}) => { }) => {
const stageStates = useMemo(() => { const stageStates = useMemo(() => {
if (stageStatesProp) { if (stageStatesProp) {
return stageStatesProp; return stageStatesProp
} }
return getCreationStageStates(selectedProject); return getCreationStageStates(selectedProject)
}, [stageStatesProp, selectedProject]); }, [stageStatesProp, selectedProject])
const stageStatesByKey = useMemo(() => { const stageStatesByKey = useMemo(() => {
if (!Array.isArray(stageStates)) { if (!Array.isArray(stageStates)) {
return {}; return {}
} }
return stageStates.reduce((acc, stage) => { return stageStates.reduce((acc, stage) => {
if (stage?.key) { if (stage?.key) {
acc[stage.key] = stage; acc[stage.key] = stage
} }
return acc; return acc
}, {}); }, {})
}, [stageStates]); }, [stageStates])
const carouselItems = useMemo( const carouselItems = useMemo(
() => () =>
STAGE_CARD_CONTENT.map((item) => { STAGE_CARD_CONTENT.map((item) => {
const state = stageStatesByKey[item.key]; const state = stageStatesByKey[item.key]
return { return {
...item, ...item,
isLocked: state?.isLocked ?? true, isLocked: state?.isLocked ?? true,
description: state?.description ?? item.description, description: state?.description ?? item.description,
}; }
}), }),
[stageStatesByKey] [stageStatesByKey]
); )
const { height: windowHeight } = useWindowDimensions(); const { height: windowHeight } = useWindowDimensions()
const isWeb = Platform.OS === "web"; const isWeb = Platform.OS === 'web'
const [viewportHeight, setViewportHeight] = useState(() => const [viewportHeight, setViewportHeight] = useState(() => Math.max(windowHeight, 1))
Math.max(windowHeight, 1)
);
const updateSnapHeight = useCallback((height) => { const updateSnapHeight = useCallback((height) => {
if (!height || Number.isNaN(height)) { if (!height || Number.isNaN(height)) {
return; return
} }
setViewportHeight((prev) => { setViewportHeight((prev) => {
if (prev == null || Math.abs(prev - height) > 0.5) { if (prev == null || Math.abs(prev - height) > 0.5) {
return height; return height
} }
return prev; return prev
}); })
}, []); }, [])
useEffect(() => { useEffect(() => {
updateSnapHeight(Math.max(windowHeight, 1)); updateSnapHeight(Math.max(windowHeight, 1))
}, [updateSnapHeight, windowHeight]); }, [updateSnapHeight, windowHeight])
const itemHeight = Math.max(viewportHeight, 1); const itemHeight = Math.max(viewportHeight, 1)
const scrollViewRef = useRef(null); const scrollViewRef = useRef(null)
const alignTimeoutRef = useRef(null); const alignTimeoutRef = useRef(null)
const activeIndexRef = useRef( const activeIndexRef = useRef(typeof activeIndex === 'number' ? activeIndex : 0)
typeof activeIndex === "number" ? activeIndex : 0 const onActiveIndexChangeRef = useRef(onActiveIndexChange)
); const scrollY = useRef(new Animated.Value(0)).current
const onActiveIndexChangeRef = useRef(onActiveIndexChange);
const scrollY = useRef(new Animated.Value(0)).current;
useEffect(() => { useEffect(() => {
onActiveIndexChangeRef.current = onActiveIndexChange; onActiveIndexChangeRef.current = onActiveIndexChange
}, [onActiveIndexChange]); }, [onActiveIndexChange])
useEffect(() => { useEffect(() => {
if (typeof activeIndex === "number") { if (typeof activeIndex === 'number') {
activeIndexRef.current = activeIndex; activeIndexRef.current = activeIndex
} }
}, [activeIndex]); }, [activeIndex])
const clampIndex = useCallback( const clampIndex = useCallback(
(index) => { (index) => {
if (!carouselItems.length) { if (!carouselItems.length) {
return 0; return 0
} }
if (index < 0) { if (index < 0) {
return 0; return 0
} }
if (index >= carouselItems.length) { if (index >= carouselItems.length) {
return carouselItems.length - 1; return carouselItems.length - 1
} }
return index; return index
}, },
[carouselItems.length] [carouselItems.length]
); )
const clearPendingAlignment = useCallback(() => { const clearPendingAlignment = useCallback(() => {
if (alignTimeoutRef.current != null) { if (alignTimeoutRef.current != null) {
globalThis.clearTimeout(alignTimeoutRef.current); globalThis.clearTimeout(alignTimeoutRef.current)
alignTimeoutRef.current = null; alignTimeoutRef.current = null
} }
}, []); }, [])
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]); useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment])
const getScrollNode = useCallback(() => { const getScrollNode = useCallback(() => {
const node = scrollViewRef.current; const node = scrollViewRef.current
if (!node) { if (!node) {
return null; return null
} }
if (typeof node.scrollTo === "function") { if (typeof node.scrollTo === 'function') {
return node; return node
} }
if (typeof node.getNode === "function") { if (typeof node.getNode === 'function') {
return node.getNode(); return node.getNode()
} }
return null; return null
}, []); }, [])
const scrollToIndex = useCallback( const scrollToIndex = useCallback(
(index, animated = true, heightOverride) => { (index, animated = true, heightOverride) => {
const target = getScrollNode(); const target = getScrollNode()
if (!target) { if (!target) {
return; return
} }
const clamped = clampIndex(index); const clamped = clampIndex(index)
const height = const height = heightOverride && heightOverride > 0 ? heightOverride : itemHeight
heightOverride && heightOverride > 0 ? heightOverride : itemHeight;
if (!height) { if (!height) {
return; return
} }
updateSnapHeight(height); updateSnapHeight(height)
const offset = clamped * height; const offset = clamped * height
try { try {
if (typeof target.scrollTo === "function") { if (typeof target.scrollTo === 'function') {
target.scrollTo({ y: offset, animated }); target.scrollTo({ y: offset, animated })
} else if (typeof target.scrollToOffset === "function") { } else if (typeof target.scrollToOffset === 'function') {
target.scrollToOffset({ offset, animated }); target.scrollToOffset({ offset, animated })
} }
} catch (_error) { } catch (_error) {
// ScrollView not ready yet, ignore. // ScrollView not ready yet, ignore.
} }
activeIndexRef.current = clamped; activeIndexRef.current = clamped
}, },
[clampIndex, getScrollNode, itemHeight, updateSnapHeight] [clampIndex, getScrollNode, itemHeight, updateSnapHeight]
); )
useEffect(() => { useEffect(() => {
if ( if (
scrollViewRef.current == null || scrollViewRef.current == null ||
typeof activeIndex !== "number" || typeof activeIndex !== 'number' ||
activeIndex < 0 || activeIndex < 0 ||
activeIndex >= carouselItems.length || activeIndex >= carouselItems.length ||
(isWeb && !itemHeight) (isWeb && !itemHeight)
) { ) {
return; return
} }
scrollToIndex(activeIndex); scrollToIndex(activeIndex)
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]); }, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex])
useEffect(() => { useEffect(() => {
if (scrollViewRef.current == null || (isWeb && !itemHeight)) { if (scrollViewRef.current == null || (isWeb && !itemHeight)) {
return; return
} }
scrollToIndex(activeIndexRef.current, false); scrollToIndex(activeIndexRef.current, false)
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex]); }, [carouselItems.length, isWeb, itemHeight, scrollToIndex])
useEffect(() => { useEffect(() => {
if (!isWeb || !isFocused) { if (!isWeb || !isFocused) {
return; return
} }
if (!itemHeight) { if (!itemHeight) {
return; return
} }
clearPendingAlignment(); clearPendingAlignment()
scrollToIndex(activeIndexRef.current, false); scrollToIndex(activeIndexRef.current, false)
}, [ }, [clearPendingAlignment, isFocused, isWeb, itemHeight, scrollToIndex])
clearPendingAlignment,
isFocused,
isWeb,
itemHeight,
scrollToIndex,
]);
const alignToOffset = useCallback( const alignToOffset = useCallback(
(offset, layoutHeight, options = {}) => { (offset, layoutHeight, options = {}) => {
const { forceSnap = false } = options || {}; const { forceSnap = false } = options || {}
const height = const height = layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight
layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight;
if (!height) { if (!height) {
return; return
} }
updateSnapHeight(height); updateSnapHeight(height)
const currentIndex = activeIndexRef.current; const currentIndex = activeIndexRef.current
const rawIndex = height ? offset / height : currentIndex; const rawIndex = height ? offset / height : currentIndex
let nextIndex = currentIndex; let nextIndex = currentIndex
if (isWeb) { if (isWeb) {
const delta = rawIndex - currentIndex; const delta = rawIndex - currentIndex
if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) { if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) {
if (Math.abs(delta) <= 1) { if (Math.abs(delta) <= 1) {
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1)); nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1))
} else { } else {
nextIndex = clampIndex(currentIndex + Math.round(delta)); nextIndex = clampIndex(currentIndex + Math.round(delta))
} }
} }
} else { } else {
nextIndex = clampIndex(Math.round(rawIndex)); nextIndex = clampIndex(Math.round(rawIndex))
} }
const hasChanged = nextIndex !== activeIndexRef.current; const hasChanged = nextIndex !== activeIndexRef.current
if (hasChanged) { if (hasChanged) {
activeIndexRef.current = nextIndex; activeIndexRef.current = nextIndex
const callback = onActiveIndexChangeRef.current; const callback = onActiveIndexChangeRef.current
if (callback) { if (callback) {
callback(nextIndex); callback(nextIndex)
} }
} }
if (forceSnap || hasChanged) { if (forceSnap || hasChanged) {
scrollToIndex(nextIndex, true, height); scrollToIndex(nextIndex, true, height)
} }
}, },
[clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight] [clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight]
); )
const handleScrollEnd = useCallback( const handleScrollEnd = useCallback(
(event) => { (event) => {
clearPendingAlignment(); clearPendingAlignment()
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0; const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height; const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
alignToOffset(offsetY, layoutHeight, { forceSnap: true }); alignToOffset(offsetY, layoutHeight, { forceSnap: true })
}, },
[alignToOffset, clearPendingAlignment] [alignToOffset, clearPendingAlignment]
); )
const handleScroll = useCallback( const handleScroll = useCallback(
(event) => { (event) => {
if (!isWeb) { if (!isWeb) {
return; return
} }
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0; const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height; const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
clearPendingAlignment(); clearPendingAlignment()
alignTimeoutRef.current = globalThis.setTimeout(() => { alignTimeoutRef.current = globalThis.setTimeout(() => {
alignToOffset(offsetY, layoutHeight, { forceSnap: false }); alignToOffset(offsetY, layoutHeight, { forceSnap: false })
alignTimeoutRef.current = null; alignTimeoutRef.current = null
}, 80); }, 80)
}, },
[alignToOffset, clearPendingAlignment, isWeb] [alignToOffset, clearPendingAlignment, isWeb]
); )
const animatedScrollHandler = useMemo( const animatedScrollHandler = useMemo(
() => () =>
@@ -319,19 +301,19 @@ const FeatureCarousel = ({
listener: isWeb ? handleScroll : undefined, listener: isWeb ? handleScroll : undefined,
}), }),
[handleScroll, isWeb, scrollY] [handleScroll, isWeb, scrollY]
); )
const handleLayout = useCallback( const handleLayout = useCallback(
(event) => { (event) => {
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0; const layoutHeight = event?.nativeEvent?.layout?.height ?? 0
if (layoutHeight > 0) { if (layoutHeight > 0) {
updateSnapHeight(layoutHeight); updateSnapHeight(layoutHeight)
} }
}, },
[updateSnapHeight] [updateSnapHeight]
); )
const hasBackgroundImage = !!backgroundImage; const hasBackgroundImage = !!backgroundImage
const renderContent = useMemo( const renderContent = useMemo(
() => () =>
@@ -345,43 +327,35 @@ const FeatureCarousel = ({
]} ]}
> >
<View style={styles.cardWrapper}> <View style={styles.cardWrapper}>
<PersonaCard <PersonaCard item={item} index={index} isLock={item.isLocked} height={itemHeight} />
item={item}
index={index}
isLock={item.isLocked}
height={itemHeight}
/>
</View> </View>
</View> </View>
)), )),
[carouselItems, hasBackgroundImage, itemHeight] [carouselItems, hasBackgroundImage, itemHeight]
); )
const snapOffsets = useMemo(() => { const snapOffsets = useMemo(() => {
if (!itemHeight || !isWeb) { if (!itemHeight || !isWeb) {
return undefined; return undefined
} }
return carouselItems.map((_, index) => index * itemHeight); return carouselItems.map((_, index) => index * itemHeight)
}, [carouselItems, isWeb, itemHeight]); }, [carouselItems, isWeb, itemHeight])
const blurIntensity = isWeb ? 80 : 30; const blurIntensity = isWeb ? 80 : 30
const dotsWrapperStyle = useMemo( const dotsWrapperStyle = useMemo(
() => [ () => [styles.dotsWrapperBase, isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative],
styles.dotsWrapperBase,
isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative,
],
[isWeb] [isWeb]
); )
const containerProps = hasBackgroundImage const containerProps = hasBackgroundImage
? { ? {
source: backgroundImage, source: backgroundImage,
resizeMode: "cover", resizeMode: 'cover',
imageStyle: styles.backgroundImage, imageStyle: styles.backgroundImage,
} }
: {}; : {}
const ContainerComponent = hasBackgroundImage ? ImageBackground : View; const ContainerComponent = hasBackgroundImage ? ImageBackground : View
return ( return (
<ContainerComponent <ContainerComponent
@@ -396,11 +370,7 @@ const FeatureCarousel = ({
> >
{hasBackgroundImage && ( {hasBackgroundImage && (
<LinearGradient <LinearGradient
colors={[ colors={['rgba(66, 91, 135, 0)', 'rgba(66, 91, 135, 0.4)', HOME_BACKGROUND_COLOR]}
"rgba(66, 91, 135, 0)",
"rgba(66, 91, 135, 0.4)",
HOME_BACKGROUND_COLOR,
]}
locations={[0, 0.75, 1]} locations={[0, 0.75, 1]}
style={styles.backgroundGradient} style={styles.backgroundGradient}
pointerEvents="none" pointerEvents="none"
@@ -416,7 +386,7 @@ const FeatureCarousel = ({
snapToAlignment="start" snapToAlignment="start"
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined} snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
snapToOffsets={snapOffsets} snapToOffsets={snapOffsets}
decelerationRate={!isWeb ? "fast" : "normal"} decelerationRate={!isWeb ? 'fast' : 'normal'}
style={styles.list} style={styles.list}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
onScroll={animatedScrollHandler} onScroll={animatedScrollHandler}
@@ -427,7 +397,7 @@ const FeatureCarousel = ({
</Animated.ScrollView> </Animated.ScrollView>
<BlurView <BlurView
intensity={blurIntensity} intensity={blurIntensity}
tint={Platform.OS === "web" ? undefined : "dark"} tint={Platform.OS === 'web' ? undefined : 'dark'}
style={dotsWrapperStyle} style={dotsWrapperStyle}
pointerEvents="none" pointerEvents="none"
> >
@@ -444,16 +414,16 @@ const FeatureCarousel = ({
/> />
</BlurView> </BlurView>
</ContainerComponent> </ContainerComponent>
); )
}; }
export default FeatureCarousel; export default FeatureCarousel
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
width: "100%", width: '100%',
flexDirection: "row", flexDirection: 'row',
}, },
containerWeb: { containerWeb: {
// paddingRight: 56, // paddingRight: 56,
@@ -462,7 +432,7 @@ const styles = StyleSheet.create({
backgroundColor: HOME_BACKGROUND_COLOR, backgroundColor: HOME_BACKGROUND_COLOR,
}, },
containerTransparent: { containerTransparent: {
backgroundColor: "transparent", backgroundColor: 'transparent',
}, },
list: { list: {
flex: 1, flex: 1,
@@ -477,45 +447,45 @@ const styles = StyleSheet.create({
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
}, },
slide: { slide: {
width: "100%", width: '100%',
justifyContent: "flex-start", justifyContent: 'flex-start',
}, },
slideColored: { slideColored: {
backgroundColor: HOME_BACKGROUND_COLOR, backgroundColor: HOME_BACKGROUND_COLOR,
}, },
slideTransparent: { slideTransparent: {
backgroundColor: "transparent", backgroundColor: 'transparent',
}, },
cardWrapper: { cardWrapper: {
flex: 1, flex: 1,
width: "60%", width: '60%',
justifyContent: "center", justifyContent: 'center',
alignSelf: "center", alignSelf: 'center',
zIndex: 1, zIndex: 1,
}, },
dotsWrapperBase: { dotsWrapperBase: {
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
borderRadius: 16, borderRadius: 16,
paddingVertical: 12, paddingVertical: 12,
paddingHorizontal: 8, paddingHorizontal: 8,
overflow: "hidden", overflow: 'hidden',
backgroundColor: "rgba(18, 18, 18, 0.2)", backgroundColor: 'rgba(18, 18, 18, 0.2)',
}, },
dotsWrapperWeb: { dotsWrapperWeb: {
position: "absolute", position: 'absolute',
right: 12, right: 12,
top: 0, top: 0,
bottom: 0, bottom: 0,
maxHeight: "75%", maxHeight: '75%',
alignSelf: "center", alignSelf: 'center',
}, },
dotsWrapperNative: { dotsWrapperNative: {
marginLeft: 12, marginLeft: 12,
alignSelf: "center", alignSelf: 'center',
}, },
dotsContainer: { dotsContainer: {
flexDirection: "column", flexDirection: 'column',
}, },
dot: { dot: {
width: 8, width: 8,
@@ -523,6 +493,6 @@ const styles = StyleSheet.create({
marginHorizontal: 0, marginHorizontal: 0,
marginVertical: 6, marginVertical: 6,
borderRadius: 999, 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 { useContext, useGlobal } from 'reactn'
import { ScrollView, Text, View, Image, Pressable } from "react-native"; 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 { getFileNameFromURL } from '../helpers'
import { Fonts, Palette, Style, gutters } from "../styles"; import { Fonts, Palette, Style, gutters } from '../styles'
import { icons } from "../assets"; import { icons } from '../assets'
import { WebViewContext } from "../providers/WebViewProvider"; import { WebViewContext } from '../providers/WebViewProvider'
import alert from "./Alert"; import alert from './Alert'
export default ({ export default ({
files = [], files = [],
@@ -17,67 +17,61 @@ export default ({
collectionRef = null, collectionRef = null,
containerStyle = {}, containerStyle = {},
}) => { }) => {
const [, setIsLoading] = useGlobal("_isLoading"); const [, setIsLoading] = useGlobal('_isLoading')
const [, setTooltip] = useGlobal("_tooltip"); const [, setTooltip] = useGlobal('_tooltip')
const { setWebViewUrl } = useContext(WebViewContext); const { setWebViewUrl } = useContext(WebViewContext)
const onDeleteFile = async (url) => { const onDeleteFile = async (url) => {
alert( alert(
"Êtes-vous sûr ?", 'Êtes-vous sûr ?',
"Cette action est irréversible.", 'Cette action est irréversible.',
[ [
{ {
text: "Annuler", text: 'Annuler',
style: "cancel", style: 'cancel',
}, },
{ {
text: "Confirmer", text: 'Confirmer',
onPress: async () => { onPress: async () => {
try { try {
setIsLoading(true); setIsLoading(true)
setFiles( setFiles(files.filter((file) => file !== url && file.uri !== url))
files.filter((file) => file !== url && file.uri !== url) await firebase.storage().refFromURL(url).delete()
);
await firebase.storage().refFromURL(url).delete();
if (documentID) { if (documentID) {
await collectionRef.doc(documentID).update({ await collectionRef.doc(documentID).update({
files: arrayRemove(url), files: arrayRemove(url),
}); })
} }
setTooltip({ setTooltip({
type: "success", type: 'success',
text: "Fichier supprimé avec succès !", text: 'Fichier supprimé avec succès !',
}); })
} catch (error) { } catch (error) {
console.log(error); console.log(error)
} finally { } finally {
setIsLoading(false); setIsLoading(false)
} }
}, },
style: "confirm", style: 'confirm',
}, },
], ],
{ cancelable: false } { cancelable: false }
); )
}; }
if (files.length === 0) { if (files.length === 0) {
return null; return null
} }
return ( return (
<View> <View>
<ScrollView <ScrollView horizontal style={{ ...containerStyle }} showsHorizontalScrollIndicator={false}>
horizontal
style={{ ...containerStyle }}
showsHorizontalScrollIndicator={false}
>
{files.map((file, index) => { {files.map((file, index) => {
const uri = file.uri || file; const uri = file.uri || file
return ( return (
<View <View
@@ -97,7 +91,7 @@ export default ({
<Text <Text
numberOfLines={1} numberOfLines={1}
style={Fonts({ style={Fonts({
type: "default", type: 'default',
color: Palette.white, color: Palette.white,
style: { style: {
maxWidth: files?.length > 1 ? 100 : 200, maxWidth: files?.length > 1 ? 100 : 200,
@@ -119,16 +113,12 @@ export default ({
)} )}
<Pressable onPress={() => setWebViewUrl(uri)}> <Pressable onPress={() => setWebViewUrl(uri)}>
<Image <Image source={icons.eye} style={Style.iconDefault} resizeMode="contain" />
source={icons.eye}
style={Style.iconDefault}
resizeMode="contain"
/>
</Pressable> </Pressable>
</View> </View>
); )
})} })}
</ScrollView> </ScrollView>
</View> </View>
); )
}; }
+57 -73
View File
@@ -1,102 +1,86 @@
import React, { useCallback, useEffect, useRef } from "react"; import React, { useCallback, useEffect, useRef } from 'react'
import { View, Pressable, Text } from "react-native"; import { View, Pressable, Text } from 'react-native'
import { VideoView, useVideoPlayer } from "expo-video"; import { VideoView, useVideoPlayer } from 'expo-video'
import { videos } from "../assets"; import { videos } from '../assets'
import { Portal } from "@gorhom/portal"; import { Portal } from '@gorhom/portal'
// Fullscreen vertical video overlay without controls // Fullscreen vertical video overlay without controls
// Props: // Props:
// - url?: string | number (require), source of the video. Defaults to videos.test // - url?: string | number (require), source of the video. Defaults to videos.test
// - visible?: boolean, when false returns null // - visible?: boolean, when false returns null
// - onClose: () => void, called when user skips or when video ends // - 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 FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
const source = url const source = url ? (typeof url === 'string' ? { uri: url } : url) : videos.test
? typeof url === "string"
? { uri: url }
: url
: videos.test;
const hasClosedRef = useRef(false); const hasClosedRef = useRef(false)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
if (hasClosedRef.current) return; if (hasClosedRef.current) return
hasClosedRef.current = true; hasClosedRef.current = true
try { try {
onClose?.(); onClose?.()
} catch (e) {} } catch (e) {}
}, [onClose]); }, [onClose])
const player = useVideoPlayer(source, (p) => { const player = useVideoPlayer(source, (p) => {
p.loop = false; p.loop = false
p.timeUpdateEventInterval = 0.25; p.timeUpdateEventInterval = 0.25
}); })
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
hasClosedRef.current = false; hasClosedRef.current = false
} else { } else {
hasClosedRef.current = true; hasClosedRef.current = true
try { try {
player?.pause?.(); player?.pause?.()
} catch (e) {} } catch (e) {}
} }
}, [player, visible]); }, [player, visible])
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
return; return
} }
try { try {
player?.play?.(); player?.play?.()
} catch (e) {} } catch (e) {}
}, [player, visible]); }, [player, visible])
useEffect(() => { useEffect(() => {
if (!player || !visible) return; if (!player || !visible) return
const playToEndSub = player.addListener?.("playToEnd", handleClose); const playToEndSub = player.addListener?.('playToEnd', handleClose)
const timeUpdateSub = player.addListener?.( const timeUpdateSub = player.addListener?.('timeUpdate', ({ currentTime } = {}) => {
"timeUpdate", if (!player?.duration || hasClosedRef.current) {
({ currentTime } = {}) => { return
if (!player?.duration || hasClosedRef.current) {
return;
}
const remaining = player.duration - currentTime;
if (
Number.isFinite(remaining) &&
remaining <= CLOSE_THRESHOLD_SECONDS
) {
handleClose();
}
} }
); const remaining = player.duration - currentTime
const playingChangeSub = player.addListener?.( if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
"playingChange", handleClose()
({ 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 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 () => { return () => {
try { try {
playToEndSub?.remove?.(); playToEndSub?.remove?.()
timeUpdateSub?.remove?.(); timeUpdateSub?.remove?.()
playingChangeSub?.remove?.(); playingChangeSub?.remove?.()
} catch (e) {} } catch (e) {}
}; }
}, [handleClose, player, visible]); }, [handleClose, player, visible])
if (!visible) { if (!visible) {
return null; return null
} }
return ( return (
@@ -104,15 +88,15 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
<View <View
pointerEvents="box-none" pointerEvents="box-none"
style={{ style={{
position: "absolute", position: 'absolute',
paddingHorizontal: 10, paddingHorizontal: 10,
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
backgroundColor: "black", backgroundColor: 'black',
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
zIndex: 9999, zIndex: 9999,
}} }}
> >
@@ -133,22 +117,22 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
<Pressable <Pressable
onPress={handleClose} onPress={handleClose}
style={{ style={{
position: "absolute", position: 'absolute',
top: 50, top: 50,
right: 20, right: 20,
backgroundColor: "#00000080", backgroundColor: '#00000080',
paddingVertical: 10, paddingVertical: 10,
paddingHorizontal: 14, paddingHorizontal: 14,
borderRadius: 20, borderRadius: 20,
borderWidth: 1, 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> </Pressable>
</View> </View>
</Portal> </Portal>
); )
}; }
export default FullscreenIntroVideo; export default FullscreenIntroVideo
+87 -87
View File
@@ -1,183 +1,183 @@
import { Portal } from "@gorhom/portal"; import { Portal } from '@gorhom/portal'
import { Asset } from "expo-asset"; import { Asset } from 'expo-asset'
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from 'react-native'
const CLOSE_THRESHOLD_SECONDS = 0.35; const CLOSE_THRESHOLD_SECONDS = 0.35
const CLOSE_POLL_INTERVAL_MS = 500; const CLOSE_POLL_INTERVAL_MS = 500
const overlayStyle = { const overlayStyle = {
position: "fixed", position: 'fixed',
top: 0, top: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
left: 0, left: 0,
backgroundColor: "black", backgroundColor: 'black',
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
zIndex: 9999, zIndex: 9999,
}; }
const videoStyle = { const videoStyle = {
position: "absolute", position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: "100%", width: '100%',
height: "100%", height: '100%',
objectFit: "cover", objectFit: 'cover',
}; }
const closeButtonStyle = { const closeButtonStyle = {
position: "absolute", position: 'absolute',
top: 50, top: 50,
right: 20, right: 20,
backgroundColor: "#00000080", backgroundColor: '#00000080',
paddingVertical: 10, paddingVertical: 10,
paddingHorizontal: 14, paddingHorizontal: 14,
borderRadius: 20, borderRadius: 20,
borderWidth: 1, borderWidth: 1,
borderColor: "#FFFFFF55", borderColor: '#FFFFFF55',
cursor: "pointer", cursor: 'pointer',
}; }
const closeTextStyle = { const closeTextStyle = {
color: "#FFF", color: '#FFF',
fontSize: 14, fontSize: 14,
}; }
const resolveModuleUri = async (module) => { const resolveModuleUri = async (module) => {
const asset = Asset.fromModule(module); const asset = Asset.fromModule(module)
if (!asset.localUri && !asset.uri) { 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 FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
const videoRef = useRef(null); const videoRef = useRef(null)
const [uri, setUri] = useState(null); const [uri, setUri] = useState(null)
const [muted, setMuted] = useState(false); const [muted, setMuted] = useState(false)
const hasClosedRef = useRef(false); const hasClosedRef = useRef(false)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
if (hasClosedRef.current) return; if (hasClosedRef.current) return
hasClosedRef.current = true; hasClosedRef.current = true
onClose?.(); onClose?.()
}, [onClose]); }, [onClose])
const evaluateShouldClose = useCallback(() => { const evaluateShouldClose = useCallback(() => {
const video = videoRef.current; const video = videoRef.current
if (!video || hasClosedRef.current) { if (!video || hasClosedRef.current) {
return; return
} }
if (video.ended) { if (video.ended) {
handleClose(); handleClose()
return; return
} }
const remaining = video.duration - video.currentTime; const remaining = video.duration - video.currentTime
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) { if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
handleClose(); handleClose()
} }
}, [handleClose]); }, [handleClose])
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true
const assignUri = (nextUri) => { const assignUri = (nextUri) => {
if (isMounted) { if (isMounted) {
setMuted(false); setMuted(false)
setUri(nextUri); setUri(nextUri)
} }
}; }
if (typeof url === "string") { if (typeof url === 'string') {
assignUri(url); assignUri(url)
return () => { return () => {
isMounted = false; isMounted = false
}; }
} }
const load = async () => { const load = async () => {
try { try {
const nextUri = await resolveModuleUri(url); const nextUri = await resolveModuleUri(url)
assignUri(nextUri); assignUri(nextUri)
} catch { } catch {
if (isMounted) { if (isMounted) {
setUri(null); setUri(null)
} }
} }
}; }
load(); load()
return () => { return () => {
isMounted = false; isMounted = false
}; }
}, [url]); }, [url])
useEffect(() => { useEffect(() => {
if (!visible || !uri) { if (!visible || !uri) {
return undefined; return undefined
} }
hasClosedRef.current = false; hasClosedRef.current = false
let rafId; let rafId
const attemptPlay = () => { const attemptPlay = () => {
const video = videoRef.current; const video = videoRef.current
if (!video) { if (!video) {
rafId = requestAnimationFrame(attemptPlay); rafId = requestAnimationFrame(attemptPlay)
return; return
} }
video.currentTime = 0; video.currentTime = 0
const result = video.play(); const result = video.play()
if (result?.catch) { if (result?.catch) {
result.catch((error) => { result.catch((error) => {
if (error?.name === "NotAllowedError" && !muted) { if (error?.name === 'NotAllowedError' && !muted) {
setMuted(true); setMuted(true)
} }
}); })
} }
}; }
attemptPlay(); attemptPlay()
const pollId = setInterval(evaluateShouldClose, CLOSE_POLL_INTERVAL_MS); const pollId = setInterval(evaluateShouldClose, CLOSE_POLL_INTERVAL_MS)
return () => { return () => {
if (rafId) { if (rafId) {
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId)
} }
clearInterval(pollId); clearInterval(pollId)
const video = videoRef.current; const video = videoRef.current
video?.pause(); video?.pause()
}; }
}, [evaluateShouldClose, muted, uri, visible]); }, [evaluateShouldClose, muted, uri, visible])
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current
if (!video || !muted) { if (!video || !muted) {
return; return
} }
const result = video.play(); const result = video.play()
if (result?.catch) { if (result?.catch) {
result.catch(() => {}); result.catch(() => {})
} }
}, [muted]); }, [muted])
if (!visible) { if (!visible) {
return null; return null
} }
return ( return (
@@ -204,7 +204,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
</Pressable> </Pressable>
</View> </View>
</Portal> </Portal>
); )
}; }
export default FullscreenIntroVideo; export default FullscreenIntroVideo
+17 -18
View File
@@ -1,40 +1,39 @@
import React from "react"; import React from 'react'
import { Image, Pressable, Text } from "react-native"; import { Image, Pressable, Text } from 'react-native'
import { Palette, Style } from "../styles"; import { Palette, Style } from '../styles'
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from '../styles/Fonts'
import { size } from "../styles/Style"; import { size } from '../styles/Style'
import { LinearGradient } from "./LinearGradient/LinearGradient"; import { LinearGradient } from './LinearGradient/LinearGradient'
const HEIGHT_BY_SIZE = { const HEIGHT_BY_SIZE = {
small: 44, small: 44,
medium: 50, medium: 50,
large: 58, large: 58,
}; }
const FONT_SIZE_BY_SIZE = { const FONT_SIZE_BY_SIZE = {
small: 14, small: 14,
medium: 15, medium: 15,
large: 17, large: 17,
}; }
const GradientButton = ({ const GradientButton = ({
title = "", title = '',
colors = ["#F94697", "#7023F7"], colors = ['#F94697', '#7023F7'],
onPress, onPress,
props, props,
containerStyle = {}, containerStyle = {},
icon, icon,
disabled = false, disabled = false,
maxWidth = null, maxWidth = null,
size = "medium", size = 'medium',
textStyle = {}, textStyle = {},
gradientStyle = {}, gradientStyle = {},
height = null, height = null,
}) => { }) => {
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium"; const resolvedSize = HEIGHT_BY_SIZE[size] ? size : 'medium'
const buttonHeight = const buttonHeight = typeof height === 'number' ? height : HEIGHT_BY_SIZE[resolvedSize]
typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize]; const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
return ( return (
<Pressable <Pressable
@@ -79,7 +78,7 @@ const GradientButton = ({
</Text> </Text>
</LinearGradient> </LinearGradient>
</Pressable> </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 { useWebView } from '../providers/WebViewProvider'
import { Palette } from "../styles"; import { Palette } from '../styles'
const HyperlinkContainer = ({ children }) => { const HyperlinkContainer = ({ children }) => {
const { setWebViewUrl } = useWebView(); const { setWebViewUrl } = useWebView()
return ( return (
<Hyperlink <Hyperlink
onPress={(url) => { onPress={(url) => {
setWebViewUrl(url); setWebViewUrl(url)
}} }}
linkStyle={{ linkStyle={{
color: Palette.primary, color: Palette.primary,
textDecorationLine: "underline", textDecorationLine: 'underline',
}} }}
> >
{children} {children}
</Hyperlink> </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 { Palette, Style } from '../styles'
import { gutters, mainBorderRadius } from "../styles/Style"; import { gutters, mainBorderRadius } from '../styles/Style'
const IconContainer = ({ icon }) => { const IconContainer = ({ icon }) => {
return ( return (
@@ -19,13 +19,13 @@ const IconContainer = ({ icon }) => {
source={icon} source={icon}
resizeMode="contain" resizeMode="contain"
style={{ style={{
width: "50%", width: '50%',
height: "50%", height: '50%',
tintColor: Palette.primary, tintColor: Palette.primary,
}} }}
/> />
</View> </View>
); )
}; }
export default IconContainer; export default IconContainer
+25 -27
View File
@@ -1,29 +1,28 @@
import React, { useState, useEffect } from "reactn"; import React, { useState, useEffect } from 'reactn'
import { Image, Pressable, Text, View } from "react-native"; import { Image, Pressable, Text, View } from 'react-native'
import { FlatGrid } from "react-native-super-grid"; 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 { Fonts, Palette, Style, gutters } from '../styles'
import { mainBorderRadius } from "../styles/Style"; import { mainBorderRadius } from '../styles/Style'
import useLayoutType from "../hooks/useLayoutType.js"; import useLayoutType from '../hooks/useLayoutType.js'
const IconSelector = ({ onClose } = {}) => { const IconSelector = ({ onClose } = {}) => {
const { isNative } = useLayoutType(); const { isNative } = useLayoutType()
const [currentIconIndex, setCurrentIconIndex] = useState(0); const [currentIconIndex, setCurrentIconIndex] = useState(0)
useEffect(() => { useEffect(() => {
if (isNative) { if (isNative) {
// import("expo-dynamic-app-icon").then((module) => { // import("expo-dynamic-app-icon").then((module) => {
// getAppIcon = module.getAppIcon; // getAppIcon = module.getAppIcon;
// const iconIndex = getAppIcon(); // const iconIndex = getAppIcon();
// setCurrentIconIndex(Number(iconIndex)); // setCurrentIconIndex(Number(iconIndex));
// }); // });
} }
}, []); }, [])
return ( return (
<FlatGrid <FlatGrid
@@ -31,8 +30,8 @@ const IconSelector = ({ onClose } = {}) => {
<View> <View>
<Text <Text
style={{ style={{
...Fonts({ type: "title" }), ...Fonts({ type: 'title' }),
textAlign: "center", textAlign: 'center',
marginBottom: gutters / 2, marginBottom: gutters / 2,
}} }}
> >
@@ -40,10 +39,10 @@ const IconSelector = ({ onClose } = {}) => {
</Text> </Text>
<Text <Text
style={{ style={{
...Fonts({ type: "default" }), ...Fonts({ type: 'default' }),
textAlign: "center", textAlign: 'center',
width: "60%", width: '60%',
alignSelf: "center", alignSelf: 'center',
marginBottom: gutters, marginBottom: gutters,
}} }}
> >
@@ -61,13 +60,13 @@ const IconSelector = ({ onClose } = {}) => {
style={{ flex: 1, backgroundColor: Palette.darkPurple }} style={{ flex: 1, backgroundColor: Palette.darkPurple }}
spacing={10} spacing={10}
renderItem={({ item, index }) => { renderItem={({ item, index }) => {
const isSelected = currentIconIndex === index + 1; const isSelected = currentIconIndex === index + 1
return ( return (
<Pressable <Pressable
key={index} key={index}
style={{ style={{
width: "100%", width: '100%',
height: responsiveWidth(45), height: responsiveWidth(45),
...Style.containerCenter, ...Style.containerCenter,
...Style.defaultShadows, ...Style.defaultShadows,
@@ -75,7 +74,6 @@ const IconSelector = ({ onClose } = {}) => {
onPress={() => { onPress={() => {
// import("expo-dynamic-app-icon").then((module) => { // import("expo-dynamic-app-icon").then((module) => {
// setAppIcon = module.setAppIcon; // setAppIcon = module.setAppIcon;
// setAppIcon((index + 1).toString()); // setAppIcon((index + 1).toString());
// setCurrentIconIndex(index + 1); // setCurrentIconIndex(index + 1);
// onClose?.(); // onClose?.();
@@ -86,10 +84,10 @@ const IconSelector = ({ onClose } = {}) => {
source={item} source={item}
resizeMode="cover" resizeMode="cover"
style={{ style={{
width: "90%", width: '90%',
height: "90%", height: '90%',
borderRadius: mainBorderRadius * 2, borderRadius: mainBorderRadius * 2,
overflow: "hidden", overflow: 'hidden',
...(isSelected && { ...(isSelected && {
borderColor: Palette.primary, borderColor: Palette.primary,
borderWidth: 2, borderWidth: 2,
@@ -97,10 +95,10 @@ const IconSelector = ({ onClose } = {}) => {
}} }}
/> />
</Pressable> </Pressable>
); )
}} }}
/> />
); )
}; }
export default IconSelector; export default IconSelector
+64 -76
View File
@@ -1,30 +1,22 @@
import { useState } from "react"; import { useState } from 'react'
import { import { Image, InputAccessoryView, Keyboard, Pressable, Text, TextInput, View } from 'react-native'
Image,
InputAccessoryView,
Keyboard,
Pressable,
Text,
TextInput,
View,
} from "react-native";
// import CountryPicker, { DARK_THEME } from "react-native-country-picker-modal"; // 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 { icons } from '../assets'
import { Fonts, Palette, Style, gutters } from "../styles"; import { Fonts, Palette, Style, gutters } from '../styles'
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from '../styles/Fonts'
import { BlurView } from "expo-blur"; import { BlurView } from 'expo-blur'
import EyeSlashSVG from "../assets/UI/EyeSlashSVG"; import EyeSlashSVG from '../assets/UI/EyeSlashSVG'
import EyeSVG from "../assets/UI/EyeSVG"; import EyeSVG from '../assets/UI/EyeSVG'
import { GOOGLE_API_KEY } from "../data/keys"; import { GOOGLE_API_KEY } from '../data/keys'
import { isWeb } from "../hooks/useLayoutType"; import { isWeb } from '../hooks/useLayoutType'
const Input = ({ const Input = ({
inputRef = null, inputRef = null,
label = "", label = '',
placeholder = "", placeholder = '',
containerStyle = {}, containerStyle = {},
textInputStyle = {}, textInputStyle = {},
@@ -34,48 +26,46 @@ const Input = ({
textInputProps = {}, textInputProps = {},
type = "default", // "default" | "password" | "textarea" | "coinAmount" | "countryPicker" | "autoCompleteAddress" type = 'default', // "default" | "password" | "textarea" | "coinAmount" | "countryPicker" | "autoCompleteAddress"
theme = "default", // "default" | "radioactiv" | "dashed" 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, isNumeric = false,
isBlur = false, isBlur = false,
}) => { }) => {
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false)
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false)
const isDefaultLayout = layout === "default"; const isDefaultLayout = layout === 'default'
const mainColor = const mainColor = theme === 'radioactiv' ? Palette.radioactivGreen : Palette.primary
theme === "radioactiv" ? Palette.radioactivGreen : Palette.primary;
const isRoundedRectangle = const isRoundedRectangle = ['textarea', 'coinAmount'].includes(type) || layout === 'default'
["textarea", "coinAmount"].includes(type) || layout === "default";
const inputAccessoryViewID = "uniqueID"; const inputAccessoryViewID = 'uniqueID'
const { places } = usePlaceApi({ const { places } = usePlaceApi({
query: type === "autoCompleteAddress" ? value : "", query: type === 'autoCompleteAddress' ? value : '',
apiKey: GOOGLE_API_KEY, // Your Google API Key apiKey: GOOGLE_API_KEY, // Your Google API Key
queryFields: "formatted_address,geometry,name,address_components", queryFields: 'formatted_address,geometry,name,address_components',
queryCountries: ["fr"], queryCountries: ['fr'],
language: "fr-FR", language: 'fr-FR',
minChars: 2, minChars: 2,
}); })
const ContainerView = isBlur ? BlurView : View; const ContainerView = isBlur ? BlurView : View
const resolvedKeyboardType = const resolvedKeyboardType =
textInputProps?.keyboardType ?? textInputProps?.keyboardType ??
(isNumeric ? "numeric" : type === "email" ? "email-address" : "default"); (isNumeric ? 'numeric' : type === 'email' ? 'email-address' : 'default')
return ( return (
<> <>
<View <View
style={{ style={{
width: "100%", width: '100%',
...containerStyle, ...containerStyle,
gap: 4, gap: 4,
}} }}
@@ -106,11 +96,11 @@ const Input = ({
? { ? {
paddingVertical: 10, paddingVertical: 10,
backgroundColor: backgroundColor:
type === "coinAmount" type === 'coinAmount'
? Palette.transparentRadioactivGreen ? Palette.transparentRadioactivGreen
: Palette.glass, : Palette.glass,
height: type === "textarea" ? 150 : 50, height: type === 'textarea' ? 150 : 50,
overflow: "hidden", overflow: 'hidden',
borderRadius: 12, borderRadius: 12,
} }
: {}), : {}),
@@ -120,68 +110,66 @@ const Input = ({
borderBottomColor: mainColor, borderBottomColor: mainColor,
borderBottomWidth: 1, borderBottomWidth: 1,
}), }),
...(borderType === "dashed" ...(borderType === 'dashed'
? { ? {
borderStyle: "dashed", borderStyle: 'dashed',
borderColor: isFocused borderColor: isFocused ? Palette.primary : Palette.transparentPrimary,
? Palette.primary
: Palette.transparentPrimary,
borderWidth: 1, borderWidth: 1,
} }
: {}), : {}),
width: "100%", width: '100%',
}} }}
> >
{type === "search" ? ( {type === 'search' ? (
<Image <Image
source={icons.search} source={icons.search}
style={[Style.iconDefault, { marginRight: 10 }]} style={[Style.iconDefault, { marginRight: 10 }]}
resizeMode="contain" resizeMode="contain"
/> />
) : null} ) : null}
{type !== "countryPicker" && ( {type !== 'countryPicker' && (
<TextInput <TextInput
ref={inputRef} ref={inputRef}
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={Palette.gray} placeholderTextColor={Palette.gray}
value={value} value={value}
onChangeText={setValue} onChangeText={setValue}
multiline={type === "textarea"} multiline={type === 'textarea'}
editable={type !== "countryPicker"} editable={type !== 'countryPicker'}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
style={{ style={{
width: "100%", width: '100%',
...(isRoundedRectangle ...(isRoundedRectangle
? { ? {
height: "100%", height: '100%',
flex: 1, flex: 1,
textAlignVertical: type === "textarea" ? "top" : "center", textAlignVertical: type === 'textarea' ? 'top' : 'center',
} }
: { textAlign: "center" }), : { textAlign: 'center' }),
fontSize: 14, fontSize: 14,
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular, fontFamily: FONT_FAMILY.InterRegular,
...(isWeb ...(isWeb
? { ? {
lineHeight: "auto", lineHeight: 'auto',
} }
: {}), : {}),
...textInputStyle, ...textInputStyle,
}} }}
{...(type === "password" {...(type === 'password'
? { ? {
secureTextEntry: !showPassword, secureTextEntry: !showPassword,
autoCapitalize: "none", autoCapitalize: 'none',
autoCompleteType: "password", autoCompleteType: 'password',
textContentType: "password", textContentType: 'password',
} }
: {})} : {})}
{...(type === "email" {...(type === 'email'
? { ? {
autoCapitalize: "none", autoCapitalize: 'none',
autoCompleteType: "email", autoCompleteType: 'email',
textContentType: "emailAddress", textContentType: 'emailAddress',
} }
: {})} : {})}
keyboardType={resolvedKeyboardType} keyboardType={resolvedKeyboardType}
@@ -190,21 +178,21 @@ const Input = ({
{...textInputProps} {...textInputProps}
/> />
)} )}
{type === "password" ? ( {type === 'password' ? (
<Pressable onPress={() => setShowPassword(!showPassword)}> <Pressable onPress={() => setShowPassword(!showPassword)}>
{showPassword ? <EyeSVG /> : <EyeSlashSVG />} {showPassword ? <EyeSVG /> : <EyeSlashSVG />}
</Pressable> </Pressable>
) : null} ) : null}
</ContainerView> </ContainerView>
{type === "autoCompleteAddress" && {type === 'autoCompleteAddress' &&
places?.[0]?.description && places?.[0]?.description &&
places?.[0]?.description !== value && places?.[0]?.description !== value &&
places.map((place, index) => ( places.map((place, index) => (
<Pressable <Pressable
key={index} key={index}
onPress={() => { onPress={() => {
setValue(place?.description); setValue(place?.description)
}} }}
style={{ style={{
...Style.containerSpaceBetween, ...Style.containerSpaceBetween,
@@ -216,19 +204,19 @@ const Input = ({
...Fonts({}), ...Fonts({}),
}} }}
> >
{place?.description || "-"} {place?.description || '-'}
</Text> </Text>
</Pressable> </Pressable>
))} ))}
</View> </View>
{(type === "textarea" || isNumeric) && !isWeb && ( {(type === 'textarea' || isNumeric) && !isWeb && (
<InputAccessoryView nativeID={inputAccessoryViewID}> <InputAccessoryView nativeID={inputAccessoryViewID}>
<Pressable <Pressable
onPress={() => Keyboard.dismiss()} onPress={() => Keyboard.dismiss()}
style={{ style={{
...Style.containerRow, ...Style.containerRow,
justifyContent: "flex-end", justifyContent: 'flex-end',
backgroundColor: Palette.transparentPrimary, backgroundColor: Palette.transparentPrimary,
padding: gutters, padding: gutters,
paddingVertical: gutters / 2, paddingVertical: gutters / 2,
@@ -245,7 +233,7 @@ const Input = ({
</InputAccessoryView> </InputAccessoryView>
)} )}
</> </>
); )
}; }
export { Input }; export { Input }
+51 -70
View File
@@ -1,78 +1,69 @@
import { useState } from "react"; import { useState } from 'react'
import { View, Text, Image, Pressable, StyleSheet } from "react-native"; import { View, Text, Image, Pressable, StyleSheet } from 'react-native'
import { BlurView } from "expo-blur"; import { BlurView } from 'expo-blur'
import Switch from "../components/Switch"; import Switch from '../components/Switch'
import OptionSelector from "../components/OptionSelector"; import OptionSelector from '../components/OptionSelector'
import { Container, Title, Input, Button } from "../components/Dialog"; import { Container, Title, Input, Button } from '../components/Dialog'
import { Fonts, Style, gutters } from "../styles"; import { Fonts, Style, gutters } from '../styles'
import { icons } from "../assets"; import { icons } from '../assets'
const labelOptions = { const labelOptions = {
name: "Nouveau nom", name: 'Nouveau nom',
email: "Nouvelle adresse email", email: 'Nouvelle adresse email',
password: "Nouveau mot de passe", password: 'Nouveau mot de passe',
language: "Nouvelle langue", language: 'Nouvelle langue',
}; }
const languageOptions = { const languageOptions = {
fr: "Français", fr: 'Français',
en: "English", en: 'English',
es: "Español", es: 'Español',
de: "Deutsch", de: 'Deutsch',
it: "Italiano", it: 'Italiano',
}; }
export default ({ itemKey, type, value, title, onUpdateValue }) => { export default ({ itemKey, type, value, title, onUpdateValue }) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false)
const [inputData, setInputData] = useState(value); const [inputData, setInputData] = useState(value)
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState('')
return ( return (
<> <>
<View style={{}}> <View style={{}}>
<View style={Style.containerSpaceBetween}> <View style={Style.containerSpaceBetween}>
<Text style={Fonts({ type: "section" })}>{title}</Text> <Text style={Fonts({ type: 'section' })}>{title}</Text>
{type === "boolean" ? ( {type === 'boolean' ? (
<Switch <Switch
value={value} value={value}
setValue={(newValue) => setValue={(newValue) => onUpdateValue({ key: itemKey, value: newValue })}
onUpdateValue({ key: itemKey, value: newValue })
}
/> />
) : ( ) : (
<Pressable <Pressable
onPress={() => setShowDialog(true)} onPress={() => setShowDialog(true)}
style={[ style={[Style.containerRow, { maxWidth: '50%', justifyContent: 'flex-end' }]}
Style.containerRow,
{ maxWidth: "50%", justifyContent: "flex-end" },
]}
> >
<Text <Text
numberOfLines={1} numberOfLines={1}
style={Fonts({ style={Fonts({
type: "section", type: 'section',
style: { style: {
opacity: 0.5, opacity: 0.5,
textAlign: "right", textAlign: 'right',
width: "80%", width: '80%',
marginRight: gutters, marginRight: gutters,
}, },
})} })}
> >
{itemKey === "password" {itemKey === 'password'
? "********" ? '********'
: itemKey === "language" : itemKey === 'language'
? languageOptions[value] || value ? languageOptions[value] || value
: value} : value}
</Text> </Text>
<Image <Image source={icons.edit} style={Style.iconDefault} resizeMode="contain" />
source={icons.edit}
style={Style.iconDefault}
resizeMode="contain"
/>
</Pressable> </Pressable>
)} )}
</View> </View>
@@ -83,37 +74,33 @@ export default ({ itemKey, type, value, title, onUpdateValue }) => {
<Container <Container
visible={showDialog} visible={showDialog}
blurComponentIOS={ blurComponentIOS={
<BlurView <BlurView style={StyleSheet.absoluteFill} blurType="xdark" blurAmount={50} />
style={StyleSheet.absoluteFill}
blurType="xdark"
blurAmount={50}
/>
} }
> >
<Title>{`Changement ${title?.toLowerCase()}`}</Title> <Title>{`Changement ${title?.toLowerCase()}`}</Title>
{["password", "email"].includes(itemKey) && ( {['password', 'email'].includes(itemKey) && (
<Input <Input
label="Mot de passe actuel" label="Mot de passe actuel"
value={currentPassword} value={currentPassword}
onChangeText={(text) => setCurrentPassword(text)} onChangeText={(text) => setCurrentPassword(text)}
keyboardType="visible-password" keyboardType="visible-password"
type={"password"} type={'password'}
containerStyle={{ marginBottom: gutters / 2 }} containerStyle={{ marginBottom: gutters / 2 }}
/> />
)} )}
{itemKey === "language" ? ( {itemKey === 'language' ? (
<OptionSelector <OptionSelector
optionTypeList={languageOptions} optionTypeList={languageOptions}
selected={inputData || "fr"} selected={inputData || 'fr'}
setSelected={setInputData} setSelected={setInputData}
containerStyle={{ marginBottom: gutters / 2 }} containerStyle={{ marginBottom: gutters / 2 }}
colorMap={{ colorMap={{
fr: { primary: "#F94697", secondary: "#F946971A" }, fr: { primary: '#F94697', secondary: '#F946971A' },
en: { primary: "#7023F7", secondary: "#7023F71A" }, en: { primary: '#7023F7', secondary: '#7023F71A' },
es: { primary: "#FDBA74", secondary: "#FDBA741A" }, es: { primary: '#FDBA74', secondary: '#FDBA741A' },
de: { primary: "#60A5FA", secondary: "#60A5FA1A" }, de: { primary: '#60A5FA', secondary: '#60A5FA1A' },
it: { primary: "#34D399", secondary: "#34D3991A" }, it: { primary: '#34D399', secondary: '#34D3991A' },
}} }}
/> />
) : ( ) : (
@@ -121,9 +108,7 @@ export default ({ itemKey, type, value, title, onUpdateValue }) => {
label={labelOptions[itemKey]} label={labelOptions[itemKey]}
value={inputData} value={inputData}
onChangeText={(text) => setInputData(text)} onChangeText={(text) => setInputData(text)}
autoCapitalize={ autoCapitalize={['password', 'email'].includes(itemKey) ? 'none' : 'words'}
["password", "email"].includes(itemKey) ? "none" : "words"
}
type={itemKey} type={itemKey}
containerStyle={{ marginBottom: gutters / 2 }} containerStyle={{ marginBottom: gutters / 2 }}
/> />
@@ -131,16 +116,12 @@ export default ({ itemKey, type, value, title, onUpdateValue }) => {
<Button <Button
label="Valider" label="Valider"
onPress={() => { onPress={() => {
onUpdateValue({ key: itemKey, value: inputData, currentPassword }); onUpdateValue({ key: itemKey, value: inputData, currentPassword })
setShowDialog(false); setShowDialog(false)
}} }}
/> />
<Button <Button label="Annuler" onPress={() => setShowDialog(false)} type={'secondary'} />
label="Annuler"
onPress={() => setShowDialog(false)}
type={"secondary"}
/>
</Container> </Container>
</> </>
); )
}; }
+16 -20
View File
@@ -1,9 +1,9 @@
import { useKeyboard } from "@react-native-community/hooks"; import { useKeyboard } from '@react-native-community/hooks'
import { BlurView } from "expo-blur"; import { BlurView } from 'expo-blur'
import React from "react"; import React from 'react'
import { Platform, StyleSheet, View } from "react-native"; import { Platform, StyleSheet, View } from 'react-native'
import { responsiveHeight } from "react-native-responsive-dimensions"; import { responsiveHeight } from 'react-native-responsive-dimensions'
import BorderGradient from "../BorderGradient/BorderGradient"; import BorderGradient from '../BorderGradient/BorderGradient'
const ItemContainer = ({ const ItemContainer = ({
height = responsiveHeight(40), height = responsiveHeight(40),
@@ -12,23 +12,19 @@ const ItemContainer = ({
style, style,
disableKeyboardHeight = false, disableKeyboardHeight = false,
}) => { }) => {
const { keyboardShown } = useKeyboard(); const { keyboardShown } = useKeyboard()
return ( return (
<BorderGradient <BorderGradient
gradientProps={{ gradientProps={{
colors: ["#FFFFFF00", "#FFFFFF"], colors: ['#FFFFFF00', '#FFFFFF'],
start: { x: 0.3, y: 0 }, start: { x: 0.3, y: 0 },
end: { x: 1, y: 1 }, end: { x: 1, y: 1 },
...gradientProps, ...gradientProps,
}} }}
style={{ style={{
...styles.borderGradientStyle, ...styles.borderGradientStyle,
height: !disableKeyboardHeight height: !disableKeyboardHeight ? (keyboardShown ? responsiveHeight(30) : height) : height,
? keyboardShown
? responsiveHeight(30)
: height
: height,
...style, ...style,
}} }}
@@ -40,23 +36,23 @@ const ItemContainer = ({
android: 100, android: 100,
web: 100, web: 100,
})} })}
tint={"dark"} tint={'dark'}
style={{ flex: 1, padding: 6 }} style={{ flex: 1, padding: 6 }}
> >
{children} {children}
</BlurView> </BlurView>
</View> </View>
</BorderGradient> </BorderGradient>
); )
}; }
export default ItemContainer; export default ItemContainer
const styles = StyleSheet.create({ const styles = StyleSheet.create({
borderGradientStyle: { borderGradientStyle: {
borderWidth: 1, borderWidth: 1,
borderRadius: 20, borderRadius: 20,
shadowColor: "#000", shadowColor: '#000',
shadowOffset: { shadowOffset: {
width: 0, width: 0,
height: 2, height: 2,
@@ -68,7 +64,7 @@ const styles = StyleSheet.create({
blurContainer: { blurContainer: {
flex: 1, flex: 1,
borderRadius: 20, borderRadius: 20,
overflow: "hidden", overflow: 'hidden',
zIndex: 1, zIndex: 1,
}, },
}); })
@@ -1,27 +1,27 @@
import { View, StyleSheet } from "react-native"; import { View, StyleSheet } from 'react-native'
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from 'react'
import omit from "lodash/omit"; import omit from 'lodash/omit'
import BorderGradient from "../BorderGradient/BorderGradient"; import BorderGradient from '../BorderGradient/BorderGradient'
import { responsiveHeight } from "react-native-responsive-dimensions"; import { responsiveHeight } from 'react-native-responsive-dimensions'
import { BlurView } from "expo-blur"; import { BlurView } from 'expo-blur'
import { Style } from "../../styles"; import { Style } from '../../styles'
const ITEM_BORDER_RADIUS = 24; const ITEM_BORDER_RADIUS = 24
const CONTAINER_STYLE_PROPS = [ const CONTAINER_STYLE_PROPS = [
"margin", 'margin',
"marginTop", 'marginTop',
"marginBottom", 'marginBottom',
"marginLeft", 'marginLeft',
"marginRight", 'marginRight',
"marginHorizontal", 'marginHorizontal',
"marginVertical", 'marginVertical',
"alignSelf", 'alignSelf',
"alignItems", 'alignItems',
"justifyContent", 'justifyContent',
"width", 'width',
"minWidth", 'minWidth',
"maxWidth", 'maxWidth',
]; ]
const ItemContainer = ({ const ItemContainer = ({
height = responsiveHeight(40), height = responsiveHeight(40),
@@ -32,33 +32,33 @@ const ItemContainer = ({
style, style,
disableKeyboardHeight = false, // parity with native signature disableKeyboardHeight = false, // parity with native signature
}) => { }) => {
const [containerLayout, setContainerLayout] = useState(null); const [containerLayout, setContainerLayout] = useState(null)
const flattenedStyle = StyleSheet.flatten(style) || {}; const flattenedStyle = StyleSheet.flatten(style) || {}
const containerStyleOverrides = useMemo(() => { const containerStyleOverrides = useMemo(() => {
return CONTAINER_STYLE_PROPS.reduce((acc, key) => { return CONTAINER_STYLE_PROPS.reduce((acc, key) => {
if (typeof flattenedStyle[key] !== "undefined") { if (typeof flattenedStyle[key] !== 'undefined') {
acc[key] = flattenedStyle[key]; acc[key] = flattenedStyle[key]
} }
return acc; return acc
}, {}); }, {})
}, [flattenedStyle]); }, [flattenedStyle])
const { const {
width: styleWidth, width: styleWidth,
maxWidth: styleMaxWidth, maxWidth: styleMaxWidth,
minWidth: styleMinWidth, minWidth: styleMinWidth,
...remainingContainerStyle ...remainingContainerStyle
} = containerStyleOverrides; } = containerStyleOverrides
const gradientStyleOverrides = useMemo( const gradientStyleOverrides = useMemo(
() => omit(flattenedStyle, CONTAINER_STYLE_PROPS), () => omit(flattenedStyle, CONTAINER_STYLE_PROPS),
[flattenedStyle] [flattenedStyle]
); )
const baseHeight = typeof height === "number" ? height : undefined; const baseHeight = typeof height === 'number' ? height : undefined
const measuredHeight = containerLayout?.height ?? baseHeight; const measuredHeight = containerLayout?.height ?? baseHeight
const resolvedWidth = styleWidth ?? width ?? "100%"; const resolvedWidth = styleWidth ?? width ?? '100%'
const resolvedMaxWidth = styleMaxWidth ?? maxWidth; const resolvedMaxWidth = styleMaxWidth ?? maxWidth
return ( return (
<View <View
@@ -72,7 +72,7 @@ const ItemContainer = ({
> >
<BorderGradient <BorderGradient
gradientProps={{ gradientProps={{
colors: ["rgba(72, 51, 51, 0)", "#FFFFFF"], colors: ['rgba(72, 51, 51, 0)', '#FFFFFF'],
start: { x: 0.3, y: 0 }, start: { x: 0.3, y: 0 },
end: { x: 1, y: 1 }, end: { x: 1, y: 1 },
locations: [0, 1], locations: [0, 1],
@@ -91,32 +91,28 @@ const ItemContainer = ({
baseHeight ? { height: baseHeight } : null, baseHeight ? { height: baseHeight } : null,
]} ]}
onLayout={(e) => { onLayout={(e) => {
setContainerLayout(e.nativeEvent.layout); setContainerLayout(e.nativeEvent.layout)
}} }}
> >
<BlurView <BlurView intensity={disableKeyboardHeight ? 35 : 45} tint="dark" style={styles.blurView}>
intensity={disableKeyboardHeight ? 35 : 45}
tint="dark"
style={styles.blurView}
>
{children} {children}
</BlurView> </BlurView>
</View> </View>
</View> </View>
); )
}; }
export default ItemContainer; export default ItemContainer
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrapper: { wrapper: {
position: "relative", position: 'relative',
alignSelf: "stretch", alignSelf: 'stretch',
}, },
borderGradientStyle: { borderGradientStyle: {
borderWidth: 1.2, borderWidth: 1.2,
borderRadius: ITEM_BORDER_RADIUS, borderRadius: ITEM_BORDER_RADIUS,
shadowColor: "rgba(3, 0, 18, 0.58)", shadowColor: 'rgba(3, 0, 18, 0.58)',
shadowOffset: { shadowOffset: {
width: 0, width: 0,
height: 2, height: 2,
@@ -124,21 +120,21 @@ const styles = StyleSheet.create({
shadowOpacity: 0.22, shadowOpacity: 0.22,
shadowRadius: 18, shadowRadius: 18,
elevation: 8, elevation: 8,
position: "absolute", position: 'absolute',
width: "100%", width: '100%',
}, },
contentWrapper: { contentWrapper: {
borderRadius: ITEM_BORDER_RADIUS, borderRadius: ITEM_BORDER_RADIUS,
overflow: "hidden", overflow: 'hidden',
zIndex: 1, zIndex: 1,
}, },
blurView: { blurView: {
flex: 1, flex: 1,
width: "100%", width: '100%',
height: "100%", height: '100%',
paddingHorizontal: 32, paddingHorizontal: 32,
paddingVertical: 28, paddingVertical: 28,
justifyContent: "center", justifyContent: 'center',
alignSelf: "stretch", alignSelf: 'stretch',
}, },
}); })
+13 -19
View File
@@ -1,11 +1,11 @@
import React from "react"; import React from 'react'
import { Image, Pressable, Text, View } from "react-native"; 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 { Fonts, Palette, Style } from '../styles'
import { icons } from "../assets"; import { icons } from '../assets'
import IconContainer from "./IconContainer"; import IconContainer from './IconContainer'
const ItemRowList = ({ const ItemRowList = ({
title, title,
@@ -14,7 +14,7 @@ const ItemRowList = ({
textStyle = {}, textStyle = {},
addMarginTopFromPrevious = false, addMarginTopFromPrevious = false,
containerStyle = {}, containerStyle = {},
separatorPosition = "bottom", separatorPosition = 'bottom',
}) => { }) => {
return ( return (
<View <View
@@ -23,27 +23,21 @@ const ItemRowList = ({
...containerStyle, ...containerStyle,
}} }}
> >
{separatorPosition === "top" && ( {separatorPosition === 'top' && <View style={Style.separatorHorizontal} />}
<View style={Style.separatorHorizontal} />
)}
<Pressable onPress={action} style={Style.containerSpaceBetween}> <Pressable onPress={action} style={Style.containerSpaceBetween}>
<View style={Style.containerRow}> <View style={Style.containerRow}>
{icon && <IconContainer icon={icon} />} {icon && <IconContainer icon={icon} />}
<Text style={Fonts({ type: "section", style: textStyle })}> <Text style={Fonts({ type: 'section', style: textStyle })}>{title}</Text>
{title}
</Text>
</View> </View>
<Image source={icons.arrowRight} style={Style.iconSmall} /> <Image source={icons.arrowRight} style={Style.iconSmall} />
</Pressable> </Pressable>
{separatorPosition === "bottom" && ( {separatorPosition === 'bottom' && <View style={Style.separatorHorizontal} />}
<View style={Style.separatorHorizontal} />
)}
</View> </View>
); )
}; }
export default ItemRowList; export default ItemRowList
+50 -60
View File
@@ -1,72 +1,68 @@
import React, { useMemo } from "react"; import React, { useMemo } from 'react'
import { Text, View } from "react-native"; import { Text, View } from 'react-native'
import { Palette } from "../styles"; import { Palette } from '../styles'
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from '../styles/Fonts'
export function groupAlignedWordsToLines(alignedWords = [], { removeTags = true } = {}) { export function groupAlignedWordsToLines(alignedWords = [], { removeTags = true } = {}) {
const out = []; const out = []
let buf = []; let buf = []
let start = null; let start = null
const clean = (txt) => const clean = (txt) =>
String(txt || "") String(txt || '')
.replace(/\s+/g, " ") .replace(/\s+/g, ' ')
.trim(); .trim()
const isSentenceEnd = (txt) => /[.!?]$/.test((txt || "").trim()); const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim())
const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || "").trim()); const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || '').trim())
for (let i = 0; i < alignedWords.length; i++) { for (let i = 0; i < alignedWords.length; i++) {
const w = alignedWords[i] || {}; const w = alignedWords[i] || {}
const original = String(w.word || ""); const original = String(w.word || '')
const textNoNewline = original.replace(/\n/g, " "); const textNoNewline = original.replace(/\n/g, ' ')
const textNoTag = removeTags const textNoTag = removeTags ? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, '') : textNoNewline
? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, "") const next = alignedWords[i + 1] || null
: textNoNewline; const gapToNext = next ? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0)) : 0
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 (isSectionTag(textNoNewline)) {
if (!removeTags) { if (!removeTags) {
const joined = clean(textNoNewline); const joined = clean(textNoNewline)
if (joined) if (joined)
out.push({ out.push({
text: joined, text: joined,
startS: start ?? Number(w.startS || 0), startS: start ?? Number(w.startS || 0),
endS: Number(w.endS || 0), endS: Number(w.endS || 0),
}); })
} }
buf = []; buf = []
start = null; start = null
continue; continue
} }
if (!clean(textNoTag)) { if (!clean(textNoTag)) {
continue; continue
} }
buf.push(textNoTag); buf.push(textNoTag)
const eolByNewline = /\n/.test(original); const eolByNewline = /\n/.test(original)
const eolByPause = gapToNext >= 0.6; // threshold for a logical break const eolByPause = gapToNext >= 0.6 // threshold for a logical break
const eolByPunct = isSentenceEnd(textNoTag); const eolByPunct = isSentenceEnd(textNoTag)
const isLast = i === alignedWords.length - 1; const isLast = i === alignedWords.length - 1
if (eolByNewline || eolByPause || eolByPunct || isLast) { if (eolByNewline || eolByPause || eolByPunct || isLast) {
const joined = clean(buf.join(" ")); const joined = clean(buf.join(' '))
if (joined) if (joined)
out.push({ out.push({
text: joined, text: joined,
startS: start ?? Number(w.startS || 0), startS: start ?? Number(w.startS || 0),
endS: Number(w.endS || 0), endS: Number(w.endS || 0),
}); })
buf = []; buf = []
start = null; start = null
} }
} }
return out; return out
} }
export default function KaraokeLyrics({ export default function KaraokeLyrics({
@@ -78,30 +74,24 @@ export default function KaraokeLyrics({
const lines = useMemo( const lines = useMemo(
() => groupAlignedWordsToLines(alignedWords, { removeTags }), () => groupAlignedWordsToLines(alignedWords, { removeTags }),
[alignedWords, removeTags] [alignedWords, removeTags]
); )
const currentLineIdx = useMemo(() => { 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++) { for (let i = 0; i < lines.length; i++) {
const L = lines[i]; const L = lines[i]
if (currentTimeS >= (L.startS || 0) && currentTimeS <= (L.endS || 0)) if (currentTimeS >= (L.startS || 0) && currentTimeS <= (L.endS || 0)) return i
return i;
} }
if (currentTimeS > (lines[lines.length - 1]?.endS || 0)) if (currentTimeS > (lines[lines.length - 1]?.endS || 0)) return lines.length - 1
return lines.length - 1; return -1
return -1; }, [lines, currentTimeS])
}, [lines, currentTimeS]);
if (!lines.length) return null; if (!lines.length) return null
const prev = const prev = showContext && currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : ''
showContext && currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : ""; const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : lines[0]?.text
const curr =
currentLineIdx >= 0 ? lines[currentLineIdx]?.text : lines[0]?.text;
const next = const next =
showContext && currentLineIdx + 1 < lines.length showContext && currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : ''
? lines[currentLineIdx + 1]?.text
: "";
return ( return (
<View style={{ gap: 4 }}> <View style={{ gap: 4 }}>
@@ -121,7 +111,7 @@ export default function KaraokeLyrics({
style={{ style={{
color: Palette.white, color: Palette.white,
fontSize: 18, fontSize: 18,
textAlign: "center", textAlign: 'center',
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
}} }}
> >
@@ -130,9 +120,9 @@ export default function KaraokeLyrics({
{next ? ( {next ? (
<Text <Text
style={{ style={{
color: "#FFFFFF99", color: '#FFFFFF99',
fontSize: 14, fontSize: 14,
textAlign: "center", textAlign: 'center',
fontFamily: FONT_FAMILY.InterMedium, fontFamily: FONT_FAMILY.InterMedium,
}} }}
> >
@@ -140,5 +130,5 @@ export default function KaraokeLyrics({
</Text> </Text>
) : null} ) : null}
</View> </View>
); )
} }
+107 -137
View File
@@ -1,20 +1,19 @@
import { BlurView } from "expo-blur"; import { BlurView } from 'expo-blur'
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from 'react'
import { FlatList, Platform, SectionList, Text, View } from "react-native"; import { FlatList, Platform, SectionList, Text, View } from 'react-native'
import useLayoutType from "../../hooks/useLayoutType"; import useLayoutType from '../../hooks/useLayoutType'
import CreateLyricsHeader from "../../screens/Writing/components/CreateLyricsHeader"; import CreateLyricsHeader from '../../screens/Writing/components/CreateLyricsHeader'
import { Palette } from "../../styles"; import { Palette } from '../../styles'
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from '../../styles/Fonts'
import { LinearGradient } from "../LinearGradient/LinearGradient"; import { LinearGradient } from '../LinearGradient/LinearGradient'
const BLUE_SELECTION_GRADIENT = ["#4673F9", "#7023F7"]; const BLUE_SELECTION_GRADIENT = ['#4673F9', '#7023F7']
const buildItemKey = (value, category) => const buildItemKey = (value, category) => (category ? `${category}::${value}` : `${value}`)
category ? `${category}::${value}` : `${value}`;
const ListSelection = ({ const ListSelection = ({
options = [], options = [],
variant = "simple", variant = 'simple',
selected: selectedProp, selected: selectedProp,
setSelected: setSelectedProp, setSelected: setSelectedProp,
multiple = false, multiple = false,
@@ -27,92 +26,88 @@ const ListSelection = ({
itemContainerStyle, itemContainerStyle,
itemOuterStyle, itemOuterStyle,
itemTextStyle, itemTextStyle,
highlightColor = "#F94697", highlightColor = '#F94697',
disableHover = false, disableHover = false,
}) => { }) => {
// état interne si non contrôlé // état interne si non contrôlé
const [internalSelected, setInternalSelected] = useState( const [internalSelected, setInternalSelected] = useState(
variant === "sectioned" ? {} : multiple ? [] : null variant === 'sectioned' ? {} : multiple ? [] : null
); )
const selected = selectedProp !== undefined ? selectedProp : internalSelected; const selected = selectedProp !== undefined ? selectedProp : internalSelected
const setSelected = const setSelected = setSelectedProp !== undefined ? setSelectedProp : setInternalSelected
setSelectedProp !== undefined ? setSelectedProp : setInternalSelected;
const { isWeb } = useLayoutType(); const { isWeb } = useLayoutType()
const [hoveredKey, setHoveredKey] = useState(null); const [hoveredKey, setHoveredKey] = useState(null)
useEffect(() => { useEffect(() => {
if (disableHover && hoveredKey !== null) setHoveredKey(null); if (disableHover && hoveredKey !== null) setHoveredKey(null)
}, [disableHover, hoveredKey]); }, [disableHover, hoveredKey])
// helpers // helpers
const valueOf = useMemo(() => { const valueOf = useMemo(() => {
if (typeof getItemValue === "function") return getItemValue; if (typeof getItemValue === 'function') return getItemValue
if (variant === "simple") return (item) => item; if (variant === 'simple') return (item) => item
return (item) => item?.title ?? item; return (item) => item?.title ?? item
}, [getItemValue, variant]); }, [getItemValue, variant])
const formatValue = (item) => const formatValue = (item) =>
typeof formatSelectedValue === "function" typeof formatSelectedValue === 'function' ? formatSelectedValue(item) : valueOf(item)
? formatSelectedValue(item)
: valueOf(item);
const extractSelectedComparable = (s) => { const extractSelectedComparable = (s) => {
if (typeof selectedValueExtractor === "function") if (typeof selectedValueExtractor === 'function') return selectedValueExtractor(s)
return selectedValueExtractor(s); if (s && typeof s === 'object' && 'title' in s) return s.title
if (s && typeof s === "object" && "title" in s) return s.title; return s
return s; }
};
const isSelected = (val, category) => { const isSelected = (val, category) => {
if (variant === "sectioned") return selected?.[category] === val; if (variant === 'sectioned') return selected?.[category] === val
if (multiple) { if (multiple) {
const list = Array.isArray(selected) ? selected : []; const list = Array.isArray(selected) ? selected : []
return list.some((v) => v === val); return list.some((v) => v === val)
} }
return extractSelectedComparable(selected) === val; return extractSelectedComparable(selected) === val
}; }
const toggleSelect = (item, category) => { const toggleSelect = (item, category) => {
const val = valueOf(item); const val = valueOf(item)
if (variant === "sectioned") { if (variant === 'sectioned') {
const current = selected && typeof selected === "object" ? selected : {}; const current = selected && typeof selected === 'object' ? selected : {}
const next = { ...current }; const next = { ...current }
if (current?.[category] === val) next[category] = null; if (current?.[category] === val) next[category] = null
else next[category] = formatValue(item); else next[category] = formatValue(item)
setSelected(next); setSelected(next)
return; return
} }
if (multiple) { if (multiple) {
const list = Array.isArray(selected) ? selected : []; const list = Array.isArray(selected) ? selected : []
const exists = list.some((v) => v === val); const exists = list.some((v) => v === val)
if (exists) setSelected(list.filter((v) => v !== val)); if (exists) setSelected(list.filter((v) => v !== val))
else if (!maxSelection || list.length < maxSelection) else if (!maxSelection || list.length < maxSelection)
setSelected([...list, formatValue(item)]); setSelected([...list, formatValue(item)])
} else { } else {
if (extractSelectedComparable(selected) === val) setSelected(null); if (extractSelectedComparable(selected) === val) setSelected(null)
else setSelected(formatValue(item)); else setSelected(formatValue(item))
} }
}; }
// contenus élémentaires : toujours encapsuler le texte dans <Text> // contenus élémentaires : toujours encapsuler le texte dans <Text>
const renderSimpleContent = (label) => ( const renderSimpleContent = (label) => (
<View style={{ minHeight: 40, justifyContent: "center" }}> <View style={{ minHeight: 40, justifyContent: 'center' }}>
<Text <Text
style={{ style={{
fontSize: 16, fontSize: 16,
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular, fontFamily: FONT_FAMILY.InterRegular,
textAlign: "left", textAlign: 'left',
...itemTextStyle, ...itemTextStyle,
}} }}
> >
{typeof label === "string" ? label : String(label)} {typeof label === 'string' ? label : String(label)}
</Text> </Text>
</View> </View>
); )
const renderTitleDescriptionContent = (item) => ( const renderTitleDescriptionContent = (item) => (
<View style={{ paddingVertical: 6 }}> <View style={{ paddingVertical: 6 }}>
@@ -125,14 +120,14 @@ const ListSelection = ({
}} }}
> >
<Text style={{ fontFamily: FONT_FAMILY.InterBold }}>{item?.title}</Text> <Text style={{ fontFamily: FONT_FAMILY.InterBold }}>{item?.title}</Text>
<Text>{` : ${item?.description ?? ""}`}</Text> <Text>{` : ${item?.description ?? ''}`}</Text>
</Text> </Text>
</View> </View>
); )
// applique le gradient bleu UNIQUEMENT sur web et quand sélectionné // applique le gradient bleu UNIQUEMENT sur web et quand sélectionné
const withWebBlueGradientIfSelected = (content, sel) => { 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) // wrap dans un View pour éviter texte brut direct sous le gradient (compat RN Web)
return ( return (
<LinearGradient <LinearGradient
@@ -144,62 +139,53 @@ const ListSelection = ({
borderRadius: 14, borderRadius: 14,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 8, paddingVertical: 8,
overflow: "hidden", overflow: 'hidden',
}} }}
> >
<View>{content}</View> <View>{content}</View>
</LinearGradient> </LinearGradient>
); )
}; }
// ============ SECTIONED ============ // ============ SECTIONED ============
if (variant === "sectioned") { if (variant === 'sectioned') {
return ( return (
<SectionList <SectionList
sections={options} sections={options}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={contentContainerStyle} contentContainerStyle={contentContainerStyle}
renderItem={({ item, section }) => { renderItem={({ item, section }) => {
const cat = section?.title; const cat = section?.title
const val = valueOf(item); const val = valueOf(item)
const sel = isSelected(val, cat); const sel = isSelected(val, cat)
const itemKey = buildItemKey(String(val ?? ""), cat); const itemKey = buildItemKey(String(val ?? ''), cat)
const isHovered = isWeb && !disableHover && hoveredKey === itemKey; const isHovered = isWeb && !disableHover && hoveredKey === itemKey
const showHoverOutline = isHovered && !sel; const showHoverOutline = isHovered && !sel
const showSelectionOutline = !isWeb && sel; const showSelectionOutline = !isWeb && sel
const baseContent = renderSimpleContent(item); const baseContent = renderSimpleContent(item)
const headerContainerStyle = { const headerContainerStyle = {
borderWidth: 0, borderWidth: 0,
borderColor: "transparent", borderColor: 'transparent',
borderRadius: 14, borderRadius: 14,
...itemContainerStyle, ...itemContainerStyle,
}; }
if (sel && isWeb) if (sel && isWeb) headerContainerStyle.backgroundColor = 'transparent'
headerContainerStyle.backgroundColor = "transparent";
return ( return (
<View <View
style={[{ position: "relative" }, itemOuterStyle]} style={[{ position: 'relative' }, itemOuterStyle]}
onMouseEnter={ onMouseEnter={isWeb && !disableHover ? () => setHoveredKey(itemKey) : undefined}
isWeb && !disableHover onMouseLeave={isWeb && !disableHover ? () => setHoveredKey(null) : undefined}
? () => setHoveredKey(itemKey)
: undefined
}
onMouseLeave={
isWeb && !disableHover ? () => setHoveredKey(null) : undefined
}
> >
<CreateLyricsHeader <CreateLyricsHeader
onPress={() => toggleSelect(item, cat)} onPress={() => toggleSelect(item, cat)}
tint={sel ? "default" : "dark"} tint={sel ? 'default' : 'dark'}
colors={[Palette.tran, Palette.tran]} colors={[Palette.tran, Palette.tran]}
showBorder={!sel} showBorder={!sel}
disableBlur={sel && isWeb} disableBlur={sel && isWeb}
blurViewStyle={ blurViewStyle={
sel && isWeb sel && isWeb ? { paddingHorizontal: 0, paddingVertical: 0 } : undefined
? { paddingHorizontal: 0, paddingVertical: 0 }
: undefined
} }
containerStyle={headerContainerStyle} containerStyle={headerContainerStyle}
> >
@@ -210,7 +196,7 @@ const ListSelection = ({
<View <View
pointerEvents="none" pointerEvents="none"
style={{ style={{
position: "absolute", position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
@@ -223,21 +209,17 @@ const ListSelection = ({
/> />
)} )}
</View> </View>
); )
}} }}
renderSectionHeader={({ section: { title } }) => ( renderSectionHeader={({ section: { title } }) => (
<View <View style={{ alignSelf: 'flex-start', marginLeft: 10, marginBottom: 6 }}>
style={{ alignSelf: "flex-start", marginLeft: 10, marginBottom: 6 }}
>
<BlurView <BlurView
intensity={40} intensity={40}
tint="dark" tint="dark"
experimentalBlurMethod={ experimentalBlurMethod={Platform.OS !== 'ios' ? 'dimezisBlurView' : 'none'}
Platform.OS !== "ios" ? "dimezisBlurView" : "none"
}
style={{ style={{
borderRadius: 18, borderRadius: 18,
overflow: "hidden", overflow: 'hidden',
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 4, paddingVertical: 4,
}} }}
@@ -256,7 +238,7 @@ const ListSelection = ({
)} )}
keyExtractor={(item, idx) => `${valueOf(item)}-${idx}`} keyExtractor={(item, idx) => `${valueOf(item)}-${idx}`}
/> />
); )
} }
// ============ FLAT (simple/titleDescription/emotion) ============ // ============ FLAT (simple/titleDescription/emotion) ============
@@ -266,66 +248,54 @@ const ListSelection = ({
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={contentContainerStyle} contentContainerStyle={contentContainerStyle}
renderItem={({ item }) => { renderItem={({ item }) => {
const val = valueOf(item); const val = valueOf(item)
const sel = isSelected(val); const sel = isSelected(val)
const useEmotionColors = variant === "emotion"; const useEmotionColors = variant === 'emotion'
// pour "emotion", on masque le bord gauche si sélectionné // pour "emotion", on masque le bord gauche si sélectionné
const borderColors = useEmotionColors const borderColors = useEmotionColors
? sel ? sel
? [Palette.tran, Palette.tran] ? [Palette.tran, Palette.tran]
: item?.color : item?.color
: [Palette.tran, Palette.tran]; : [Palette.tran, Palette.tran]
const tint = useEmotionColors ? "dark" : sel ? "default" : "dark"; const tint = useEmotionColors ? 'dark' : sel ? 'default' : 'dark'
const itemKey = buildItemKey(String(val ?? "")); const itemKey = buildItemKey(String(val ?? ''))
const isHovered = isWeb && !disableHover && hoveredKey === itemKey; const isHovered = isWeb && !disableHover && hoveredKey === itemKey
const showHoverOutline = isHovered && !sel; const showHoverOutline = isHovered && !sel
const showSelectionOutline = const showSelectionOutline =
!isWeb && !isWeb &&
(variant === "simple" || (variant === 'simple' || variant === 'emotion' || variant === 'titleDescription') &&
variant === "emotion" || sel
variant === "titleDescription") &&
sel;
const baseContent = const baseContent =
variant === "simple" variant === 'simple' ? renderSimpleContent(item) : renderTitleDescriptionContent(item)
? renderSimpleContent(item)
: renderTitleDescriptionContent(item);
const headerContainerStyle = { const headerContainerStyle = {
borderWidth: 0, borderWidth: 0,
borderColor: "transparent", borderColor: 'transparent',
borderRadius: 14, borderRadius: 14,
...itemContainerStyle, ...itemContainerStyle,
}; }
if (sel && isWeb) headerContainerStyle.backgroundColor = "transparent"; if (sel && isWeb) headerContainerStyle.backgroundColor = 'transparent'
const shouldShowBorder = !sel; const shouldShowBorder = !sel
return ( return (
<View <View
style={[{ position: "relative" }, itemOuterStyle]} style={[{ position: 'relative' }, itemOuterStyle]}
onMouseEnter={ onMouseEnter={isWeb && !disableHover ? () => setHoveredKey(itemKey) : undefined}
isWeb && !disableHover ? () => setHoveredKey(itemKey) : undefined onMouseLeave={isWeb && !disableHover ? () => setHoveredKey(null) : undefined}
}
onMouseLeave={
isWeb && !disableHover ? () => setHoveredKey(null) : undefined
}
> >
<CreateLyricsHeader <CreateLyricsHeader
colors={borderColors} colors={borderColors}
tint={tint} tint={tint}
onPress={() => toggleSelect(item)} onPress={() => toggleSelect(item)}
gradientProps={ gradientProps={useEmotionColors && !sel ? { locations: [0.24, 1] } : undefined}
useEmotionColors && !sel ? { locations: [0.24, 1] } : undefined
}
showBorder={shouldShowBorder} showBorder={shouldShowBorder}
disableBlur={sel && isWeb} // web: pas de blur si gradient disableBlur={sel && isWeb} // web: pas de blur si gradient
blurViewStyle={ blurViewStyle={
sel && isWeb sel && isWeb ? { paddingHorizontal: 0, paddingVertical: 0 } : undefined
? { paddingHorizontal: 0, paddingVertical: 0 }
: undefined
} }
containerStyle={headerContainerStyle} containerStyle={headerContainerStyle}
> >
@@ -336,7 +306,7 @@ const ListSelection = ({
<View <View
pointerEvents="none" pointerEvents="none"
style={{ style={{
position: "absolute", position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
@@ -349,11 +319,11 @@ const ListSelection = ({
/> />
)} )}
</View> </View>
); )
}} }}
keyExtractor={(item, idx) => `${valueOf(item)}-${idx}`} 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; const MaskedView = NativeMaskedView
export default MaskedView; export default MaskedView
+4 -4
View File
@@ -1,8 +1,8 @@
import React from "react"; import React from 'react'
import { View } from "react-native"; import { View } from 'react-native'
function MaskedView({ maskElement, ...props }) { 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