feat: fixes and formatter
This commit is contained in:
+5
-5
@@ -1,12 +1,12 @@
|
||||
module.exports = {
|
||||
extends: ["expo", "prettier"],
|
||||
plugins: ["prettier"],
|
||||
extends: ['expo', 'prettier'],
|
||||
plugins: ['prettier'],
|
||||
rules: {
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
'prettier/prettier': [
|
||||
'error',
|
||||
{
|
||||
singleQuote: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
vendor
|
||||
coverage
|
||||
.next
|
||||
.out
|
||||
public/build
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -1,28 +1,22 @@
|
||||
import "@expo/metro-runtime";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { DefaultTheme, NavigationContainer } from "@react-navigation/native";
|
||||
import { useFonts } from "expo-font";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import moment from "moment";
|
||||
import "moment/locale/fr";
|
||||
import { Platform, Text, TextInput } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import React, {
|
||||
setGlobal,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useGlobal,
|
||||
useState,
|
||||
} from "reactn";
|
||||
import '@expo/metro-runtime'
|
||||
import { PortalProvider } from '@gorhom/portal'
|
||||
import { DefaultTheme, NavigationContainer } from '@react-navigation/native'
|
||||
import { useFonts } from 'expo-font'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import moment from 'moment'
|
||||
import 'moment/locale/fr'
|
||||
import { Platform, Text, TextInput } from 'react-native'
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import React, { setGlobal, useCallback, useEffect, useGlobal, useState } from 'reactn'
|
||||
|
||||
import { MainStack, Routes } from "./src/navigation";
|
||||
import { Palette } from "./src/styles";
|
||||
import { MainStack, Routes } from './src/navigation'
|
||||
import { Palette } from './src/styles'
|
||||
|
||||
import { navigationRef, reset } from "./src/navigation/NavigationService";
|
||||
import { navigationRef, reset } from './src/navigation/NavigationService'
|
||||
|
||||
import { MenuProvider } from "react-native-popup-menu";
|
||||
import initialGlobalState from "./src/config/initialGlobalState";
|
||||
import { MenuProvider } from 'react-native-popup-menu'
|
||||
import initialGlobalState from './src/config/initialGlobalState'
|
||||
|
||||
import {
|
||||
Inter_400Regular,
|
||||
@@ -30,143 +24,143 @@ import {
|
||||
Inter_500Medium,
|
||||
Inter_600SemiBold,
|
||||
Inter_700Bold,
|
||||
} from "@expo-google-fonts/inter";
|
||||
import firebase from "./src/config/firebase";
|
||||
import Providers from "./src/providers";
|
||||
} from '@expo-google-fonts/inter'
|
||||
import firebase from './src/config/firebase'
|
||||
import Providers from './src/providers'
|
||||
|
||||
import { LogBox } from "react-native";
|
||||
import CommentsBottomSheet from "./src/components/bottomsheets/CommentsBottomSheet";
|
||||
import ShareQrModalContainer from "./src/components/modal/ShareQrModalContainer";
|
||||
import AppDownloadBanner from "./src/components/AppDownloadBanner";
|
||||
import MobileSplashVideo from "./src/components/MobileSplashVideo";
|
||||
import "./src/utils/Sheet";
|
||||
import { LogBox } from 'react-native'
|
||||
import CommentsBottomSheet from './src/components/bottomsheets/CommentsBottomSheet'
|
||||
import ShareQrModalContainer from './src/components/modal/ShareQrModalContainer'
|
||||
import AppDownloadBanner from './src/components/AppDownloadBanner'
|
||||
import MobileSplashVideo from './src/components/MobileSplashVideo'
|
||||
import './src/utils/Sheet'
|
||||
|
||||
console.disableYellowBox = true;
|
||||
console.reportErrorsAsExceptions = false;
|
||||
console.disableYellowBox = true
|
||||
console.reportErrorsAsExceptions = false
|
||||
|
||||
moment.locale("fr");
|
||||
moment.locale('fr')
|
||||
|
||||
// Keep typography stable regardless of the user's system font scaling on mobile
|
||||
if (Platform.OS !== "web") {
|
||||
if (Platform.OS !== 'web') {
|
||||
if (Text.defaultProps == null) {
|
||||
Text.defaultProps = {};
|
||||
Text.defaultProps = {}
|
||||
}
|
||||
if (TextInput.defaultProps == null) {
|
||||
TextInput.defaultProps = {};
|
||||
TextInput.defaultProps = {}
|
||||
}
|
||||
Text.defaultProps.allowFontScaling = false;
|
||||
TextInput.defaultProps.allowFontScaling = false;
|
||||
Text.defaultProps.allowFontScaling = false
|
||||
TextInput.defaultProps.allowFontScaling = false
|
||||
}
|
||||
|
||||
setGlobal(initialGlobalState);
|
||||
setGlobal(initialGlobalState)
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
const App = () => {
|
||||
const [, setCurrentUID] = useGlobal("currentUID");
|
||||
const [, setCurrentUserRoles] = useGlobal("currentUserRoles");
|
||||
const [, setCurrentUID] = useGlobal('currentUID')
|
||||
const [, setCurrentUserRoles] = useGlobal('currentUserRoles')
|
||||
|
||||
const [appIsReady, setAppIsReady] = useState(false);
|
||||
const [appIsReady, setAppIsReady] = useState(false)
|
||||
|
||||
const [isInitializing, setInitializing] = useState(true);
|
||||
const [isInitializing, setInitializing] = useState(true)
|
||||
|
||||
const routeNameRef = React.useRef(null);
|
||||
const routeNameRef = React.useRef(null)
|
||||
|
||||
const syncActiveRoute = useCallback(() => {
|
||||
const currentRoute = navigationRef.current?.getCurrentRoute();
|
||||
const currentName = currentRoute?.name ?? null;
|
||||
const currentRoute = navigationRef.current?.getCurrentRoute()
|
||||
const currentName = currentRoute?.name ?? null
|
||||
|
||||
if (routeNameRef.current !== currentName) {
|
||||
routeNameRef.current = currentName;
|
||||
setGlobal({ activeRouteName: currentName });
|
||||
routeNameRef.current = currentName
|
||||
setGlobal({ activeRouteName: currentName })
|
||||
}
|
||||
}, [setGlobal]);
|
||||
}, [setGlobal])
|
||||
|
||||
const handleNavigationReady = useCallback(() => {
|
||||
syncActiveRoute();
|
||||
}, [syncActiveRoute]);
|
||||
syncActiveRoute()
|
||||
}, [syncActiveRoute])
|
||||
|
||||
const handleNavigationStateChange = useCallback(() => {
|
||||
syncActiveRoute();
|
||||
}, [syncActiveRoute]);
|
||||
syncActiveRoute()
|
||||
}, [syncActiveRoute])
|
||||
|
||||
const [loaded] = useFonts({
|
||||
NewYorkSemibold: require("./src/assets/fonts/NewYork-Semibold.ttf"),
|
||||
OpenSansRegular: require("./src/assets/fonts/OpenSans-Regular.ttf"),
|
||||
NewYorkSemibold: require('./src/assets/fonts/NewYork-Semibold.ttf'),
|
||||
OpenSansRegular: require('./src/assets/fonts/OpenSans-Regular.ttf'),
|
||||
InterRegular: Inter_400Regular,
|
||||
InterMedium: Inter_500Medium,
|
||||
InterSemiBold: Inter_600SemiBold,
|
||||
InterBold: Inter_700Bold,
|
||||
InterRegularItalic: Inter_400Regular_Italic,
|
||||
HelveticaNeueRegular: require("./src/assets/fonts/HelveticaNeueRegular.ttf"),
|
||||
HelveticaNeueMedium: require("./src/assets/fonts/HelveticaNeueMedium.ttf"),
|
||||
HelveticaNeueBold: require("./src/assets/fonts/HelveticaNeueBold.ttf"),
|
||||
OwnersRegular: require("./src/assets/fonts/OwnersRegular.ttf"),
|
||||
OwnersMedium: require("./src/assets/fonts/OwnersMedium.ttf"),
|
||||
OwnersBold: require("./src/assets/fonts/OwnersBold.ttf"),
|
||||
});
|
||||
HelveticaNeueRegular: require('./src/assets/fonts/HelveticaNeueRegular.ttf'),
|
||||
HelveticaNeueMedium: require('./src/assets/fonts/HelveticaNeueMedium.ttf'),
|
||||
HelveticaNeueBold: require('./src/assets/fonts/HelveticaNeueBold.ttf'),
|
||||
OwnersRegular: require('./src/assets/fonts/OwnersRegular.ttf'),
|
||||
OwnersMedium: require('./src/assets/fonts/OwnersMedium.ttf'),
|
||||
OwnersBold: require('./src/assets/fonts/OwnersBold.ttf'),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
setAppIsReady(true);
|
||||
LogBox.ignoreAllLogs();
|
||||
setAppIsReady(true)
|
||||
LogBox.ignoreAllLogs()
|
||||
}
|
||||
}, [loaded]);
|
||||
}, [loaded])
|
||||
|
||||
const onLayoutRootView = useCallback(async () => {
|
||||
if (appIsReady) {
|
||||
await SplashScreen.hideAsync();
|
||||
await SplashScreen.hideAsync()
|
||||
}
|
||||
}, [appIsReady, loaded]);
|
||||
}, [appIsReady, loaded])
|
||||
|
||||
useEffect(() => {
|
||||
const subscriber = firebase.auth().onAuthStateChanged(onAuthStateChanged);
|
||||
return subscriber;
|
||||
}, []);
|
||||
const subscriber = firebase.auth().onAuthStateChanged(onAuthStateChanged)
|
||||
return subscriber
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") {
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://minuit.app/embed/report-a-problem.js";
|
||||
s.async = true;
|
||||
s.setAttribute("data-project-id", "playbackproduction");
|
||||
s.setAttribute("data-position", "right");
|
||||
s.setAttribute("data-offset", "16");
|
||||
s.setAttribute("data-primary-color", "#FB68A8");
|
||||
s.setAttribute("data-bg-primary-color", "#0E0E12");
|
||||
s.setAttribute("data-bg-secondary-color", "#1D1825");
|
||||
s.setAttribute("data-text-color", "#F3F0F5");
|
||||
if (Platform.OS === 'web') {
|
||||
const s = document.createElement('script')
|
||||
s.src = 'https://minuit.app/embed/report-a-problem.js'
|
||||
s.async = true
|
||||
s.setAttribute('data-project-id', 'playbackproduction')
|
||||
s.setAttribute('data-position', 'right')
|
||||
s.setAttribute('data-offset', '16')
|
||||
s.setAttribute('data-primary-color', '#FB68A8')
|
||||
s.setAttribute('data-bg-primary-color', '#0E0E12')
|
||||
s.setAttribute('data-bg-secondary-color', '#1D1825')
|
||||
s.setAttribute('data-text-color', '#F3F0F5')
|
||||
|
||||
document.body.appendChild(s);
|
||||
document.body.appendChild(s)
|
||||
return () => {
|
||||
try {
|
||||
s.remove();
|
||||
s.remove()
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
}, [])
|
||||
const onAuthStateChanged = async (user) => {
|
||||
if (isInitializing) {
|
||||
setInitializing(false);
|
||||
setInitializing(false)
|
||||
}
|
||||
|
||||
if (user?.uid) {
|
||||
setCurrentUID(user.uid);
|
||||
setCurrentUID(user.uid)
|
||||
|
||||
const idTokenResult = await user.getIdTokenResult();
|
||||
setCurrentUserRoles(idTokenResult?.claims?.roles || []);
|
||||
const idTokenResult = await user.getIdTokenResult()
|
||||
setCurrentUserRoles(idTokenResult?.claims?.roles || [])
|
||||
} else {
|
||||
setCurrentUID(null);
|
||||
setGlobal(initialGlobalState);
|
||||
setCurrentUID(null)
|
||||
setGlobal(initialGlobalState)
|
||||
reset({
|
||||
index: 0,
|
||||
routes: [{ name: Routes.Splash }],
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!loaded || isInitializing) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -177,8 +171,7 @@ const App = () => {
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor:
|
||||
Platform.OS === "web" ? "transparent" : Palette.darkPurple,
|
||||
backgroundColor: Platform.OS === 'web' ? 'transparent' : Palette.darkPurple,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -191,10 +184,7 @@ const App = () => {
|
||||
...DefaultTheme,
|
||||
colors: {
|
||||
...DefaultTheme.colors,
|
||||
background:
|
||||
Platform.OS === "web"
|
||||
? "transparent"
|
||||
: Palette.darkPurple,
|
||||
background: Platform.OS === 'web' ? 'transparent' : Palette.darkPurple,
|
||||
},
|
||||
}}
|
||||
ref={navigationRef}
|
||||
@@ -202,12 +192,10 @@ const App = () => {
|
||||
onStateChange={handleNavigationStateChange}
|
||||
documentTitle={{
|
||||
formatter: (options) =>
|
||||
options?.title
|
||||
? `${options?.title} - MusicLand`
|
||||
: "MusicLand",
|
||||
options?.title ? `${options?.title} - MusicLand` : 'MusicLand',
|
||||
}}
|
||||
linking={{
|
||||
prefixes: ["musicland://", "https://musicland-one.vercel.app"],
|
||||
prefixes: ['musicland://', 'https://musicland-one.vercel.app'],
|
||||
config: {
|
||||
screens: {
|
||||
// Add any deep link configurations here if needed
|
||||
@@ -227,7 +215,7 @@ const App = () => {
|
||||
</PortalProvider>
|
||||
</GestureHandlerRootView>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App
|
||||
|
||||
+5
-8
@@ -1,10 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
api.cache(true)
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
plugins: [
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
"react-native-reanimated/plugin",
|
||||
],
|
||||
};
|
||||
};
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['@babel/plugin-proposal-export-namespace-from', 'react-native-reanimated/plugin'],
|
||||
}
|
||||
}
|
||||
|
||||
+89
-131
@@ -1,69 +1,69 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const parser = require("@babel/parser");
|
||||
const traverse = require("@babel/traverse").default;
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const parser = require('@babel/parser')
|
||||
const traverse = require('@babel/traverse').default
|
||||
|
||||
const entryFile = path.resolve(__dirname, "index.js");
|
||||
const srcDir = path.resolve(__dirname, "src");
|
||||
const entryFile = path.resolve(__dirname, 'index.js')
|
||||
const srcDir = path.resolve(__dirname, 'src')
|
||||
|
||||
// Extensions de fichiers à analyser
|
||||
const scriptExtensions = [".js", ".jsx", ".ts", ".tsx", ".web.js"];
|
||||
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".svg"];
|
||||
const scriptExtensions = ['.js', '.jsx', '.ts', '.tsx', '.web.js']
|
||||
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg']
|
||||
|
||||
// Fonction pour obtenir tous les fichiers dans un dossier avec des extensions spécifiques
|
||||
function getAllFiles(dir, extensions, fileList = []) {
|
||||
const files = fs.readdirSync(dir);
|
||||
const files = fs.readdirSync(dir)
|
||||
files.forEach(function (file) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
const filePath = path.join(dir, file)
|
||||
const stat = fs.statSync(filePath)
|
||||
if (stat.isDirectory()) {
|
||||
getAllFiles(filePath, extensions, fileList);
|
||||
getAllFiles(filePath, extensions, fileList)
|
||||
} else if (extensions.includes(path.extname(file))) {
|
||||
fileList.push(filePath);
|
||||
fileList.push(filePath)
|
||||
}
|
||||
});
|
||||
return fileList;
|
||||
})
|
||||
return fileList
|
||||
}
|
||||
|
||||
// Vérifie si un chemin est un fichier valide
|
||||
function isValidFile(filePath) {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||
}
|
||||
|
||||
// Résout le chemin de l'import en un fichier valide
|
||||
function resolveImport(filePath, importPath) {
|
||||
let importedFile = path.resolve(path.dirname(filePath), importPath);
|
||||
let importedFile = path.resolve(path.dirname(filePath), importPath)
|
||||
|
||||
// Liste des extensions à tester, y compris .web.js
|
||||
const allExtensions = scriptExtensions;
|
||||
const allExtensions = scriptExtensions
|
||||
|
||||
// Si le chemin n'a pas d'extension, essayer avec différentes extensions
|
||||
if (!path.extname(importedFile)) {
|
||||
for (const ext of allExtensions) {
|
||||
if (isValidFile(importedFile + ext)) {
|
||||
return importedFile + ext;
|
||||
return importedFile + ext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si un fichier avec l'extension actuelle existe, le retourner
|
||||
if (isValidFile(importedFile)) {
|
||||
return importedFile;
|
||||
return importedFile
|
||||
}
|
||||
|
||||
// Si le chemin est un répertoire, chercher un index avec les extensions
|
||||
if (fs.existsSync(importedFile) && fs.statSync(importedFile).isDirectory()) {
|
||||
const indexFiles = allExtensions.map((ext) => "index" + ext);
|
||||
const indexFiles = allExtensions.map((ext) => 'index' + ext)
|
||||
for (const indexFile of indexFiles) {
|
||||
const indexFilePath = path.join(importedFile, indexFile);
|
||||
const indexFilePath = path.join(importedFile, indexFile)
|
||||
if (isValidFile(indexFilePath)) {
|
||||
return indexFilePath;
|
||||
return indexFilePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aucun fichier valide trouvé
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
// Analyse des fichiers pour trouver les imports
|
||||
@@ -74,178 +74,136 @@ function getDependencies(
|
||||
usedFiles = new Set(),
|
||||
usedAssets = new Set()
|
||||
) {
|
||||
if (visitedFiles.has(filePath))
|
||||
return { usedDependencies, usedFiles, usedAssets };
|
||||
visitedFiles.add(filePath);
|
||||
usedFiles.add(filePath);
|
||||
if (visitedFiles.has(filePath)) return { usedDependencies, usedFiles, usedAssets }
|
||||
visitedFiles.add(filePath)
|
||||
usedFiles.add(filePath)
|
||||
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
let ast;
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
let ast
|
||||
try {
|
||||
ast = parser.parse(content, {
|
||||
sourceType: "module",
|
||||
plugins: ["jsx", "typescript", "classProperties", "dynamicImport"],
|
||||
});
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript', 'classProperties', 'dynamicImport'],
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de l'analyse du fichier ${filePath}:`, error);
|
||||
return { usedDependencies, usedFiles, usedAssets };
|
||||
console.error(`Erreur lors de l'analyse du fichier ${filePath}:`, error)
|
||||
return { usedDependencies, usedFiles, usedAssets }
|
||||
}
|
||||
|
||||
traverse(ast, {
|
||||
ImportDeclaration({ node }) {
|
||||
const importPath = node.source.value;
|
||||
handleImport(
|
||||
filePath,
|
||||
importPath,
|
||||
visitedFiles,
|
||||
usedDependencies,
|
||||
usedFiles,
|
||||
usedAssets
|
||||
);
|
||||
const importPath = node.source.value
|
||||
handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets)
|
||||
},
|
||||
CallExpression({ node }) {
|
||||
if (
|
||||
node.callee.type === "Import" ||
|
||||
(node.callee.name === "require" && node.arguments.length)
|
||||
node.callee.type === 'Import' ||
|
||||
(node.callee.name === 'require' && node.arguments.length)
|
||||
) {
|
||||
const importArg = node.arguments[0];
|
||||
if (importArg && importArg.type === "StringLiteral") {
|
||||
const importPath = importArg.value;
|
||||
handleImport(
|
||||
filePath,
|
||||
importPath,
|
||||
visitedFiles,
|
||||
usedDependencies,
|
||||
usedFiles,
|
||||
usedAssets
|
||||
);
|
||||
const importArg = node.arguments[0]
|
||||
if (importArg && importArg.type === 'StringLiteral') {
|
||||
const importPath = importArg.value
|
||||
handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets)
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Vérifier si un fichier .web.js correspondant existe
|
||||
if (filePath.endsWith(".js")) {
|
||||
const webFilePath = filePath.replace(/\.js$/, ".web.js");
|
||||
if (filePath.endsWith('.js')) {
|
||||
const webFilePath = filePath.replace(/\.js$/, '.web.js')
|
||||
if (isValidFile(webFilePath)) {
|
||||
getDependencies(
|
||||
webFilePath,
|
||||
visitedFiles,
|
||||
usedDependencies,
|
||||
usedFiles,
|
||||
usedAssets
|
||||
);
|
||||
getDependencies(webFilePath, visitedFiles, usedDependencies, usedFiles, usedAssets)
|
||||
}
|
||||
}
|
||||
|
||||
return { usedDependencies, usedFiles, usedAssets };
|
||||
return { usedDependencies, usedFiles, usedAssets }
|
||||
}
|
||||
|
||||
// Fonction pour gérer les imports
|
||||
function handleImport(
|
||||
filePath,
|
||||
importPath,
|
||||
visitedFiles,
|
||||
usedDependencies,
|
||||
usedFiles,
|
||||
usedAssets
|
||||
) {
|
||||
if (importPath.startsWith(".")) {
|
||||
function handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets) {
|
||||
if (importPath.startsWith('.')) {
|
||||
// Chemin relatif
|
||||
const resolvedPath = resolveImport(filePath, importPath);
|
||||
const resolvedPath = resolveImport(filePath, importPath)
|
||||
if (resolvedPath) {
|
||||
const ext = path.extname(resolvedPath);
|
||||
const ext = path.extname(resolvedPath)
|
||||
if (scriptExtensions.includes(ext)) {
|
||||
getDependencies(
|
||||
resolvedPath,
|
||||
visitedFiles,
|
||||
usedDependencies,
|
||||
usedFiles,
|
||||
usedAssets
|
||||
);
|
||||
getDependencies(resolvedPath, visitedFiles, usedDependencies, usedFiles, usedAssets)
|
||||
} else if (imageExtensions.includes(ext)) {
|
||||
usedAssets.add(resolvedPath);
|
||||
usedAssets.add(resolvedPath)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dépendance de node_modules
|
||||
const dep = importPath.split("/")[0];
|
||||
usedDependencies.add(dep);
|
||||
const dep = importPath.split('/')[0]
|
||||
usedDependencies.add(dep)
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
;(async () => {
|
||||
// Étape 1: Obtenir tous les fichiers utilisés
|
||||
const allScriptFiles = getAllFiles(srcDir, scriptExtensions);
|
||||
const allAssetFiles = getAllFiles(srcDir, imageExtensions);
|
||||
const allScriptFiles = getAllFiles(srcDir, scriptExtensions)
|
||||
const allAssetFiles = getAllFiles(srcDir, imageExtensions)
|
||||
|
||||
const { usedDependencies, usedFiles, usedAssets } =
|
||||
getDependencies(entryFile);
|
||||
const { usedDependencies, usedFiles, usedAssets } = getDependencies(entryFile)
|
||||
|
||||
// Étape 2: Supprimer les fichiers scripts inutilisés
|
||||
const unusedScriptFiles = allScriptFiles.filter(
|
||||
(file) => !usedFiles.has(file)
|
||||
);
|
||||
const unusedScriptFiles = allScriptFiles.filter((file) => !usedFiles.has(file))
|
||||
|
||||
unusedScriptFiles.forEach((file) => {
|
||||
fs.unlinkSync(file);
|
||||
console.log(`Fichier script supprimé: ${file}`);
|
||||
});
|
||||
fs.unlinkSync(file)
|
||||
console.log(`Fichier script supprimé: ${file}`)
|
||||
})
|
||||
|
||||
// Étape 3: Supprimer les images non utilisées
|
||||
const unusedAssetFiles = allAssetFiles.filter(
|
||||
(file) => !usedAssets.has(file)
|
||||
);
|
||||
const unusedAssetFiles = allAssetFiles.filter((file) => !usedAssets.has(file))
|
||||
|
||||
unusedAssetFiles.forEach((file) => {
|
||||
fs.unlinkSync(file);
|
||||
console.log(`Fichier image supprimé: ${file}`);
|
||||
});
|
||||
fs.unlinkSync(file)
|
||||
console.log(`Fichier image supprimé: ${file}`)
|
||||
})
|
||||
|
||||
// Étape 4: Optimiser les images utilisées sans perte de qualité
|
||||
async function optimizeImages(usedAssets) {
|
||||
// Import dynamique des modules ES
|
||||
const imagemin = (await import("imagemin")).default;
|
||||
const imageminOptipng = (await import("imagemin-optipng")).default;
|
||||
const imageminJpegtran = (await import("imagemin-jpegtran")).default;
|
||||
const imageminGifsicle = (await import("imagemin-gifsicle")).default;
|
||||
const imageminSvgo = (await import("imagemin-svgo")).default;
|
||||
const imagemin = (await import('imagemin')).default
|
||||
const imageminOptipng = (await import('imagemin-optipng')).default
|
||||
const imageminJpegtran = (await import('imagemin-jpegtran')).default
|
||||
const imageminGifsicle = (await import('imagemin-gifsicle')).default
|
||||
const imageminSvgo = (await import('imagemin-svgo')).default
|
||||
|
||||
for (const file of usedAssets) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
const plugins = [];
|
||||
const ext = path.extname(file).toLowerCase()
|
||||
const plugins = []
|
||||
|
||||
if (ext === ".png") {
|
||||
plugins.push(imageminOptipng({ optimizationLevel: 3 }));
|
||||
} else if (ext === ".jpg" || ext === ".jpeg") {
|
||||
plugins.push(imageminJpegtran({ progressive: true }));
|
||||
} else if (ext === ".gif") {
|
||||
plugins.push(imageminGifsicle({ optimizationLevel: 3 }));
|
||||
} else if (ext === ".svg") {
|
||||
plugins.push(imageminSvgo());
|
||||
if (ext === '.png') {
|
||||
plugins.push(imageminOptipng({ optimizationLevel: 3 }))
|
||||
} else if (ext === '.jpg' || ext === '.jpeg') {
|
||||
plugins.push(imageminJpegtran({ progressive: true }))
|
||||
} else if (ext === '.gif') {
|
||||
plugins.push(imageminGifsicle({ optimizationLevel: 3 }))
|
||||
} else if (ext === '.svg') {
|
||||
plugins.push(imageminSvgo())
|
||||
} else {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const optimized = await imagemin([file], {
|
||||
destination: path.dirname(file),
|
||||
plugins: plugins,
|
||||
});
|
||||
})
|
||||
|
||||
if (optimized && optimized.length > 0) {
|
||||
console.log(`Image optimisée: ${file}`);
|
||||
console.log(`Image optimisée: ${file}`)
|
||||
} else {
|
||||
console.log(`Image déjà optimisée ou non optimisable: ${file}`);
|
||||
console.log(`Image déjà optimisée ou non optimisable: ${file}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Erreur lors de l'optimisation de l'image ${file}:`,
|
||||
error
|
||||
);
|
||||
console.error(`Erreur lors de l'optimisation de l'image ${file}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await optimizeImages(usedAssets);
|
||||
})();
|
||||
await optimizeImages(usedAssets)
|
||||
})()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y";
|
||||
exports.GEMINI_API_KEY = 'AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y'
|
||||
|
||||
exports.SUNO_API_KEY = "c1636e04f606811511e19ec6e1545aa6"; // api key
|
||||
exports.SUNO_API_KEY = 'c1636e04f606811511e19ec6e1545aa6' // api key
|
||||
|
||||
exports.RESEND_API_KEY = "re_NLcHmYiz_4LoFrzvPBbShNQBgNGTQmBbu";
|
||||
exports.RESEND_API_KEY = 're_NLcHmYiz_4LoFrzvPBbShNQBgNGTQmBbu'
|
||||
|
||||
exports.STRIPE_SECRET_KEY =
|
||||
"sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu";
|
||||
exports.STRIPE_WEBHOOK_SECRET = "whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW";
|
||||
exports.STRIPE_RETURN_URL = "";
|
||||
'sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu'
|
||||
exports.STRIPE_WEBHOOK_SECRET = 'whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW'
|
||||
exports.STRIPE_RETURN_URL = ''
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
exports.SUNO_API_BASE = "https://api.sunoapi.org";
|
||||
exports.SUNO_API_PATH = "/api/v1/generate";
|
||||
exports.SUNO_STATUS_PATH = "/api/v1/generate/record-info";
|
||||
exports.SUNO_TIMESTAMPED_LYRICS_PATH =
|
||||
"/api/v1/generate/get-timestamped-lyrics";
|
||||
exports.SUNO_MODEL = "V5";
|
||||
exports.SUNO_API_BASE = 'https://api.sunoapi.org'
|
||||
exports.SUNO_API_PATH = '/api/v1/generate'
|
||||
exports.SUNO_STATUS_PATH = '/api/v1/generate/record-info'
|
||||
exports.SUNO_TIMESTAMPED_LYRICS_PATH = '/api/v1/generate/get-timestamped-lyrics'
|
||||
exports.SUNO_MODEL = 'V5'
|
||||
exports.SUNO_CALLBACK_URL =
|
||||
"https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback";
|
||||
'https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
exports.BATCH_TYPE = {
|
||||
ADD: "ADD",
|
||||
UPDATE: "UPDATE",
|
||||
DELETE: "DELETE",
|
||||
};
|
||||
ADD: 'ADD',
|
||||
UPDATE: 'UPDATE',
|
||||
DELETE: 'DELETE',
|
||||
}
|
||||
|
||||
+16
-17
@@ -1,18 +1,17 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const musicLandLogoBase64 = fs.readFileSync(
|
||||
path.join(__dirname, "../assets/musicLandLogo.png"),
|
||||
{ encoding: "base64" },
|
||||
);
|
||||
const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`;
|
||||
const musicLandLogoBase64 = fs.readFileSync(path.join(__dirname, '../assets/musicLandLogo.png'), {
|
||||
encoding: 'base64',
|
||||
})
|
||||
const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`
|
||||
|
||||
function basicTemplate({ title = "", content = "", button = null }) {
|
||||
function basicTemplate({ title = '', content = '', button = null }) {
|
||||
const btn = button?.url
|
||||
? `<p><a href="${button.url}" style="display:inline-block;padding:10px 16px;background:#6C5CE7;color:#fff;border-radius:8px;text-decoration:none">${
|
||||
button?.label || "Ouvrir"
|
||||
button?.label || 'Ouvrir'
|
||||
}</a></p>`
|
||||
: "";
|
||||
: ''
|
||||
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
@@ -50,12 +49,12 @@ function basicTemplate({ title = "", content = "", button = null }) {
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body></html>`;
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
function welcomeTemplate({ firstName = "", lastName = "" }) {
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
|
||||
const greeting = fullName ? `Salut ${fullName},` : "Salut,";
|
||||
function welcomeTemplate({ firstName = '', lastName = '' }) {
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim()
|
||||
const greeting = fullName ? `Salut ${fullName},` : 'Salut,'
|
||||
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
@@ -104,8 +103,8 @@ function welcomeTemplate({ firstName = "", lastName = "" }) {
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body></html>`;
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
exports.basicTemplate = basicTemplate;
|
||||
exports.welcomeTemplate = welcomeTemplate;
|
||||
exports.basicTemplate = basicTemplate
|
||||
exports.welcomeTemplate = welcomeTemplate
|
||||
|
||||
@@ -1,88 +1,82 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { BATCH_TYPE } = require("../config/types");
|
||||
const admin = require('firebase-admin')
|
||||
const { BATCH_TYPE } = require('../config/types')
|
||||
|
||||
async function deleteFolder(path) {
|
||||
try {
|
||||
console.log(`Deleting folder: ${path}`);
|
||||
const bucket = admin.storage().bucket();
|
||||
await bucket.deleteFiles({ prefix: path, force: true });
|
||||
console.log(`Folder ${path} deleted successfully`);
|
||||
console.log(`Deleting folder: ${path}`)
|
||||
const bucket = admin.storage().bucket()
|
||||
await bucket.deleteFiles({ prefix: path, force: true })
|
||||
console.log(`Folder ${path} deleted successfully`)
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function batchFirestore({
|
||||
path = "", // ADD only
|
||||
path = '', // ADD only
|
||||
docs = [], // not for ADD
|
||||
data = {}, // not for DELETE
|
||||
type = BATCH_TYPE.UPDATE,
|
||||
}) {
|
||||
try {
|
||||
if (docs?.length === 0 && type !== BATCH_TYPE.ADD) {
|
||||
console.log(`Aucun document trouvé dans la collection ${path}.`);
|
||||
return;
|
||||
console.log(`Aucun document trouvé dans la collection ${path}.`)
|
||||
return
|
||||
}
|
||||
|
||||
// Préparer des lots pour les opérations
|
||||
const batches = [];
|
||||
let currentBatch = admin.firestore().batch();
|
||||
let operationCounter = 0;
|
||||
const batches = []
|
||||
let currentBatch = admin.firestore().batch()
|
||||
let operationCounter = 0
|
||||
|
||||
docs.forEach((doc) => {
|
||||
const ref =
|
||||
doc?.ref ||
|
||||
(typeof doc?.path === "string"
|
||||
? admin.firestore().doc(doc.path)
|
||||
: null);
|
||||
doc?.ref || (typeof doc?.path === 'string' ? admin.firestore().doc(doc.path) : null)
|
||||
const payload =
|
||||
doc && typeof doc.data === "object" && doc.data !== null && !Array.isArray(doc.data)
|
||||
doc && typeof doc.data === 'object' && doc.data !== null && !Array.isArray(doc.data)
|
||||
? doc.data
|
||||
: data;
|
||||
: data
|
||||
|
||||
if (type === BATCH_TYPE.ADD) {
|
||||
// Pour ADD, créer un nouveau document avec ID automatique
|
||||
const newDocRef = admin.firestore().collection(path).doc();
|
||||
currentBatch.set(newDocRef, data);
|
||||
const newDocRef = admin.firestore().collection(path).doc()
|
||||
currentBatch.set(newDocRef, data)
|
||||
} else if (type === BATCH_TYPE.UPDATE) {
|
||||
if (!ref) {
|
||||
throw new Error("batchFirestore UPDATE nécessite une référence de document.");
|
||||
throw new Error('batchFirestore UPDATE nécessite une référence de document.')
|
||||
}
|
||||
if (!payload || Object.keys(payload).length === 0) {
|
||||
throw new Error("batchFirestore UPDATE nécessite des données à écrire.");
|
||||
throw new Error('batchFirestore UPDATE nécessite des données à écrire.')
|
||||
}
|
||||
currentBatch.set(ref, payload, { merge: true });
|
||||
currentBatch.set(ref, payload, { merge: true })
|
||||
} else if (type === BATCH_TYPE.DELETE) {
|
||||
if (!ref) {
|
||||
throw new Error("batchFirestore DELETE nécessite une référence de document.");
|
||||
throw new Error('batchFirestore DELETE nécessite une référence de document.')
|
||||
}
|
||||
currentBatch.delete(ref);
|
||||
currentBatch.delete(ref)
|
||||
} else {
|
||||
throw new Error(`Opération non supportée : ${type}`);
|
||||
throw new Error(`Opération non supportée : ${type}`)
|
||||
}
|
||||
operationCounter++;
|
||||
operationCounter++
|
||||
// Si le lot atteint la limite de 500 opérations, le sauvegarder et en créer un nouveau
|
||||
if (operationCounter === 500) {
|
||||
batches.push(currentBatch);
|
||||
currentBatch = admin.firestore().batch();
|
||||
operationCounter = 0;
|
||||
batches.push(currentBatch)
|
||||
currentBatch = admin.firestore().batch()
|
||||
operationCounter = 0
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Ajouter le dernier lot si des opérations y sont présentes
|
||||
if (operationCounter > 0) {
|
||||
batches.push(currentBatch);
|
||||
batches.push(currentBatch)
|
||||
}
|
||||
|
||||
// Exécuter tous les lots en parallèle
|
||||
await Promise.all(batches.map((batch) => batch.commit()));
|
||||
await Promise.all(batches.map((batch) => batch.commit()))
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Erreur lors des opérations ${type} pour la collection ${path} :`,
|
||||
error,
|
||||
);
|
||||
console.error(`Erreur lors des opérations ${type} pour la collection ${path} :`, error)
|
||||
}
|
||||
}
|
||||
|
||||
exports.batchFirestore = batchFirestore;
|
||||
exports.deleteFolder = deleteFolder;
|
||||
exports.batchFirestore = batchFirestore
|
||||
exports.deleteFolder = deleteFolder
|
||||
|
||||
+99
-114
@@ -1,44 +1,42 @@
|
||||
const { googleAI } = require("@genkit-ai/googleai");
|
||||
const { genkit, z } = require("genkit");
|
||||
const { GEMINI_API_KEY } = require("../config/keys");
|
||||
const admin = require("firebase-admin");
|
||||
const { Buffer } = require("buffer");
|
||||
const { setTimeout } = require("timers/promises");
|
||||
const { googleAI } = require('@genkit-ai/googleai')
|
||||
const { genkit, z } = require('genkit')
|
||||
const { GEMINI_API_KEY } = require('../config/keys')
|
||||
const admin = require('firebase-admin')
|
||||
const { Buffer } = require('buffer')
|
||||
const { setTimeout } = require('timers/promises')
|
||||
|
||||
// --- CONFIGURATION ---
|
||||
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API.
|
||||
const TEXT_MODEL_NAME = "gemini-3-pro-preview";
|
||||
const IMAGE_MODEL_NAME = "gemini-3-pro-image-preview";
|
||||
const TEXT_MODEL_NAME = 'gemini-3-pro-preview'
|
||||
const IMAGE_MODEL_NAME = 'gemini-3-pro-image-preview'
|
||||
|
||||
// --- SINGLETON PATTERN (WARM START) ---
|
||||
// On stocke l'instance en dehors de la fonction pour la réutiliser
|
||||
// entre les invocations si le conteneur est "chaud".
|
||||
let aiInstance = null;
|
||||
let aiInstance = null
|
||||
|
||||
const getAiInstance = () => {
|
||||
if (!aiInstance) {
|
||||
console.log("⚡ [Gemini] Initialisation froide (Cold Start)");
|
||||
console.log('⚡ [Gemini] Initialisation froide (Cold Start)')
|
||||
aiInstance = genkit({
|
||||
plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
|
||||
});
|
||||
})
|
||||
}
|
||||
return aiInstance
|
||||
}
|
||||
return aiInstance;
|
||||
};
|
||||
|
||||
/**
|
||||
* Génération de texte générique
|
||||
*/
|
||||
exports.generateAI = async ({ system = "", prompt = "", schema }) => {
|
||||
const ai = getAiInstance(); // Récupère l'instance singleton
|
||||
exports.generateAI = async ({ system = '', prompt = '', schema }) => {
|
||||
const ai = getAiInstance() // Récupère l'instance singleton
|
||||
|
||||
if (prompt?.length < 1) {
|
||||
throw new Error(
|
||||
"Vous devez spécifier un prompt pour effectuer cette action.",
|
||||
);
|
||||
throw new Error('Vous devez spécifier un prompt pour effectuer cette action.')
|
||||
}
|
||||
|
||||
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`);
|
||||
const startedAt = Date.now();
|
||||
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`)
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
const { output } = await ai.generate({
|
||||
@@ -49,76 +47,68 @@ exports.generateAI = async ({ system = "", prompt = "", schema }) => {
|
||||
config: {
|
||||
temperature: 0.7, // Créativité équilibrée
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
return output;
|
||||
return output
|
||||
} catch (error) {
|
||||
console.error("❌ [generateAI] Error:", error.message);
|
||||
throw error;
|
||||
console.error('❌ [generateAI] Error:', error.message)
|
||||
throw error
|
||||
} finally {
|
||||
console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`);
|
||||
console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyse la toxicité des paroles.
|
||||
* Utilise gemini-1.5-flash-002 avec des réglages permissifs pour l'analyse.
|
||||
*/
|
||||
exports.analyseLyrics = async ({ title = "", lyrics }) => {
|
||||
const ai = getAiInstance();
|
||||
exports.analyseLyrics = async ({ title = '', lyrics }) => {
|
||||
const ai = getAiInstance()
|
||||
|
||||
// --- Normalisation ---
|
||||
const normalizeLyrics = (raw) => {
|
||||
if (!raw) return "";
|
||||
if (typeof raw === "string") return raw;
|
||||
if (!raw) return ''
|
||||
if (typeof raw === 'string') return raw
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
.map((s) => {
|
||||
if (!s) return "";
|
||||
const label = s.type ? String(s.type).toUpperCase() : "SECTION";
|
||||
return `[${label}]\n${s.lyrics || ""}`;
|
||||
if (!s) return ''
|
||||
const label = s.type ? String(s.type).toUpperCase() : 'SECTION'
|
||||
return `[${label}]\n${s.lyrics || ''}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
.join('\n\n')
|
||||
}
|
||||
if (typeof raw === "object") {
|
||||
const parts = [];
|
||||
if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`);
|
||||
if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`);
|
||||
return parts.join("\n\n");
|
||||
if (typeof raw === 'object') {
|
||||
const parts = []
|
||||
if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`)
|
||||
if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`)
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
return String(raw || '')
|
||||
}
|
||||
return String(raw || "");
|
||||
};
|
||||
|
||||
const lyricsText = normalizeLyrics(lyrics).trim();
|
||||
if (!lyricsText) throw new Error("analyseLyrics: paroles requises.");
|
||||
const lyricsText = normalizeLyrics(lyrics).trim()
|
||||
if (!lyricsText) throw new Error('analyseLyrics: paroles requises.')
|
||||
|
||||
// --- Schéma ---
|
||||
const moderationSchema = z.object({
|
||||
title: z.string().describe("Titre analysé"),
|
||||
flagged: z
|
||||
.boolean()
|
||||
.describe("Vrai si le contenu nécessite un avertissement."),
|
||||
blocked: z
|
||||
.boolean()
|
||||
.describe("Vrai UNIQUEMENT si violation grave (Haine, Violence réelle)."),
|
||||
score: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.describe("Score de risque (0=Sûr, 1=Dangereux)."),
|
||||
reasons: z.array(z.string()).describe("Liste concise des raisons."),
|
||||
title: z.string().describe('Titre analysé'),
|
||||
flagged: z.boolean().describe('Vrai si le contenu nécessite un avertissement.'),
|
||||
blocked: z.boolean().describe('Vrai UNIQUEMENT si violation grave (Haine, Violence réelle).'),
|
||||
score: z.number().min(0).max(1).describe('Score de risque (0=Sûr, 1=Dangereux).'),
|
||||
reasons: z.array(z.string()).describe('Liste concise des raisons.'),
|
||||
excerpts: z
|
||||
.array(
|
||||
z.object({
|
||||
quote: z.string(),
|
||||
category: z.string(),
|
||||
severity: z.string(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.max(10),
|
||||
success: z.boolean(),
|
||||
});
|
||||
})
|
||||
|
||||
// --- Prompt ---
|
||||
const system = `Tu es un Expert en Modération de Contenu Musical (Trust & Safety).
|
||||
@@ -127,20 +117,20 @@ TA MISSION : Distinguer l'expression artistique (même crue/vulgaire) du contenu
|
||||
1. "FLAGGED" (Avertissement) : Vulgarités, thèmes matures, drogue, sexe consensuel.
|
||||
2. "BLOCKED" (Interdit) : Discours de haine, harcèlement ciblé, pédopornographie, incitation explicite violence/suicide.
|
||||
|
||||
Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.`;
|
||||
Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.`
|
||||
|
||||
const userPrompt = `
|
||||
ANALYSE CETTE CHANSON :
|
||||
Titre : ${title || "Inconnu"}
|
||||
Titre : ${title || 'Inconnu'}
|
||||
|
||||
PAROLES :
|
||||
"""
|
||||
${lyricsText}
|
||||
"""
|
||||
`;
|
||||
`
|
||||
|
||||
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`);
|
||||
const startedAt = Date.now();
|
||||
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`)
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
const { output } = await ai.generate({
|
||||
@@ -151,108 +141,103 @@ ${lyricsText}
|
||||
config: {
|
||||
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
||||
safetySettings: [
|
||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" },
|
||||
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
|
||||
{
|
||||
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
threshold: "BLOCK_NONE",
|
||||
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
threshold: "BLOCK_NONE",
|
||||
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" },
|
||||
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
|
||||
],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
if (!output) throw new Error("Échec de l'analyse de modération.");
|
||||
if (!output) throw new Error("Échec de l'analyse de modération.")
|
||||
|
||||
// Correction de cohérence
|
||||
if (output.blocked) {
|
||||
output.flagged = true;
|
||||
if (output.score < 0.7) output.score = 0.85;
|
||||
output.flagged = true
|
||||
if (output.score < 0.7) output.score = 0.85
|
||||
}
|
||||
|
||||
return output;
|
||||
return output
|
||||
} finally {
|
||||
console.log(`⏱️ [analyseLyrics] Durée: ${Date.now() - startedAt}ms`);
|
||||
console.log(`⏱️ [analyseLyrics] Durée: ${Date.now() - startedAt}ms`)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Génération d'image via Imagen 3
|
||||
*/
|
||||
exports.generateImageV2 = async (prompt, size = 1024, path = "") => {
|
||||
const ai = getAiInstance();
|
||||
exports.generateImageV2 = async (prompt, size = 1024, path = '') => {
|
||||
const ai = getAiInstance()
|
||||
|
||||
if (typeof prompt !== "string" || prompt.trim().length < 1) {
|
||||
throw new Error("Prompt requis.");
|
||||
if (typeof prompt !== 'string' || prompt.trim().length < 1) {
|
||||
throw new Error('Prompt requis.')
|
||||
}
|
||||
|
||||
console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`);
|
||||
const startedAt = Date.now();
|
||||
console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`)
|
||||
const startedAt = Date.now()
|
||||
|
||||
// Optimisation du prompt pour Imagen
|
||||
let enhancedPrompt = prompt.trim();
|
||||
if (!enhancedPrompt.toLowerCase().includes("high quality")) {
|
||||
enhancedPrompt += ", high quality, detailed, 4k";
|
||||
let enhancedPrompt = prompt.trim()
|
||||
if (!enhancedPrompt.toLowerCase().includes('high quality')) {
|
||||
enhancedPrompt += ', high quality, detailed, 4k'
|
||||
}
|
||||
// Aspect ratio 1:1 pour les pochettes
|
||||
enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`;
|
||||
enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`
|
||||
|
||||
const maxAttempts = 3;
|
||||
let lastError = null;
|
||||
const maxAttempts = 3
|
||||
let lastError = null
|
||||
|
||||
try {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
console.log(`🔄 Tentative ${attempt}/${maxAttempts}`);
|
||||
console.log(`🔄 Tentative ${attempt}/${maxAttempts}`)
|
||||
|
||||
const response = await ai.generate({
|
||||
model: googleAI.model(IMAGE_MODEL_NAME),
|
||||
prompt: enhancedPrompt,
|
||||
});
|
||||
})
|
||||
|
||||
const media = response.media;
|
||||
const media = response.media
|
||||
|
||||
if (media && media.url) {
|
||||
console.log("✅ Image générée.");
|
||||
console.log('✅ Image générée.')
|
||||
|
||||
// --- Sauvegarde dans Firebase Storage ---
|
||||
const dataUrl = String(media.url);
|
||||
const commaIdx = dataUrl.indexOf(",");
|
||||
const b64 =
|
||||
commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl;
|
||||
const buffer = Buffer.from(b64, "base64");
|
||||
const dataUrl = String(media.url)
|
||||
const commaIdx = dataUrl.indexOf(',')
|
||||
const b64 = commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl
|
||||
const buffer = Buffer.from(b64, 'base64')
|
||||
|
||||
const bucket = admin.storage().bucket();
|
||||
const token = require("crypto").randomUUID();
|
||||
const file = bucket.file(path);
|
||||
const bucket = admin.storage().bucket()
|
||||
const token = require('crypto').randomUUID()
|
||||
const file = bucket.file(path)
|
||||
|
||||
await file.save(buffer, {
|
||||
resumable: false,
|
||||
metadata: {
|
||||
contentType: media.contentType || "image/png",
|
||||
contentType: media.contentType || 'image/png',
|
||||
metadata: { firebaseStorageDownloadTokens: token },
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`;
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`
|
||||
}
|
||||
|
||||
throw new Error("Pas de média dans la réponse IA.");
|
||||
throw new Error('Pas de média dans la réponse IA.')
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message);
|
||||
lastError = err;
|
||||
if (attempt < maxAttempts) await setTimeout(2000 * attempt);
|
||||
console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message)
|
||||
lastError = err
|
||||
if (attempt < maxAttempts) await setTimeout(2000 * attempt)
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`,
|
||||
);
|
||||
throw new Error(`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`)
|
||||
} finally {
|
||||
console.log(
|
||||
`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`,
|
||||
);
|
||||
console.log(`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,137 +1,115 @@
|
||||
exports.generatePicturePrompt = (project = {}) => {
|
||||
const {
|
||||
title = "",
|
||||
title = '',
|
||||
lyrics: lyricsRaw,
|
||||
musicConfig = {},
|
||||
coverStyle: coverStyleRaw = "",
|
||||
artistName: artistNameRaw = "",
|
||||
} = project || {};
|
||||
coverStyle: coverStyleRaw = '',
|
||||
artistName: artistNameRaw = '',
|
||||
} = project || {}
|
||||
|
||||
// --- 1. Nettoyage et Normalisation ---
|
||||
const sanitizeInline = (value = "") => {
|
||||
if (typeof value !== "string") return "";
|
||||
const sanitizeInline = (value = '') => {
|
||||
if (typeof value !== 'string') return ''
|
||||
return value
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.replace(/[<>]/g, "")
|
||||
.trim();
|
||||
};
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.replace(/[<>]/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const titleForPrompt = sanitizeInline(title) || "Sans titre";
|
||||
const artistName =
|
||||
sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || "");
|
||||
const hasArtistName = artistName.length > 0;
|
||||
const titleForPrompt = sanitizeInline(title) || 'Sans titre'
|
||||
const artistName = sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || '')
|
||||
const hasArtistName = artistName.length > 0
|
||||
|
||||
const {
|
||||
genres = [],
|
||||
tempo = "",
|
||||
mood = "",
|
||||
instruments = [],
|
||||
} = musicConfig || {};
|
||||
const userStyle = sanitizeInline(coverStyleRaw);
|
||||
const { genres = [], tempo = '', mood = '', instruments = [] } = musicConfig || {}
|
||||
const userStyle = sanitizeInline(coverStyleRaw)
|
||||
|
||||
// --- 2. Intelligence Visuelle (Mapping) ---
|
||||
|
||||
// Détermination de l'énergie visuelle
|
||||
const isEnergetic =
|
||||
tempo &&
|
||||
/\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(
|
||||
String(tempo),
|
||||
);
|
||||
tempo && /\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(String(tempo))
|
||||
const isDark =
|
||||
mood &&
|
||||
/\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(
|
||||
String(mood),
|
||||
);
|
||||
mood && /\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(String(mood))
|
||||
|
||||
// Construction de la Palette & Lumière
|
||||
let visualAtmosphere = "";
|
||||
let visualAtmosphere = ''
|
||||
if (isDark) {
|
||||
visualAtmosphere =
|
||||
"Atmosphere: Cinematic atmosphere, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents.";
|
||||
'Atmosphere: Cinematic atmosphere, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents.'
|
||||
} else if (isEnergetic) {
|
||||
visualAtmosphere =
|
||||
"Atmosphere: Dynamic atmosphere, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast.";
|
||||
'Atmosphere: Dynamic atmosphere, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast.'
|
||||
} else {
|
||||
visualAtmosphere =
|
||||
"Atmosphere: Soft atmosphere, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors.";
|
||||
'Atmosphere: Soft atmosphere, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors.'
|
||||
}
|
||||
|
||||
// Définition du Style de Rendu (Si l'utilisateur est vague, on renforce)
|
||||
let renderingStyle = userStyle
|
||||
? `Art Style: ${userStyle}`
|
||||
: "Art Style: Digital Art, Mixed Media";
|
||||
let renderingStyle = userStyle ? `Art Style: ${userStyle}` : 'Art Style: Digital Art, Mixed Media'
|
||||
|
||||
if (
|
||||
userStyle.toLowerCase().includes("realist") ||
|
||||
userStyle.toLowerCase().includes("photo")
|
||||
) {
|
||||
if (userStyle.toLowerCase().includes('realist') || userStyle.toLowerCase().includes('photo')) {
|
||||
renderingStyle +=
|
||||
", 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing.";
|
||||
', 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing.'
|
||||
} else if (
|
||||
userStyle.toLowerCase().includes("illu") ||
|
||||
userStyle.toLowerCase().includes("dessin")
|
||||
userStyle.toLowerCase().includes('illu') ||
|
||||
userStyle.toLowerCase().includes('dessin')
|
||||
) {
|
||||
renderingStyle +=
|
||||
", vector art, clean lines, professional illustration, flat design or detailed painting.";
|
||||
', vector art, clean lines, professional illustration, flat design or detailed painting.'
|
||||
} else {
|
||||
// Style par défaut "Album Cover" qui marche bien
|
||||
renderingStyle +=
|
||||
", abstract surrealism, conceptual album art, high fidelity, masterpiece.";
|
||||
renderingStyle += ', abstract surrealism, conceptual album art, high fidelity, masterpiece.'
|
||||
}
|
||||
|
||||
// --- 3. Extraction de l'Inspiration (Lyrics) ---
|
||||
|
||||
// On cherche le REFRAIN en priorité pour l'image, car c'est le cœur visuel
|
||||
const sections = Array.isArray(lyricsRaw) ? lyricsRaw : [];
|
||||
const chorus = sections.find(
|
||||
(s) => s.type === "refrain" || s.type === "chorus",
|
||||
);
|
||||
const verse = sections.find(
|
||||
(s) => s.type === "couplet" || s.type === "verse",
|
||||
);
|
||||
const sections = Array.isArray(lyricsRaw) ? lyricsRaw : []
|
||||
const chorus = sections.find((s) => s.type === 'refrain' || s.type === 'chorus')
|
||||
const verse = sections.find((s) => s.type === 'couplet' || s.type === 'verse')
|
||||
|
||||
// On prend 2 lignes max du refrain, ou du premier couplet
|
||||
const visualHook = (chorus?.lyrics || verse?.lyrics || "")
|
||||
.split("\n")
|
||||
const visualHook = (chorus?.lyrics || verse?.lyrics || '')
|
||||
.split('\n')
|
||||
.filter((l) => l.length > 10) // On évite les lignes trop courtes
|
||||
.slice(0, 2)
|
||||
.join(". ");
|
||||
.join('. ')
|
||||
|
||||
const imageryPrompt = visualHook
|
||||
? `Visual Inspiration: An interpretation of these lyrics: "${visualHook}".`
|
||||
: "Visual Inspiration: Abstract visual representation of the song's mood.";
|
||||
: "Visual Inspiration: Abstract visual representation of the song's mood."
|
||||
|
||||
// --- 4. Construction du Prompt Final (Structure Optimisée Imagen 3) ---
|
||||
|
||||
const promptParts = [
|
||||
// Rôle
|
||||
"Design a professional, high-quality music album cover.",
|
||||
'Design a professional, high-quality music album cover.',
|
||||
|
||||
// 1. Le Texte (Crucial pour Imagen 3 - Doit être au début ou très clair)
|
||||
`**Typography & Text:**`,
|
||||
`The song title "${titleForPrompt}" must be the CENTERPIECE. Write it in a distinct font that matches the mood.`,
|
||||
hasArtistName
|
||||
? `The artist name "${artistName}" must appear smaller, elegant, and legible near the bottom or top.`
|
||||
: "",
|
||||
"Ensure perfect spelling. The text should be integrated into the artwork (e.g., metallic texture, neon glow, or bold cut-out), not just pasted on top.",
|
||||
: '',
|
||||
'Ensure perfect spelling. The text should be integrated into the artwork (e.g., metallic texture, neon glow, or bold cut-out), not just pasted on top.',
|
||||
|
||||
// 2. Le Visuel
|
||||
`**Visuals:**`,
|
||||
renderingStyle,
|
||||
imageryPrompt,
|
||||
`Subject: A central visual element that represents the song. ${isEnergetic ? "Dynamic composition." : "Balanced, centered composition."}`,
|
||||
`Subject: A central visual element that represents the song. ${isEnergetic ? 'Dynamic composition.' : 'Balanced, centered composition.'}`,
|
||||
|
||||
// 3. L'Atmosphère (Context from music config)
|
||||
`**Mood & Color:**`,
|
||||
visualAtmosphere,
|
||||
genres.length > 0 ? `Musical Vibe Reference: ${genres.join(", ")}.` : "",
|
||||
genres.length > 0 ? `Musical Vibe Reference: ${genres.join(', ')}.` : '',
|
||||
|
||||
// 4. Contraintes Négatives (Phrasées positivement pour l'IA)
|
||||
"**Constraints:**",
|
||||
"Use a square 1:1 aspect ratio.",
|
||||
"Do NOT depict literal musical instruments (like guitars or microphones) unless they are part of a surreal abstract composition.",
|
||||
"No blurry text. No messy borders. No watermarks.",
|
||||
];
|
||||
'**Constraints:**',
|
||||
'Use a square 1:1 aspect ratio.',
|
||||
'Do NOT depict literal musical instruments (like guitars or microphones) unless they are part of a surreal abstract composition.',
|
||||
'No blurry text. No messy borders. No watermarks.',
|
||||
]
|
||||
|
||||
return promptParts.filter(Boolean).join("\n\n");
|
||||
};
|
||||
return promptParts.filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
+19
-19
@@ -1,26 +1,26 @@
|
||||
const normalizeDate = (input) =>
|
||||
typeof input?.toDate === "function" ? input.toDate() : new Date(input);
|
||||
typeof input?.toDate === 'function' ? input.toDate() : new Date(input)
|
||||
|
||||
const buildMonthKey = (timestamp) => {
|
||||
const date = normalizeDate(timestamp);
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
return `${year}-${month}`;
|
||||
};
|
||||
const date = normalizeDate(timestamp)
|
||||
const year = date.getUTCFullYear()
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||
return `${year}-${month}`
|
||||
}
|
||||
|
||||
const buildPreviousMonthContext = (referenceDate) => {
|
||||
const current = referenceDate ? new Date(referenceDate) : new Date();
|
||||
current.setUTCHours(0, 0, 0, 0);
|
||||
current.setUTCDate(1);
|
||||
const current = referenceDate ? new Date(referenceDate) : new Date()
|
||||
current.setUTCHours(0, 0, 0, 0)
|
||||
current.setUTCDate(1)
|
||||
|
||||
const target = new Date(current);
|
||||
target.setUTCMonth(target.getUTCMonth() - 1);
|
||||
const target = new Date(current)
|
||||
target.setUTCMonth(target.getUTCMonth() - 1)
|
||||
|
||||
const year = target.getUTCFullYear();
|
||||
const monthIndex = target.getUTCMonth();
|
||||
const rangeStart = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0));
|
||||
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999));
|
||||
const monthKey = buildMonthKey(rangeStart);
|
||||
const year = target.getUTCFullYear()
|
||||
const monthIndex = target.getUTCMonth()
|
||||
const rangeStart = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0))
|
||||
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999))
|
||||
const monthKey = buildMonthKey(rangeStart)
|
||||
|
||||
return {
|
||||
year,
|
||||
@@ -28,10 +28,10 @@ const buildPreviousMonthContext = (referenceDate) => {
|
||||
monthKey,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildMonthKey,
|
||||
buildPreviousMonthContext,
|
||||
};
|
||||
}
|
||||
|
||||
+189
-230
@@ -1,281 +1,263 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { HttpsError } = require("firebase-functions/https");
|
||||
const Stripe = require("stripe");
|
||||
const { URL } = require("url");
|
||||
const admin = require('firebase-admin')
|
||||
const { HttpsError } = require('firebase-functions/https')
|
||||
const Stripe = require('stripe')
|
||||
const { URL } = require('url')
|
||||
|
||||
const {
|
||||
STRIPE_SECRET_KEY = "",
|
||||
STRIPE_RETURN_URL = "",
|
||||
STRIPE_PORTAL_CONFIGURATION = "",
|
||||
STRIPE_SECRET_KEY = '',
|
||||
STRIPE_RETURN_URL = '',
|
||||
STRIPE_PORTAL_CONFIGURATION = '',
|
||||
STRIPE_MODE: CONFIG_STRIPE_MODE,
|
||||
} = require("../config/keys");
|
||||
} = require('../config/keys')
|
||||
|
||||
const STRIPE_MODE =
|
||||
typeof CONFIG_STRIPE_MODE === "string" && CONFIG_STRIPE_MODE.trim()
|
||||
typeof CONFIG_STRIPE_MODE === 'string' && CONFIG_STRIPE_MODE.trim()
|
||||
? CONFIG_STRIPE_MODE.trim()
|
||||
: "test";
|
||||
: 'test'
|
||||
|
||||
const requireEnv = (key) => {
|
||||
const value = process.env?.[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
const value = process.env?.[key]
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
throw new Error(`${key} not configured`)
|
||||
}
|
||||
throw new Error(`${key} not configured`);
|
||||
};
|
||||
|
||||
const STRIPE_API_VERSION = "2023-10-16";
|
||||
const DEFAULT_TEST_RETURN_URL = "http://localhost:8081";
|
||||
const ALLOWED_RETURN_SCHEMES = ["http", "https", "minuit"];
|
||||
const STRIPE_API_VERSION = '2023-10-16'
|
||||
const DEFAULT_TEST_RETURN_URL = 'http://localhost:8081'
|
||||
const ALLOWED_RETURN_SCHEMES = ['http', 'https', 'minuit']
|
||||
|
||||
let cachedStripeClient = null;
|
||||
let cachedPortalConfigurationId = null;
|
||||
let cachedStripeClient = null
|
||||
let cachedPortalConfigurationId = null
|
||||
|
||||
const resolveStripeSecretKey = () => {
|
||||
const inlineKey =
|
||||
typeof STRIPE_SECRET_KEY === "string" ? STRIPE_SECRET_KEY.trim() : "";
|
||||
const inlineKey = typeof STRIPE_SECRET_KEY === 'string' ? STRIPE_SECRET_KEY.trim() : ''
|
||||
|
||||
if (inlineKey) {
|
||||
return inlineKey;
|
||||
return inlineKey
|
||||
}
|
||||
|
||||
const required = requireEnv("STRIPE_SECRET_KEY");
|
||||
if (typeof required === "string" && required.trim()) {
|
||||
return required.trim();
|
||||
const required = requireEnv('STRIPE_SECRET_KEY')
|
||||
if (typeof required === 'string' && required.trim()) {
|
||||
return required.trim()
|
||||
}
|
||||
|
||||
throw new Error("STRIPE_SECRET_KEY not configured");
|
||||
};
|
||||
throw new Error('STRIPE_SECRET_KEY not configured')
|
||||
}
|
||||
|
||||
const getStripeClient = () => {
|
||||
if (cachedStripeClient) {
|
||||
return cachedStripeClient;
|
||||
return cachedStripeClient
|
||||
}
|
||||
|
||||
let secretKey;
|
||||
let secretKey
|
||||
try {
|
||||
secretKey = resolveStripeSecretKey();
|
||||
secretKey = resolveStripeSecretKey()
|
||||
} catch (error) {
|
||||
console.error("[getStripeClient] Missing STRIPE_SECRET_KEY", error);
|
||||
console.error('[getStripeClient] Missing STRIPE_SECRET_KEY', error)
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"Stripe n’est pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.",
|
||||
);
|
||||
'failed-precondition',
|
||||
'Stripe n’est pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.'
|
||||
)
|
||||
}
|
||||
|
||||
cachedStripeClient = new Stripe(secretKey, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
})
|
||||
|
||||
return cachedStripeClient;
|
||||
};
|
||||
return cachedStripeClient
|
||||
}
|
||||
|
||||
const getReturnBaseUrl = () => {
|
||||
const resolveBase = () => {
|
||||
if (STRIPE_RETURN_URL) {
|
||||
return STRIPE_RETURN_URL;
|
||||
return STRIPE_RETURN_URL
|
||||
}
|
||||
if (STRIPE_MODE !== "prod") {
|
||||
return DEFAULT_TEST_RETURN_URL;
|
||||
if (STRIPE_MODE !== 'prod') {
|
||||
return DEFAULT_TEST_RETURN_URL
|
||||
}
|
||||
return requireEnv('STRIPE_RETURN_URL')
|
||||
}
|
||||
return requireEnv("STRIPE_RETURN_URL");
|
||||
};
|
||||
|
||||
const rawBase = resolveBase();
|
||||
const sanitizedBase = typeof rawBase === "string" ? rawBase.trim() : "";
|
||||
const rawBase = resolveBase()
|
||||
const sanitizedBase = typeof rawBase === 'string' ? rawBase.trim() : ''
|
||||
if (!sanitizedBase) {
|
||||
if (STRIPE_MODE !== "prod") {
|
||||
return DEFAULT_TEST_RETURN_URL;
|
||||
if (STRIPE_MODE !== 'prod') {
|
||||
return DEFAULT_TEST_RETURN_URL
|
||||
}
|
||||
throw new Error("STRIPE_RETURN_URL not configured");
|
||||
throw new Error('STRIPE_RETURN_URL not configured')
|
||||
}
|
||||
|
||||
return sanitizedBase.endsWith("/")
|
||||
? sanitizedBase.slice(0, -1)
|
||||
: sanitizedBase;
|
||||
};
|
||||
return sanitizedBase.endsWith('/') ? sanitizedBase.slice(0, -1) : sanitizedBase
|
||||
}
|
||||
|
||||
const sanitizeReturnUrl = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const validateScheme = (scheme) => {
|
||||
if (!ALLOWED_RETURN_SCHEMES.includes(scheme)) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
`Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`,
|
||||
);
|
||||
'invalid-argument',
|
||||
`Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(trimmed);
|
||||
const scheme = parsedUrl.protocol.replace(":", "").toLowerCase();
|
||||
validateScheme(scheme);
|
||||
return trimmed;
|
||||
const parsedUrl = new URL(trimmed)
|
||||
const scheme = parsedUrl.protocol.replace(':', '').toLowerCase()
|
||||
validateScheme(scheme)
|
||||
return trimmed
|
||||
} catch (_error) {
|
||||
const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i);
|
||||
const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i)
|
||||
if (schemeMatch && schemeMatch[1]) {
|
||||
const scheme = schemeMatch[1].toLowerCase();
|
||||
validateScheme(scheme);
|
||||
return trimmed;
|
||||
const scheme = schemeMatch[1].toLowerCase()
|
||||
validateScheme(scheme)
|
||||
return trimmed
|
||||
}
|
||||
throw new HttpsError('invalid-argument', `URL de retour Stripe invalide: ${trimmed}`)
|
||||
}
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
`URL de retour Stripe invalide: ${trimmed}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getReturnUrls = (overrides) => {
|
||||
if (overrides && typeof overrides === "object") {
|
||||
const successOverride = sanitizeReturnUrl(overrides.successUrl);
|
||||
const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl);
|
||||
if (overrides && typeof overrides === 'object') {
|
||||
const successOverride = sanitizeReturnUrl(overrides.successUrl)
|
||||
const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl)
|
||||
|
||||
if (!successOverride) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
"successUrl est requis pour configurer les retours Stripe.",
|
||||
);
|
||||
'invalid-argument',
|
||||
'successUrl est requis pour configurer les retours Stripe.'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
successUrl: successOverride,
|
||||
cancelUrl: cancelOverride || successOverride,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = getReturnBaseUrl();
|
||||
const baseUrl = getReturnBaseUrl()
|
||||
|
||||
const joinPath = (url, path) => {
|
||||
const trimmedUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
const trimmedPath = path.startsWith("/") ? path.slice(1) : path;
|
||||
return `${trimmedUrl}/${trimmedPath}`;
|
||||
};
|
||||
const trimmedUrl = url.endsWith('/') ? url.slice(0, -1) : url
|
||||
const trimmedPath = path.startsWith('/') ? path.slice(1) : path
|
||||
return `${trimmedUrl}/${trimmedPath}`
|
||||
}
|
||||
|
||||
const appendQuery = (url, query) =>
|
||||
url.includes("?") ? `${url}&${query}` : `${url}?${query}`;
|
||||
const appendQuery = (url, query) => (url.includes('?') ? `${url}&${query}` : `${url}?${query}`)
|
||||
|
||||
const successBase = joinPath(baseUrl, "payment-success");
|
||||
const cancelBase = joinPath(baseUrl, "payment-error");
|
||||
const successBase = joinPath(baseUrl, 'payment-success')
|
||||
const cancelBase = joinPath(baseUrl, 'payment-error')
|
||||
|
||||
return {
|
||||
successUrl: appendQuery(successBase, "session_id={CHECKOUT_SESSION_ID}"),
|
||||
cancelUrl: appendQuery(cancelBase, "session_id={CHECKOUT_SESSION_ID}"),
|
||||
};
|
||||
};
|
||||
successUrl: appendQuery(successBase, 'session_id={CHECKOUT_SESSION_ID}'),
|
||||
cancelUrl: appendQuery(cancelBase, 'session_id={CHECKOUT_SESSION_ID}'),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeBoolean = (value) => value === true;
|
||||
const normalizeBoolean = (value) => value === true
|
||||
|
||||
const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
|
||||
if (!Array.isArray(productList) || productList.length === 0) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
"Au moins un produit est requis pour créer une session de paiement.",
|
||||
);
|
||||
'invalid-argument',
|
||||
'Au moins un produit est requis pour créer une session de paiement.'
|
||||
)
|
||||
}
|
||||
|
||||
const lineItems = [];
|
||||
const summary = [];
|
||||
let hasSubscription = false;
|
||||
const lineItems = []
|
||||
const summary = []
|
||||
let hasSubscription = false
|
||||
|
||||
for (let index = 0; index < productList.length; index += 1) {
|
||||
const rawItem = productList[index];
|
||||
const item = rawItem && typeof rawItem === "object" ? rawItem : {};
|
||||
const rawItem = productList[index]
|
||||
const item = rawItem && typeof rawItem === 'object' ? rawItem : {}
|
||||
|
||||
const isRenewable = normalizeBoolean(item.isRenewable);
|
||||
const isRenewable = normalizeBoolean(item.isRenewable)
|
||||
const rawQuantity =
|
||||
typeof item.quantity === "number" && Number.isFinite(item.quantity)
|
||||
typeof item.quantity === 'number' && Number.isFinite(item.quantity)
|
||||
? item.quantity
|
||||
: parseInt(item.quantity, 10);
|
||||
const quantity =
|
||||
Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1;
|
||||
: parseInt(item.quantity, 10)
|
||||
const quantity = Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1
|
||||
|
||||
const priceId = typeof item.priceID === "string" ? item.priceID.trim() : "";
|
||||
const priceId = typeof item.priceID === 'string' ? item.priceID.trim() : ''
|
||||
|
||||
if (isRenewable && !priceId) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
`Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`,
|
||||
);
|
||||
'invalid-argument',
|
||||
`Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`
|
||||
)
|
||||
}
|
||||
|
||||
if (priceId) {
|
||||
let stripePrice = null;
|
||||
let stripePrice = null
|
||||
if (stripe) {
|
||||
try {
|
||||
stripePrice = await stripe.prices.retrieve(priceId);
|
||||
stripePrice = await stripe.prices.retrieve(priceId)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[buildCheckoutLineItems] Unable to retrieve price",
|
||||
priceId,
|
||||
error,
|
||||
);
|
||||
console.error('[buildCheckoutLineItems] Unable to retrieve price', priceId, error)
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
`Le price ID "${priceId}" est introuvable (élément ${index + 1}).`,
|
||||
);
|
||||
'invalid-argument',
|
||||
`Le price ID "${priceId}" est introuvable (élément ${index + 1}).`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const priceIsRecurring =
|
||||
stripePrice?.type === "recurring" || !!stripePrice?.recurring;
|
||||
const priceIsRecurring = stripePrice?.type === 'recurring' || !!stripePrice?.recurring
|
||||
if (isRenewable && !priceIsRecurring) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
`Le price ID "${priceId}" n'est pas compatible avec un abonnement.`,
|
||||
);
|
||||
'invalid-argument',
|
||||
`Le price ID "${priceId}" n'est pas compatible avec un abonnement.`
|
||||
)
|
||||
}
|
||||
|
||||
const resolvedIsRenewable = priceIsRecurring ? true : isRenewable;
|
||||
const resolvedIsRenewable = priceIsRecurring ? true : isRenewable
|
||||
|
||||
if (resolvedIsRenewable) {
|
||||
hasSubscription = true;
|
||||
hasSubscription = true
|
||||
}
|
||||
|
||||
lineItems.push({
|
||||
price: priceId,
|
||||
quantity,
|
||||
});
|
||||
})
|
||||
summary.push({
|
||||
type: "price",
|
||||
type: 'price',
|
||||
priceID: priceId,
|
||||
quantity,
|
||||
isRenewable: resolvedIsRenewable,
|
||||
});
|
||||
continue;
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const rawUnitAmount = Number(item.unitAmount);
|
||||
const unitAmount = Math.round(rawUnitAmount);
|
||||
const rawUnitAmount = Number(item.unitAmount)
|
||||
const unitAmount = Math.round(rawUnitAmount)
|
||||
if (!Number.isFinite(unitAmount) || unitAmount <= 0) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
`Le montant indiqué pour l'élément ${index + 1} est invalide.`,
|
||||
);
|
||||
'invalid-argument',
|
||||
`Le montant indiqué pour l'élément ${index + 1} est invalide.`
|
||||
)
|
||||
}
|
||||
|
||||
const currency =
|
||||
typeof item.currency === "string"
|
||||
? item.currency.trim().toLowerCase()
|
||||
: "eur";
|
||||
const currency = typeof item.currency === 'string' ? item.currency.trim().toLowerCase() : 'eur'
|
||||
|
||||
if (!/^[a-z]{3}$/.test(currency)) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
`La devise indiquée pour l'élément ${index + 1} est invalide.`,
|
||||
);
|
||||
'invalid-argument',
|
||||
`La devise indiquée pour l'élément ${index + 1} est invalide.`
|
||||
)
|
||||
}
|
||||
|
||||
const label =
|
||||
typeof item.label === "string" && item.label.trim()
|
||||
? item.label.trim()
|
||||
: "Paiement ponctuel";
|
||||
typeof item.label === 'string' && item.label.trim() ? item.label.trim() : 'Paiement ponctuel'
|
||||
|
||||
lineItems.push({
|
||||
price_data: {
|
||||
@@ -286,95 +268,84 @@ const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
|
||||
unit_amount: unitAmount,
|
||||
},
|
||||
quantity,
|
||||
});
|
||||
})
|
||||
|
||||
summary.push({
|
||||
type: "custom",
|
||||
type: 'custom',
|
||||
currency,
|
||||
unitAmount,
|
||||
quantity,
|
||||
isRenewable: false,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
if (hasSubscription) {
|
||||
const hasNonSubscription = summary.some((item) => !item.isRenewable);
|
||||
const hasNonSubscription = summary.some((item) => !item.isRenewable)
|
||||
if (hasNonSubscription) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
"Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.",
|
||||
);
|
||||
'invalid-argument',
|
||||
'Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { lineItems, summary, hasSubscription };
|
||||
};
|
||||
return { lineItems, summary, hasSubscription }
|
||||
}
|
||||
|
||||
const ensureStripeCustomer = async ({
|
||||
uid,
|
||||
stripe,
|
||||
refsList,
|
||||
createIfMissing = true,
|
||||
}) => {
|
||||
const ensureStripeCustomer = async ({ uid, stripe, refsList, createIfMissing = true }) => {
|
||||
if (!uid) {
|
||||
return { customerId: null, userData: null };
|
||||
return { customerId: null, userData: null }
|
||||
}
|
||||
|
||||
const userRef = refsList?.users?.doc(uid);
|
||||
const snapshot = userRef ? await userRef.get() : null;
|
||||
const userData = snapshot?.exists ? snapshot.data() : null;
|
||||
const userRef = refsList?.users?.doc(uid)
|
||||
const snapshot = userRef ? await userRef.get() : null
|
||||
const userData = snapshot?.exists ? snapshot.data() : null
|
||||
|
||||
let customerId = userData?.stripeCustomerId;
|
||||
let customerId = userData?.stripeCustomerId
|
||||
if (customerId) {
|
||||
return { customerId, userData };
|
||||
return { customerId, userData }
|
||||
}
|
||||
|
||||
if (!createIfMissing) {
|
||||
return { customerId: null, userData };
|
||||
return { customerId: null, userData }
|
||||
}
|
||||
|
||||
let authRecord = null;
|
||||
let authRecord = null
|
||||
try {
|
||||
authRecord = await admin.auth().getUser(uid);
|
||||
authRecord = await admin.auth().getUser(uid)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[ensureStripeCustomer] Impossible de récupérer auth user",
|
||||
error,
|
||||
);
|
||||
console.warn('[ensureStripeCustomer] Impossible de récupérer auth user', error)
|
||||
}
|
||||
|
||||
const email = userData?.email || authRecord?.email || undefined;
|
||||
const nameFromProfile = [userData?.firstName, userData?.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
const name = nameFromProfile || authRecord?.displayName || undefined;
|
||||
const email = userData?.email || authRecord?.email || undefined
|
||||
const nameFromProfile = [userData?.firstName, userData?.lastName].filter(Boolean).join(' ').trim()
|
||||
const name = nameFromProfile || authRecord?.displayName || undefined
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
name,
|
||||
metadata: {
|
||||
firebaseUID: uid,
|
||||
appMode: STRIPE_MODE || "test",
|
||||
appMode: STRIPE_MODE || 'test',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
customerId = customer.id;
|
||||
customerId = customer.id
|
||||
|
||||
if (userRef) {
|
||||
await userRef.set(
|
||||
{
|
||||
stripeCustomerId: customerId,
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
{ merge: true }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
customerId,
|
||||
userData: { ...userData, stripeCustomerId: customerId },
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const formatCheckoutSessionResponse = (session) => ({
|
||||
id: session.id,
|
||||
@@ -391,84 +362,72 @@ const formatCheckoutSessionResponse = (session) => ({
|
||||
created: session.created,
|
||||
expires_at: session.expires_at,
|
||||
client_secret: session.client_secret || null,
|
||||
});
|
||||
})
|
||||
|
||||
const getPortalConfigurationId = async (stripe) => {
|
||||
if (STRIPE_PORTAL_CONFIGURATION) {
|
||||
return STRIPE_PORTAL_CONFIGURATION;
|
||||
return STRIPE_PORTAL_CONFIGURATION
|
||||
}
|
||||
|
||||
if (cachedPortalConfigurationId) {
|
||||
return cachedPortalConfigurationId;
|
||||
return cachedPortalConfigurationId
|
||||
}
|
||||
|
||||
if (
|
||||
!stripe ||
|
||||
typeof stripe.billingPortal?.configurations?.list !== "function"
|
||||
) {
|
||||
throw new HttpsError(
|
||||
"internal",
|
||||
"Client Stripe indisponible pour la configuration du portail.",
|
||||
);
|
||||
if (!stripe || typeof stripe.billingPortal?.configurations?.list !== 'function') {
|
||||
throw new HttpsError('internal', 'Client Stripe indisponible pour la configuration du portail.')
|
||||
}
|
||||
|
||||
try {
|
||||
const configurations = await stripe.billingPortal.configurations.list({
|
||||
limit: 100,
|
||||
});
|
||||
})
|
||||
|
||||
const defaultConfiguration =
|
||||
configurations.data.find((config) => config.is_default) ||
|
||||
configurations.data.find((config) => config.active);
|
||||
configurations.data.find((config) => config.active)
|
||||
|
||||
if (defaultConfiguration?.id) {
|
||||
cachedPortalConfigurationId = defaultConfiguration.id;
|
||||
return cachedPortalConfigurationId;
|
||||
cachedPortalConfigurationId = defaultConfiguration.id
|
||||
return cachedPortalConfigurationId
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[getPortalConfigurationId] Impossible de lister les configurations de portail",
|
||||
error?.message || error,
|
||||
);
|
||||
'[getPortalConfigurationId] Impossible de lister les configurations de portail',
|
||||
error?.message || error
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const defaultReturnUrl = getReturnBaseUrl();
|
||||
const createdConfiguration =
|
||||
await stripe.billingPortal.configurations.create({
|
||||
const defaultReturnUrl = getReturnBaseUrl()
|
||||
const createdConfiguration = await stripe.billingPortal.configurations.create({
|
||||
default_return_url: defaultReturnUrl,
|
||||
business_profile: {
|
||||
headline: "Minuit Starter",
|
||||
headline: 'Minuit Starter',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
if (createdConfiguration?.id) {
|
||||
cachedPortalConfigurationId = createdConfiguration.id;
|
||||
return cachedPortalConfigurationId;
|
||||
cachedPortalConfigurationId = createdConfiguration.id
|
||||
return cachedPortalConfigurationId
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut",
|
||||
error?.message || error,
|
||||
);
|
||||
'[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut',
|
||||
error?.message || error
|
||||
)
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
const mapStripeErrorToHttps = (error, fallbackMessage) => {
|
||||
const message =
|
||||
error?.raw?.message ||
|
||||
error?.message ||
|
||||
fallbackMessage ||
|
||||
"Erreur Stripe.";
|
||||
const statusCode = error?.statusCode || error?.raw?.statusCode;
|
||||
const isClientError =
|
||||
typeof statusCode === "number" && statusCode >= 400 && statusCode < 500;
|
||||
const message = error?.raw?.message || error?.message || fallbackMessage || 'Erreur Stripe.'
|
||||
const statusCode = error?.statusCode || error?.raw?.statusCode
|
||||
const isClientError = typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500
|
||||
|
||||
const code = isClientError ? "failed-precondition" : "internal";
|
||||
return new HttpsError(code, message);
|
||||
};
|
||||
const code = isClientError ? 'failed-precondition' : 'internal'
|
||||
return new HttpsError(code, message)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStripeClient,
|
||||
@@ -479,4 +438,4 @@ module.exports = {
|
||||
formatCheckoutSessionResponse,
|
||||
getPortalConfigurationId,
|
||||
mapStripeErrorToHttps,
|
||||
};
|
||||
}
|
||||
|
||||
+41
-43
@@ -1,54 +1,52 @@
|
||||
const admin = require("firebase-admin");
|
||||
const admin = require('firebase-admin')
|
||||
|
||||
// Use default credentials/environment provided by Cloud Functions.
|
||||
// Avoid bundling a service account key and hardcoding project/bucket.
|
||||
admin.initializeApp();
|
||||
admin.initializeApp()
|
||||
|
||||
const db = admin.firestore();
|
||||
const REGION = process.env.FIREBASE_REGION || "europe-west1";
|
||||
const db = admin.firestore()
|
||||
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
|
||||
|
||||
exports.db = db;
|
||||
exports.REGION = REGION;
|
||||
exports.db = db
|
||||
exports.REGION = REGION
|
||||
|
||||
exports.refList = {
|
||||
projects: db.collection("projects"),
|
||||
playlists: db.collection("playlists"),
|
||||
tasks: db.collection("tasks"),
|
||||
notifications: db.collection("notifications"),
|
||||
users: db.collection("users"),
|
||||
projectStreamStats: db.collection("projectStreamStats"),
|
||||
projectStreamStatsMonthlyTotals: db.collection(
|
||||
"projectStreamStatsMonthlyTotals",
|
||||
),
|
||||
monthlyPayoutEntries: db.collection("monthlyPayoutEntries"),
|
||||
monthlyPayouts: db.collection("monthlyPayouts"),
|
||||
};
|
||||
exports.refsList = exports.refList;
|
||||
projects: db.collection('projects'),
|
||||
playlists: db.collection('playlists'),
|
||||
tasks: db.collection('tasks'),
|
||||
notifications: db.collection('notifications'),
|
||||
users: db.collection('users'),
|
||||
projectStreamStats: db.collection('projectStreamStats'),
|
||||
projectStreamStatsMonthlyTotals: db.collection('projectStreamStatsMonthlyTotals'),
|
||||
monthlyPayoutEntries: db.collection('monthlyPayoutEntries'),
|
||||
monthlyPayouts: db.collection('monthlyPayouts'),
|
||||
}
|
||||
exports.refsList = exports.refList
|
||||
|
||||
exports.ALERT_TYPE = {
|
||||
NEW_LIKE: "NEW_LIKE",
|
||||
NEW_COMMENT: "NEW_COMMENT",
|
||||
NEW_FOLLOWER: "NEW_FOLLOWER",
|
||||
MUSIC_GENERATION_SUCCESS: "MUSIC_GENERATION_SUCCESS",
|
||||
MUSIC_GENERATION_FAILED: "MUSIC_GENERATION_FAILED",
|
||||
COVER_GENERATION_SUCCESS: "COVER_GENERATION_SUCCESS",
|
||||
COVER_GENERATION_FAILED: "COVER_GENERATION_FAILED",
|
||||
CREDITS_UPDATED: "CREDITS_UPDATED",
|
||||
PAYOUT_AVAILABLE: "PAYOUT_AVAILABLE",
|
||||
};
|
||||
NEW_LIKE: 'NEW_LIKE',
|
||||
NEW_COMMENT: 'NEW_COMMENT',
|
||||
NEW_FOLLOWER: 'NEW_FOLLOWER',
|
||||
MUSIC_GENERATION_SUCCESS: 'MUSIC_GENERATION_SUCCESS',
|
||||
MUSIC_GENERATION_FAILED: 'MUSIC_GENERATION_FAILED',
|
||||
COVER_GENERATION_SUCCESS: 'COVER_GENERATION_SUCCESS',
|
||||
COVER_GENERATION_FAILED: 'COVER_GENERATION_FAILED',
|
||||
CREDITS_UPDATED: 'CREDITS_UPDATED',
|
||||
PAYOUT_AVAILABLE: 'PAYOUT_AVAILABLE',
|
||||
}
|
||||
|
||||
// Exporter toutes les fonctions
|
||||
exports.users = require("./src/users");
|
||||
exports.music = require("./src/music");
|
||||
exports.lyrics = require("./src/lyrics");
|
||||
exports.cover = require("./src/cover");
|
||||
exports.projects = require("./src/project");
|
||||
exports.thumbnail = require("./src/thumbnail");
|
||||
exports.upload = require("./src/upload");
|
||||
exports.algolia = require("./src/algolia");
|
||||
exports.notifications = require("./src/notifications");
|
||||
exports.rankings = require("./src/rankings");
|
||||
exports.payouts = require("./src/payouts");
|
||||
exports.subscription = require("./src/subscription");
|
||||
exports.youtube = require("./src/youtube");
|
||||
exports.orders = require("./src/orders");
|
||||
exports.users = require('./src/users')
|
||||
exports.music = require('./src/music')
|
||||
exports.lyrics = require('./src/lyrics')
|
||||
exports.cover = require('./src/cover')
|
||||
exports.projects = require('./src/project')
|
||||
exports.thumbnail = require('./src/thumbnail')
|
||||
exports.upload = require('./src/upload')
|
||||
exports.algolia = require('./src/algolia')
|
||||
exports.notifications = require('./src/notifications')
|
||||
exports.rankings = require('./src/rankings')
|
||||
exports.payouts = require('./src/payouts')
|
||||
exports.subscription = require('./src/subscription')
|
||||
exports.youtube = require('./src/youtube')
|
||||
exports.orders = require('./src/orders')
|
||||
|
||||
+15
-18
@@ -1,26 +1,23 @@
|
||||
const { onRequest } = require("firebase-functions/v2/https");
|
||||
const { onRequest } = require('firebase-functions/v2/https')
|
||||
|
||||
exports.algoliaTransformProjectData = onRequest(
|
||||
{ region: "europe-west1" },
|
||||
(req, res) => {
|
||||
const payload = req.body.data;
|
||||
const objectID = payload.objectID;
|
||||
exports.algoliaTransformProjectData = onRequest({ region: 'europe-west1' }, (req, res) => {
|
||||
const payload = req.body.data
|
||||
const objectID = payload.objectID
|
||||
try {
|
||||
const flat = { ...payload };
|
||||
delete flat["musicTimestamps"];
|
||||
const flat = { ...payload }
|
||||
delete flat['musicTimestamps']
|
||||
|
||||
console.log(`Change in ${payload.objectID}`);
|
||||
console.log(flat);
|
||||
console.log(`Change in ${payload.objectID}`)
|
||||
console.log(flat)
|
||||
// Ton object final doit contenir "objectID"
|
||||
const result = {
|
||||
objectID,
|
||||
...flat,
|
||||
};
|
||||
|
||||
res.send({ result });
|
||||
} catch (e) {
|
||||
console.log(`Error ${objectID}:`, e.message);
|
||||
res.status(500).end();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
res.send({ result })
|
||||
} catch (e) {
|
||||
console.log(`Error ${objectID}:`, e.message)
|
||||
res.status(500).end()
|
||||
}
|
||||
})
|
||||
|
||||
+142
-159
@@ -1,98 +1,97 @@
|
||||
const { onDocumentCreated } = require("firebase-functions/v2/firestore");
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const axios = require("axios");
|
||||
const sharp = require("sharp");
|
||||
const crypto = require("crypto");
|
||||
const { onDocumentCreated } = require('firebase-functions/v2/firestore')
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const logger = require('firebase-functions/logger')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const axios = require('axios')
|
||||
const sharp = require('sharp')
|
||||
const crypto = require('crypto')
|
||||
|
||||
// Imports internes
|
||||
const { generateImageV2 } = require("../helpers/gemini");
|
||||
const { generatePicturePrompt } = require("../helpers/prompts");
|
||||
const { ALERT_TYPE, refList } = require("../index");
|
||||
const { sendNotification } = require("./notifications");
|
||||
const { generateImageV2 } = require('../helpers/gemini')
|
||||
const { generatePicturePrompt } = require('../helpers/prompts')
|
||||
const { ALERT_TYPE, refList } = require('../index')
|
||||
const { sendNotification } = require('./notifications')
|
||||
|
||||
// Configuration
|
||||
const bucket = admin.storage().bucket();
|
||||
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png");
|
||||
const bucket = admin.storage().bucket()
|
||||
const LOGO_PATH = path.resolve(__dirname, '../assets/musicLandLogo.png')
|
||||
|
||||
// Cache mémoire pour le buffer du logo (évite les I/O disque à chaque appel sur instance chaude)
|
||||
let _cachedLogoBuffer = null;
|
||||
let _cachedLogoBuffer = null
|
||||
|
||||
/**
|
||||
* Récupère le buffer du logo depuis le cache ou le disque
|
||||
*/
|
||||
const getLogoBuffer = async () => {
|
||||
if (_cachedLogoBuffer) return _cachedLogoBuffer;
|
||||
if (_cachedLogoBuffer) return _cachedLogoBuffer
|
||||
try {
|
||||
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH);
|
||||
return _cachedLogoBuffer;
|
||||
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH)
|
||||
return _cachedLogoBuffer
|
||||
} catch (error) {
|
||||
logger.error("❌ [Cover] Impossible de lire le fichier logo", error);
|
||||
throw new Error("Asset Logo manquant sur le serveur");
|
||||
logger.error('❌ [Cover] Impossible de lire le fichier logo', error)
|
||||
throw new Error('Asset Logo manquant sur le serveur')
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Utilitaires Strings
|
||||
*/
|
||||
const pickFirstNonEmpty = (...values) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value.trim()
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
return ''
|
||||
}
|
||||
|
||||
const combineNames = (...parts) =>
|
||||
parts
|
||||
.map((part) => (typeof part === "string" ? part.trim() : ""))
|
||||
.map((part) => (typeof part === 'string' ? part.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
.join(' ')
|
||||
.trim()
|
||||
|
||||
/**
|
||||
* Résolution intelligente du nom d'artiste
|
||||
*/
|
||||
async function resolveArtistName(project = {}) {
|
||||
// 1. Vérification directe sur le projet ou le snapshot "owner"
|
||||
const owner = project?.owner || {};
|
||||
const owner = project?.owner || {}
|
||||
const direct = pickFirstNonEmpty(
|
||||
project?.artistName,
|
||||
project?.userName,
|
||||
owner?.artistName,
|
||||
owner?.userName,
|
||||
owner?.displayName,
|
||||
);
|
||||
if (direct) return direct;
|
||||
owner?.displayName
|
||||
)
|
||||
if (direct) return direct
|
||||
|
||||
// 2. Fallback : Récupération depuis la collection Users
|
||||
const userId =
|
||||
typeof project?.userId === "string" ? project.userId.trim() : "";
|
||||
if (!userId) return "";
|
||||
const userId = typeof project?.userId === 'string' ? project.userId.trim() : ''
|
||||
if (!userId) return ''
|
||||
|
||||
try {
|
||||
const userSnapshot = await refList.users.doc(userId).get();
|
||||
if (!userSnapshot?.exists) return "";
|
||||
const userSnapshot = await refList.users.doc(userId).get()
|
||||
if (!userSnapshot?.exists) return ''
|
||||
|
||||
const userData = userSnapshot.data() || {};
|
||||
const userData = userSnapshot.data() || {}
|
||||
return (
|
||||
pickFirstNonEmpty(
|
||||
userData.artistName,
|
||||
userData.userName,
|
||||
userData.displayName,
|
||||
combineNames(userData.firstName, userData.lastName),
|
||||
) || ""
|
||||
);
|
||||
combineNames(userData.firstName, userData.lastName)
|
||||
) || ''
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn("⚠️ [Cover] Artist name resolution failed", {
|
||||
logger.warn('⚠️ [Cover] Artist name resolution failed', {
|
||||
projectId: project?.id,
|
||||
error: error.message,
|
||||
});
|
||||
return "";
|
||||
})
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,60 +99,57 @@ async function resolveArtistName(project = {}) {
|
||||
* Ajoute le logo en filigrane sur l'image générée
|
||||
*/
|
||||
async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
||||
logger.info("🖼️ [Cover] Compositing logo...");
|
||||
logger.info('🖼️ [Cover] Compositing logo...')
|
||||
|
||||
try {
|
||||
// Téléchargement background + Lecture Logo (parallèle)
|
||||
const [bgResponse, logoBuffer] = await Promise.all([
|
||||
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
|
||||
axios.get(backgroundUrl, { responseType: 'arraybuffer' }),
|
||||
getLogoBuffer(),
|
||||
]);
|
||||
])
|
||||
|
||||
const baseImage = sharp(bgResponse.data);
|
||||
const metadata = await baseImage.metadata();
|
||||
const width = metadata.width || 1024;
|
||||
const height = metadata.height || 1024;
|
||||
const baseImage = sharp(bgResponse.data)
|
||||
const metadata = await baseImage.metadata()
|
||||
const width = metadata.width || 1024
|
||||
const height = metadata.height || 1024
|
||||
|
||||
// Calcul dynamique de la taille du logo (32% de la largeur)
|
||||
const desiredWidth = Math.round(width * 0.32);
|
||||
const margin = Math.round(width * 0.04);
|
||||
const desiredWidth = Math.round(width * 0.32)
|
||||
const margin = Math.round(width * 0.04)
|
||||
|
||||
// Redimensionnement du logo
|
||||
const resizedLogo = await sharp(logoBuffer)
|
||||
.resize({ width: desiredWidth })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const resizedLogo = await sharp(logoBuffer).resize({ width: desiredWidth }).png().toBuffer()
|
||||
|
||||
// Positionnement (Bas Droite)
|
||||
const logoMetadata = await sharp(resizedLogo).metadata();
|
||||
const left = Math.max(width - logoMetadata.width - margin, 0);
|
||||
const top = Math.max(height - logoMetadata.height - margin, 0);
|
||||
const logoMetadata = await sharp(resizedLogo).metadata()
|
||||
const left = Math.max(width - logoMetadata.width - margin, 0)
|
||||
const top = Math.max(height - logoMetadata.height - margin, 0)
|
||||
|
||||
// Composition
|
||||
const stampedBuffer = await baseImage
|
||||
.ensureAlpha()
|
||||
.composite([{ input: resizedLogo, left, top, blend: "over" }])
|
||||
.composite([{ input: resizedLogo, left, top, blend: 'over' }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
.toBuffer()
|
||||
|
||||
// Upload vers Storage
|
||||
const token = crypto.randomUUID();
|
||||
const file = bucket.file(targetPath);
|
||||
const token = crypto.randomUUID()
|
||||
const file = bucket.file(targetPath)
|
||||
|
||||
await file.save(stampedBuffer, {
|
||||
resumable: false,
|
||||
metadata: {
|
||||
contentType: "image/png",
|
||||
cacheControl: "public, max-age=31536000",
|
||||
contentType: 'image/png',
|
||||
cacheControl: 'public, max-age=31536000',
|
||||
metadata: { firebaseStorageDownloadTokens: token },
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`;
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
|
||||
} catch (error) {
|
||||
logger.error("❌ [Cover] buildCoverWithLogo failed", error);
|
||||
logger.error('❌ [Cover] buildCoverWithLogo failed', error)
|
||||
// En cas d'échec du logo, on renvoie l'URL originale pour ne pas tout perdre
|
||||
return backgroundUrl;
|
||||
return backgroundUrl
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,74 +157,69 @@ async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
||||
* Cœur de la logique de génération
|
||||
*/
|
||||
async function performCoverGeneration(project) {
|
||||
const t0 = Date.now();
|
||||
const artistName = await resolveArtistName(project);
|
||||
const t0 = Date.now()
|
||||
const artistName = await resolveArtistName(project)
|
||||
|
||||
// Génération du Prompt optimisé
|
||||
const prompt = generatePicturePrompt({
|
||||
...project,
|
||||
artistName,
|
||||
});
|
||||
})
|
||||
|
||||
logger.info("🎨 [Cover] Prompt generated", {
|
||||
logger.info('🎨 [Cover] Prompt generated', {
|
||||
projectId: project.id,
|
||||
artistName,
|
||||
promptPreview: prompt.slice(0, 100) + "...",
|
||||
});
|
||||
promptPreview: prompt.slice(0, 100) + '...',
|
||||
})
|
||||
|
||||
const baseTimestamp = Date.now();
|
||||
const GENERATION_COUNT = 2; // Nombre de variantes simultanées
|
||||
const baseTimestamp = Date.now()
|
||||
const GENERATION_COUNT = 2 // Nombre de variantes simultanées
|
||||
|
||||
// Création d'un tableau de promesses pour exécuter les tâches en parallèle
|
||||
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(
|
||||
async (_, index) => {
|
||||
const uniqueSuffix = `${baseTimestamp}-${index}`;
|
||||
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
|
||||
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
|
||||
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`;
|
||||
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(async (_, index) => {
|
||||
const uniqueSuffix = `${baseTimestamp}-${index}`
|
||||
const storageBasePath = `users/${project.userId}/projects/${project.id}`
|
||||
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`
|
||||
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`
|
||||
|
||||
try {
|
||||
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
|
||||
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
|
||||
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath)
|
||||
|
||||
if (!generatedUrl) throw new Error("URL vide retournée par l'IA");
|
||||
if (!generatedUrl) throw new Error("URL vide retournée par l'IA")
|
||||
|
||||
// 2. Ajout du Logo
|
||||
const finalCoverUrl = await buildCoverWithLogo(
|
||||
generatedUrl,
|
||||
stampedPath,
|
||||
);
|
||||
const finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath)
|
||||
|
||||
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`);
|
||||
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`)
|
||||
|
||||
return {
|
||||
id: uniqueSuffix,
|
||||
generatedUrl,
|
||||
finalUrl: finalCoverUrl,
|
||||
promptUsed: prompt,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all
|
||||
logger.error(`❌ [Cover] Option ${index + 1} failed`, {
|
||||
error: e.message,
|
||||
});
|
||||
return null; // On retourne null pour filtrer plus tard
|
||||
})
|
||||
return null // On retourne null pour filtrer plus tard
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
// Attente de la résolution de toutes les générations
|
||||
const results = await Promise.all(generationPromises);
|
||||
const results = await Promise.all(generationPromises)
|
||||
|
||||
// On garde uniquement les tentatives réussies (non null)
|
||||
const options = results.filter(Boolean);
|
||||
const options = results.filter(Boolean)
|
||||
|
||||
if (options.length === 0) {
|
||||
throw new Error("Toutes les tentatives de génération ont échoué.");
|
||||
throw new Error('Toutes les tentatives de génération ont échoué.')
|
||||
}
|
||||
|
||||
// Sauvegarde dans Firestore
|
||||
const [firstOption] = options;
|
||||
const [firstOption] = options
|
||||
|
||||
await refList.projects.doc(project.id).set(
|
||||
{
|
||||
@@ -238,19 +229,19 @@ async function performCoverGeneration(project) {
|
||||
// selectedOptionId: firstOption.id, // disable default selection
|
||||
options, // Sauvegarde de toutes les variantes réussies
|
||||
},
|
||||
coverStatus: "GENERATED",
|
||||
coverStatus: 'GENERATED',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
logger.info("🏁 [Cover] Process complete", {
|
||||
logger.info('🏁 [Cover] Process complete', {
|
||||
projectId: project.id,
|
||||
successCount: options.length,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
})
|
||||
|
||||
return firstOption.finalUrl;
|
||||
return firstOption.finalUrl
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,77 +251,72 @@ async function performCoverGeneration(project) {
|
||||
exports.onTaskCreateGenerateCover = onDocumentCreated(
|
||||
{
|
||||
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
|
||||
memory: "1GiB",
|
||||
document: "tasks/{taskId}",
|
||||
memory: '1GiB',
|
||||
document: 'tasks/{taskId}',
|
||||
},
|
||||
async (event) => {
|
||||
const data = event.data?.data() || {};
|
||||
const { type, projectId } = data;
|
||||
const taskId = event.params.taskId;
|
||||
const data = event.data?.data() || {}
|
||||
const { type, projectId } = data
|
||||
const taskId = event.params.taskId
|
||||
|
||||
if (!projectId) return; // Ignorer les tâches mal formées
|
||||
if (!["cover", "combine"].includes(type)) return; // Ignorer les autres types de tâches
|
||||
if (!projectId) return // Ignorer les tâches mal formées
|
||||
if (!['cover', 'combine'].includes(type)) return // Ignorer les autres types de tâches
|
||||
|
||||
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId });
|
||||
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId })
|
||||
|
||||
try {
|
||||
// 1. Validation & Setup
|
||||
if (type === "combine") {
|
||||
if (type === 'combine') {
|
||||
// Feature désactivée pour le moment
|
||||
await event.data.ref.update({
|
||||
status: "CANCELLED",
|
||||
status: 'CANCELLED',
|
||||
error: "La personnalisation photo n'est plus disponible.",
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mise à jour statut projet
|
||||
await refList.projects.doc(projectId).update({
|
||||
coverStatus: "GENERATING",
|
||||
coverStatus: 'GENERATING',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
})
|
||||
|
||||
// 2. Chargement Projet
|
||||
const projectSnap = await refList.projects.doc(projectId).get();
|
||||
if (!projectSnap.exists) throw new Error("Projet introuvable");
|
||||
const projectSnap = await refList.projects.doc(projectId).get()
|
||||
if (!projectSnap.exists) throw new Error('Projet introuvable')
|
||||
|
||||
const project = { id: projectId, ...projectSnap.data() };
|
||||
const project = { id: projectId, ...projectSnap.data() }
|
||||
|
||||
// Idempotency check (si déjà généré, on ne refait pas)
|
||||
if (
|
||||
Array.isArray(project?.cover?.options) &&
|
||||
project.cover.options.length > 0
|
||||
) {
|
||||
logger.warn("⚠️ [Task] Cover already exists. Skipping.");
|
||||
await refList.projects
|
||||
.doc(projectId)
|
||||
.update({ coverStatus: "GENERATED" });
|
||||
if (Array.isArray(project?.cover?.options) && project.cover.options.length > 0) {
|
||||
logger.warn('⚠️ [Task] Cover already exists. Skipping.')
|
||||
await refList.projects.doc(projectId).update({ coverStatus: 'GENERATED' })
|
||||
await event.data.ref.update({
|
||||
status: "DONE",
|
||||
info: "Already generated",
|
||||
});
|
||||
return;
|
||||
status: 'DONE',
|
||||
info: 'Already generated',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Exécution Génération
|
||||
const coverUrl = await performCoverGeneration(project);
|
||||
const coverUrl = await performCoverGeneration(project)
|
||||
|
||||
// 4. Finalisation Tâche
|
||||
await event.data.ref.update({
|
||||
status: "DONE",
|
||||
status: 'DONE',
|
||||
coverUrl,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
})
|
||||
|
||||
// 5. Notification
|
||||
if (project.userId) {
|
||||
const projectTitle = project.title || "ton projet";
|
||||
const projectTitle = project.title || 'ton projet'
|
||||
await sendNotification({
|
||||
sender: "SYSTEM",
|
||||
sender: 'SYSTEM',
|
||||
receiver: project.userId,
|
||||
receiverCollection: "users",
|
||||
title: "Pochette prête !",
|
||||
receiverCollection: 'users',
|
||||
title: 'Pochette prête !',
|
||||
message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
|
||||
data: {
|
||||
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
|
||||
@@ -338,39 +324,36 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
|
||||
projectTitle,
|
||||
coverUrl,
|
||||
},
|
||||
}).catch((err) => logger.warn("Notification failed", err));
|
||||
}).catch((err) => logger.warn('Notification failed', err))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`🔥 [Task ${taskId}] Failed`, error);
|
||||
logger.error(`🔥 [Task ${taskId}] Failed`, error)
|
||||
|
||||
// Mise à jour erreur Tâche
|
||||
await event.data.ref.set(
|
||||
{ status: "ERROR", error: error.message },
|
||||
{ merge: true },
|
||||
);
|
||||
await event.data.ref.set({ status: 'ERROR', error: error.message }, { merge: true })
|
||||
|
||||
// Mise à jour erreur Projet
|
||||
await refList.projects.doc(projectId).update({
|
||||
coverStatus: "ERROR",
|
||||
coverStatus: 'ERROR',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
})
|
||||
|
||||
// Notification Erreur
|
||||
const projectData = (await refList.projects.doc(projectId).get()).data();
|
||||
const projectData = (await refList.projects.doc(projectId).get()).data()
|
||||
if (projectData?.userId) {
|
||||
await sendNotification({
|
||||
sender: "SYSTEM",
|
||||
sender: 'SYSTEM',
|
||||
receiver: projectData.userId,
|
||||
receiverCollection: "users",
|
||||
title: "Échec pochette",
|
||||
message: `Impossible de générer la pochette pour "${projectData.title || "ton projet"}".`,
|
||||
receiverCollection: 'users',
|
||||
title: 'Échec pochette',
|
||||
message: `Impossible de générer la pochette pour "${projectData.title || 'ton projet'}".`,
|
||||
data: {
|
||||
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
|
||||
projectId,
|
||||
error: error.message,
|
||||
},
|
||||
}).catch(() => { });
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
|
||||
const ORDER_TYPES = {
|
||||
GIFT: "GIFT",
|
||||
SONG: "SONG",
|
||||
COINS: "COINS",
|
||||
SUBSCRIPTION: "SUBSCRIPTION",
|
||||
};
|
||||
GIFT: 'GIFT',
|
||||
SONG: 'SONG',
|
||||
COINS: 'COINS',
|
||||
SUBSCRIPTION: 'SUBSCRIPTION',
|
||||
}
|
||||
|
||||
const ORDER_STATUS = {
|
||||
PENDING: "PENDING",
|
||||
APPLIED: "APPLIED",
|
||||
REJECTED: "REJECTED",
|
||||
};
|
||||
PENDING: 'PENDING',
|
||||
APPLIED: 'APPLIED',
|
||||
REJECTED: 'REJECTED',
|
||||
}
|
||||
|
||||
const ORDERS_COLLECTION = "orders";
|
||||
const ORDERS_COLLECTION = 'orders'
|
||||
|
||||
const isFiniteNumber = (value) => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return true;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return true
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed);
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const normalizeAmount = (amount) => {
|
||||
if (!isFiniteNumber(amount)) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
return Number(amount)
|
||||
}
|
||||
return Number(amount);
|
||||
};
|
||||
|
||||
const createOrderDocument = async ({
|
||||
userId,
|
||||
type,
|
||||
amount,
|
||||
songId = null,
|
||||
createdBy = "system",
|
||||
createdBy = 'system',
|
||||
metadata = {},
|
||||
orderId = null,
|
||||
}) => {
|
||||
if (!userId || typeof userId !== "string") {
|
||||
throw new Error("[orders] Missing userId when creating order");
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
throw new Error('[orders] Missing userId when creating order')
|
||||
}
|
||||
|
||||
if (!Object.values(ORDER_TYPES).includes(type)) {
|
||||
throw new Error(`[orders] Invalid order type "${type}"`);
|
||||
throw new Error(`[orders] Invalid order type "${type}"`)
|
||||
}
|
||||
|
||||
const normalizedAmount = normalizeAmount(amount);
|
||||
const normalizedAmount = normalizeAmount(amount)
|
||||
|
||||
if (normalizedAmount === null || normalizedAmount === 0) {
|
||||
throw new Error("[orders] Invalid order amount");
|
||||
throw new Error('[orders] Invalid order amount')
|
||||
}
|
||||
|
||||
const payload = {
|
||||
@@ -63,25 +63,25 @@ const createOrderDocument = async ({
|
||||
amount: normalizedAmount,
|
||||
songId: type === ORDER_TYPES.SONG ? songId || null : null,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
createdBy: createdBy || "system",
|
||||
createdBy: createdBy || 'system',
|
||||
status: ORDER_STATUS.PENDING,
|
||||
metadata: metadata || {},
|
||||
};
|
||||
}
|
||||
|
||||
const collectionRef = admin.firestore().collection(ORDERS_COLLECTION);
|
||||
const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc();
|
||||
const collectionRef = admin.firestore().collection(ORDERS_COLLECTION)
|
||||
const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc()
|
||||
|
||||
if (orderId) {
|
||||
const existingSnapshot = await orderRef.get();
|
||||
const existingSnapshot = await orderRef.get()
|
||||
if (existingSnapshot.exists) {
|
||||
return { orderRef, orderId: orderRef.id };
|
||||
return { orderRef, orderId: orderRef.id }
|
||||
}
|
||||
}
|
||||
|
||||
await orderRef.set(payload);
|
||||
await orderRef.set(payload)
|
||||
|
||||
return { orderRef, orderId: orderRef.id };
|
||||
};
|
||||
return { orderRef, orderId: orderRef.id }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ORDER_TYPES,
|
||||
@@ -89,4 +89,4 @@ module.exports = {
|
||||
ORDERS_COLLECTION,
|
||||
createOrderDocument,
|
||||
normalizeAmount,
|
||||
};
|
||||
}
|
||||
|
||||
+191
-220
@@ -1,93 +1,86 @@
|
||||
const { onCall, HttpsError } = require("firebase-functions/v2/https");
|
||||
const { z } = require("genkit");
|
||||
const { generateAI, analyseLyrics } = require("../helpers/gemini");
|
||||
const {
|
||||
SUNO_API_BASE,
|
||||
SUNO_TIMESTAMPED_LYRICS_PATH,
|
||||
} = require("../config/suno");
|
||||
const { SUNO_API_KEY } = require("../config/keys");
|
||||
const axios = require("axios");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const { refList } = require("../index");
|
||||
const { onCall, HttpsError } = require('firebase-functions/v2/https')
|
||||
const { z } = require('genkit')
|
||||
const { generateAI, analyseLyrics } = require('../helpers/gemini')
|
||||
const { SUNO_API_BASE, SUNO_TIMESTAMPED_LYRICS_PATH } = require('../config/suno')
|
||||
const { SUNO_API_KEY } = require('../config/keys')
|
||||
const axios = require('axios')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const { refList } = require('../index')
|
||||
|
||||
// --- CONSTANTES DE STRUCTURE ---
|
||||
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
|
||||
const STRUCTURE_PROMPT_LABELS = {
|
||||
couplet: "couplet",
|
||||
refrain: "refrain",
|
||||
short_intro: "introduction instrumentale courte",
|
||||
long_intro: "introduction instrumentale longue",
|
||||
pre_refrain_instrumental: "pré-refrain instrumental",
|
||||
pont: "pont",
|
||||
solo_de_guitare: "solo de guitare",
|
||||
solo_de_guitare_electrique: "solo de guitare électrique",
|
||||
solo_de_batterie: "solo de batterie",
|
||||
solo_de_saxophone: "solo de saxophone",
|
||||
solo_de_violon: "solo de violon",
|
||||
break: "break",
|
||||
interlude: "interlude",
|
||||
interlude_melodique: "interlude mélodique",
|
||||
final_apogee: "final apogée",
|
||||
arret_net: "arrêt net",
|
||||
fade_out: "fade out",
|
||||
transition_douce: "transition douce vers le silence",
|
||||
};
|
||||
couplet: 'couplet',
|
||||
refrain: 'refrain',
|
||||
short_intro: 'introduction instrumentale courte',
|
||||
long_intro: 'introduction instrumentale longue',
|
||||
pre_refrain_instrumental: 'pré-refrain instrumental',
|
||||
pont: 'pont',
|
||||
solo_de_guitare: 'solo de guitare',
|
||||
solo_de_guitare_electrique: 'solo de guitare électrique',
|
||||
solo_de_batterie: 'solo de batterie',
|
||||
solo_de_saxophone: 'solo de saxophone',
|
||||
solo_de_violon: 'solo de violon',
|
||||
break: 'break',
|
||||
interlude: 'interlude',
|
||||
interlude_melodique: 'interlude mélodique',
|
||||
final_apogee: 'final apogée',
|
||||
arret_net: 'arrêt net',
|
||||
fade_out: 'fade out',
|
||||
transition_douce: 'transition douce vers le silence',
|
||||
}
|
||||
|
||||
const STRUCTURE_ALIASES = {
|
||||
"short intro": "short_intro",
|
||||
"introduction instrumentale courte": "short_intro",
|
||||
"intro instrumentale courte": "short_intro",
|
||||
"long intro": "long_intro",
|
||||
"introduction instrumentale longue": "long_intro",
|
||||
"intro instrumentale longue": "long_intro",
|
||||
"pré-refrain": "pre_refrain_instrumental",
|
||||
"pre-refrain": "pre_refrain_instrumental",
|
||||
pre_refrain: "pre_refrain_instrumental",
|
||||
"pre chorus": "pre_refrain_instrumental",
|
||||
"pre-chorus": "pre_refrain_instrumental",
|
||||
prechorus: "pre_refrain_instrumental",
|
||||
"pré-refrain instrumental": "pre_refrain_instrumental",
|
||||
"pre-refrain instrumental": "pre_refrain_instrumental",
|
||||
"instrumental pre-chorus": "pre_refrain_instrumental",
|
||||
"instrumental pre chorus": "pre_refrain_instrumental",
|
||||
bridge: "pont",
|
||||
guitar_solo: "solo_de_guitare",
|
||||
electric_guitar_solo: "solo_de_guitare_electrique",
|
||||
drum_solo: "solo_de_batterie",
|
||||
sax_solo: "solo_de_saxophone",
|
||||
violin_solo: "solo_de_violon",
|
||||
melodic_interlude: "interlude_melodique",
|
||||
grand_finale: "final_apogee",
|
||||
sudden_stop: "arret_net",
|
||||
soft_transition: "transition_douce",
|
||||
};
|
||||
'short intro': 'short_intro',
|
||||
'introduction instrumentale courte': 'short_intro',
|
||||
'intro instrumentale courte': 'short_intro',
|
||||
'long intro': 'long_intro',
|
||||
'introduction instrumentale longue': 'long_intro',
|
||||
'intro instrumentale longue': 'long_intro',
|
||||
'pré-refrain': 'pre_refrain_instrumental',
|
||||
'pre-refrain': 'pre_refrain_instrumental',
|
||||
pre_refrain: 'pre_refrain_instrumental',
|
||||
'pre chorus': 'pre_refrain_instrumental',
|
||||
'pre-chorus': 'pre_refrain_instrumental',
|
||||
prechorus: 'pre_refrain_instrumental',
|
||||
'pré-refrain instrumental': 'pre_refrain_instrumental',
|
||||
'pre-refrain instrumental': 'pre_refrain_instrumental',
|
||||
'instrumental pre-chorus': 'pre_refrain_instrumental',
|
||||
'instrumental pre chorus': 'pre_refrain_instrumental',
|
||||
bridge: 'pont',
|
||||
guitar_solo: 'solo_de_guitare',
|
||||
electric_guitar_solo: 'solo_de_guitare_electrique',
|
||||
drum_solo: 'solo_de_batterie',
|
||||
sax_solo: 'solo_de_saxophone',
|
||||
violin_solo: 'solo_de_violon',
|
||||
melodic_interlude: 'interlude_melodique',
|
||||
grand_finale: 'final_apogee',
|
||||
sudden_stop: 'arret_net',
|
||||
soft_transition: 'transition_douce',
|
||||
}
|
||||
|
||||
// --- UTILITAIRES ---
|
||||
|
||||
const normalizeStructureValue = (value) => {
|
||||
const raw = String(value || "")
|
||||
const raw = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!raw) return "";
|
||||
if (STRUCTURE_PROMPT_LABELS[raw]) return raw;
|
||||
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw];
|
||||
const sanitized = raw.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
||||
return STRUCTURE_PROMPT_LABELS[sanitized]
|
||||
? sanitized
|
||||
: STRUCTURE_ALIASES[sanitized] || sanitized;
|
||||
};
|
||||
.toLowerCase()
|
||||
if (!raw) return ''
|
||||
if (STRUCTURE_PROMPT_LABELS[raw]) return raw
|
||||
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw]
|
||||
const sanitized = raw.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||
return STRUCTURE_PROMPT_LABELS[sanitized] ? sanitized : STRUCTURE_ALIASES[sanitized] || sanitized
|
||||
}
|
||||
|
||||
// Nettoyage simplifié : on laisse l'IA gérer la logique musicale plutôt que de supprimer brutalement.
|
||||
// On s'assure juste que les clés sont propres.
|
||||
const sanitizeStructureEntries = (structure = []) => {
|
||||
if (!Array.isArray(structure)) return [];
|
||||
return structure.map(normalizeStructureValue).filter(Boolean);
|
||||
};
|
||||
if (!Array.isArray(structure)) return []
|
||||
return structure.map(normalizeStructureValue).filter(Boolean)
|
||||
}
|
||||
|
||||
const mapStructureToPrompt = (structure = []) =>
|
||||
sanitizeStructureEntries(structure).map(
|
||||
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
|
||||
);
|
||||
sanitizeStructureEntries(structure).map((entry) => STRUCTURE_PROMPT_LABELS[entry] || entry)
|
||||
|
||||
// --- MODERATION ---
|
||||
|
||||
@@ -101,40 +94,37 @@ const buildModerationBrief = ({
|
||||
rhymes,
|
||||
}) => {
|
||||
const sections = [
|
||||
`<OBJECTIF>${objective || ""}</OBJECTIF>`,
|
||||
`<CONTEXTE>${context || ""}</CONTEXTE>`,
|
||||
`<EMOTION>${emotion || ""}</EMOTION>`,
|
||||
`<STYLE>${style || ""}</STYLE>`,
|
||||
`<AUDIENCE>${audience || ""}</AUDIENCE>`,
|
||||
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`,
|
||||
];
|
||||
const promptStructure = mapStructureToPrompt(structure);
|
||||
if (promptStructure.length)
|
||||
sections.push(`<STRUCTURE>${promptStructure.join(" | ")}</STRUCTURE>`);
|
||||
return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`;
|
||||
};
|
||||
`<OBJECTIF>${objective || ''}</OBJECTIF>`,
|
||||
`<CONTEXTE>${context || ''}</CONTEXTE>`,
|
||||
`<EMOTION>${emotion || ''}</EMOTION>`,
|
||||
`<STYLE>${style || ''}</STYLE>`,
|
||||
`<AUDIENCE>${audience || ''}</AUDIENCE>`,
|
||||
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(', ') : rhymes || ''}</RIMES>`,
|
||||
]
|
||||
const promptStructure = mapStructureToPrompt(structure)
|
||||
if (promptStructure.length) sections.push(`<STRUCTURE>${promptStructure.join(' | ')}</STRUCTURE>`)
|
||||
return `<BRIEF_UTILISATEUR>\n${sections.join('\n')}\n</BRIEF_UTILISATEUR>`
|
||||
}
|
||||
|
||||
// --- GENERATION DE PAROLES (MAIN) ---
|
||||
|
||||
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
|
||||
try {
|
||||
const {
|
||||
objective = "",
|
||||
context = "",
|
||||
style = "",
|
||||
audience = "",
|
||||
emotion = "",
|
||||
structure: rawStructure = ["couplet", "refrain", "couplet", "refrain"],
|
||||
rhymes = "",
|
||||
} = data;
|
||||
objective = '',
|
||||
context = '',
|
||||
style = '',
|
||||
audience = '',
|
||||
emotion = '',
|
||||
structure: rawStructure = ['couplet', 'refrain', 'couplet', 'refrain'],
|
||||
rhymes = '',
|
||||
} = data
|
||||
|
||||
// 1. Préparation Structure
|
||||
const sanitizedStructure = sanitizeStructureEntries(rawStructure);
|
||||
const sanitizedStructure = sanitizeStructureEntries(rawStructure)
|
||||
const promptStructure = sanitizedStructure.length
|
||||
? sanitizedStructure.map(
|
||||
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
|
||||
)
|
||||
: [];
|
||||
? sanitizedStructure.map((entry) => STRUCTURE_PROMPT_LABELS[entry] || entry)
|
||||
: []
|
||||
|
||||
// 2. Modération "Pro" (Via Gemini 1.5 Pro)
|
||||
// On supprime les regex manuelles obsolètes.
|
||||
@@ -146,40 +136,34 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
|
||||
emotion,
|
||||
structure: promptStructure,
|
||||
rhymes,
|
||||
};
|
||||
const moderationInput = buildModerationBrief(moderationPayload);
|
||||
}
|
||||
const moderationInput = buildModerationBrief(moderationPayload)
|
||||
|
||||
try {
|
||||
const moderation = await analyseLyrics({
|
||||
title: "Brief utilisateur (Pré-génération)",
|
||||
title: 'Brief utilisateur (Pré-génération)',
|
||||
lyrics: moderationInput,
|
||||
});
|
||||
})
|
||||
|
||||
// Si Gemini dit "Blocked", on bloque. C'est la seule autorité.
|
||||
if (moderation?.blocked === true) {
|
||||
console.warn("⛔ generateLyrics blocked by Gemini Pro", {
|
||||
console.warn('⛔ generateLyrics blocked by Gemini Pro', {
|
||||
reasons: moderation.reasons,
|
||||
});
|
||||
})
|
||||
const summary = Array.isArray(moderation.reasons)
|
||||
? moderation.reasons.slice(0, 3).join(", ")
|
||||
: "Contenu non conforme";
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
`Demande refusée par la modération : ${summary}.`,
|
||||
);
|
||||
? moderation.reasons.slice(0, 3).join(', ')
|
||||
: 'Contenu non conforme'
|
||||
throw new HttpsError('invalid-argument', `Demande refusée par la modération : ${summary}.`)
|
||||
}
|
||||
} catch (moderationError) {
|
||||
if (moderationError instanceof HttpsError) throw moderationError;
|
||||
console.error("⚠️ Moderation check error (fail open)", moderationError);
|
||||
if (moderationError instanceof HttpsError) throw moderationError
|
||||
console.error('⚠️ Moderation check error (fail open)', moderationError)
|
||||
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique.
|
||||
// Ici je fail closed par sécurité pour une app publique.
|
||||
throw new HttpsError(
|
||||
"internal",
|
||||
"Vérification de sécurité indisponible.",
|
||||
);
|
||||
throw new HttpsError('internal', 'Vérification de sécurité indisponible.')
|
||||
}
|
||||
|
||||
console.log("🎵 generateLyrics: Start generation", { style, emotion });
|
||||
console.log('🎵 generateLyrics: Start generation', { style, emotion })
|
||||
|
||||
// 3. Le Prompt "Hit Maker"
|
||||
const system = `
|
||||
@@ -197,27 +181,25 @@ TES RÈGLES D'OR :
|
||||
5. **VOCABULAIRE** : Adapte le niveau de langue au style (Argot pour le Rap, Soutenu pour la Chanson Française, Simple pour la Pop).
|
||||
|
||||
Retourne UNIQUEMENT un JSON valide.
|
||||
`.trim();
|
||||
`.trim()
|
||||
|
||||
const structureTags = (
|
||||
Array.isArray(promptStructure) ? promptStructure : []
|
||||
)
|
||||
const structureTags = (Array.isArray(promptStructure) ? promptStructure : [])
|
||||
.map((part, index) => ` <SECTION ordre="${index + 1}">${part}</SECTION>`)
|
||||
.join("\n");
|
||||
.join('\n')
|
||||
|
||||
const fallbackStructureTags = [
|
||||
' <SECTION ordre="1">couplet</SECTION>',
|
||||
' <SECTION ordre="2">refrain</SECTION>',
|
||||
].join("\n");
|
||||
].join('\n')
|
||||
|
||||
const prompt = `
|
||||
<BRIEF_CREATIF>
|
||||
<OBJECTIF>${objective || "Créer une chanson mémorable"}</OBJECTIF>
|
||||
<CONTEXTE>${context || "Libre interprétation"}</CONTEXTE>
|
||||
<EMOTION_DOMINANTE>${emotion || "Intense"}</EMOTION_DOMINANTE>
|
||||
<STYLE_MUSICAL>${style || "Pop Moderne"}</STYLE_MUSICAL>
|
||||
<CIBLE>${audience || "Tout public"}</CIBLE>
|
||||
<TYPE_DE_RIMES>${rhymes || "Rimes croisées et riches"}</TYPE_DE_RIMES>
|
||||
<OBJECTIF>${objective || 'Créer une chanson mémorable'}</OBJECTIF>
|
||||
<CONTEXTE>${context || 'Libre interprétation'}</CONTEXTE>
|
||||
<EMOTION_DOMINANTE>${emotion || 'Intense'}</EMOTION_DOMINANTE>
|
||||
<STYLE_MUSICAL>${style || 'Pop Moderne'}</STYLE_MUSICAL>
|
||||
<CIBLE>${audience || 'Tout public'}</CIBLE>
|
||||
<TYPE_DE_RIMES>${rhymes || 'Rimes croisées et riches'}</TYPE_DE_RIMES>
|
||||
</BRIEF_CREATIF>
|
||||
|
||||
<STRUCTURE_IMPOSEE>
|
||||
@@ -231,106 +213,97 @@ ${structureTags || fallbackStructureTags}
|
||||
- Si c'est "Couplet/Refrain" : Écris 4 à 12 vers.
|
||||
3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre.
|
||||
</CONSIGNES_GENERATION>
|
||||
`.trim();
|
||||
`.trim()
|
||||
|
||||
const lyricsSchema = z.object({
|
||||
title: z.string().describe("Titre de la chanson, court et accrocheur"),
|
||||
title: z.string().describe('Titre de la chanson, court et accrocheur'),
|
||||
lyrics: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z
|
||||
.string()
|
||||
.describe(
|
||||
"Type de section (copier exactement la demande structure)",
|
||||
),
|
||||
type: z.string().describe('Type de section (copier exactement la demande structure)'),
|
||||
lyrics: z
|
||||
.string()
|
||||
.describe(
|
||||
"Les paroles. Pour les sections instrumentales, laisser vide ou décrire l'ambiance.",
|
||||
"Les paroles. Pour les sections instrumentales, laisser vide ou décrire l'ambiance."
|
||||
),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.describe("La structure complète de la chanson"),
|
||||
.describe('La structure complète de la chanson'),
|
||||
lyricsDescription: z
|
||||
.string()
|
||||
.describe(
|
||||
"Un pitch de 2 phrases décrivant l'ambiance et le thème de la chanson pour Suno.",
|
||||
"Un pitch de 2 phrases décrivant l'ambiance et le thème de la chanson pour Suno."
|
||||
),
|
||||
success: z.boolean(),
|
||||
});
|
||||
})
|
||||
|
||||
// Appel Gemini avec le nouveau modèle Pro configuré dans helpers/gemini
|
||||
return await generateAI({
|
||||
system,
|
||||
prompt,
|
||||
schema: lyricsSchema,
|
||||
});
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("❌ generateLyrics Error:", e);
|
||||
console.error('❌ generateLyrics Error:', e)
|
||||
// Remontée d'erreur propre
|
||||
if (e instanceof HttpsError) throw e;
|
||||
throw new HttpsError(
|
||||
"internal",
|
||||
"Erreur lors de la génération des paroles.",
|
||||
);
|
||||
if (e instanceof HttpsError) throw e
|
||||
throw new HttpsError('internal', 'Erreur lors de la génération des paroles.')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// --- ANALYSE TOXICITÉ (EXISTANTE, NETTOYÉE) ---
|
||||
// Cette fonction reste utile pour l'analyse POST-création ou pour l'interface UI
|
||||
|
||||
const severityScore = (severity = "") => {
|
||||
const normalized = String(severity || "").toLowerCase();
|
||||
if (normalized === "critical") return 4;
|
||||
if (normalized === "high") return 3;
|
||||
if (normalized === "medium") return 2;
|
||||
if (normalized === "low") return 1;
|
||||
return 0;
|
||||
};
|
||||
const severityScore = (severity = '') => {
|
||||
const normalized = String(severity || '').toLowerCase()
|
||||
if (normalized === 'critical') return 4
|
||||
if (normalized === 'high') return 3
|
||||
if (normalized === 'medium') return 2
|
||||
if (normalized === 'low') return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
const softenModerationDecision = (rawResult = {}) => {
|
||||
const result = { ...rawResult };
|
||||
const excerpts = Array.isArray(result.excerpts) ? result.excerpts : [];
|
||||
const reasons = Array.isArray(result.reasons) ? result.reasons : [];
|
||||
const result = { ...rawResult }
|
||||
const excerpts = Array.isArray(result.excerpts) ? result.excerpts : []
|
||||
const reasons = Array.isArray(result.reasons) ? result.reasons : []
|
||||
|
||||
const highestSeverity = excerpts.reduce(
|
||||
(max, excerpt) => Math.max(max, severityScore(excerpt?.severity)),
|
||||
0,
|
||||
);
|
||||
0
|
||||
)
|
||||
const score =
|
||||
typeof result.score === "number" && !Number.isNaN(result.score)
|
||||
typeof result.score === 'number' && !Number.isNaN(result.score)
|
||||
? Math.min(Math.max(result.score, 0), 1)
|
||||
: 0;
|
||||
: 0
|
||||
|
||||
// Détection de contexte narratif pour être plus indulgent
|
||||
const narrativeHint = reasons.some((reason) =>
|
||||
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(
|
||||
reason || "",
|
||||
),
|
||||
);
|
||||
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(reason || '')
|
||||
)
|
||||
|
||||
const adjustments = [];
|
||||
const adjustments = []
|
||||
|
||||
// Logique d'adoucissement : Si c'est "High" severity mais narratif, on peut parfois débloquer (selon ta politique).
|
||||
// Ici on reste prudent sur le block, mais on adoucit le flag.
|
||||
if (result.blocked) {
|
||||
// Débloque uniquement les erreurs manifestes (score bas mais blocked true par erreur)
|
||||
if (highestSeverity <= 1 && score < 0.65) {
|
||||
result.blocked = false;
|
||||
adjustments.push("auto-unblock-low-severity");
|
||||
result.blocked = false
|
||||
adjustments.push('auto-unblock-low-severity')
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.blocked && result.flagged) {
|
||||
const lowSignal = highestSeverity <= 1 && score < 0.4;
|
||||
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55;
|
||||
const lowSignal = highestSeverity <= 1 && score < 0.4
|
||||
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55
|
||||
|
||||
if (lowSignal) {
|
||||
result.flagged = false;
|
||||
adjustments.push("drop-flag-low-signal");
|
||||
result.flagged = false
|
||||
adjustments.push('drop-flag-low-signal')
|
||||
} else if (contextual) {
|
||||
result.flagged = false;
|
||||
adjustments.push("drop-flag-contextual");
|
||||
result.flagged = false
|
||||
adjustments.push('drop-flag-contextual')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,8 +311,8 @@ const softenModerationDecision = (rawResult = {}) => {
|
||||
...result,
|
||||
moderationAdjustments: adjustments,
|
||||
moderationCalibration: { highestSeverity, score },
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
|
||||
try {
|
||||
@@ -356,93 +329,91 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
|
||||
z.object({
|
||||
type: z.string().optional(),
|
||||
lyrics: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
])
|
||||
.describe("Paroles à analyser"),
|
||||
});
|
||||
.describe('Paroles à analyser'),
|
||||
})
|
||||
|
||||
const parsed = requestSchema.parse(data || {});
|
||||
const parsed = requestSchema.parse(data || {})
|
||||
const aiResult = await analyseLyrics({
|
||||
title: parsed.title || "",
|
||||
title: parsed.title || '',
|
||||
lyrics: parsed.lyrics,
|
||||
});
|
||||
const result = softenModerationDecision(aiResult);
|
||||
})
|
||||
const result = softenModerationDecision(aiResult)
|
||||
|
||||
const highCats = Object.entries(result.categories || {}) // Note: categories n'est pas dans le schema Zod actuel de Gemini, à vérifier si besoin
|
||||
.filter(([, v]) => (typeof v === "number" ? v : 0) >= 0.5)
|
||||
.filter(([, v]) => (typeof v === 'number' ? v : 0) >= 0.5)
|
||||
.map(([k]) => k)
|
||||
.slice(0, 5);
|
||||
.slice(0, 5)
|
||||
|
||||
let errorCode = "OK";
|
||||
let message = "Analyse effectuée: aucun blocage.";
|
||||
let errorCode = 'OK'
|
||||
let message = 'Analyse effectuée: aucun blocage.'
|
||||
|
||||
if (result.blocked) {
|
||||
errorCode = "TOXIC_CONTENT_BLOCKED";
|
||||
message = `Contenu bloqué par sécurité.`;
|
||||
errorCode = 'TOXIC_CONTENT_BLOCKED'
|
||||
message = `Contenu bloqué par sécurité.`
|
||||
} else if (result.flagged) {
|
||||
errorCode = "TOXIC_CONTENT_FLAGGED";
|
||||
message = `Attention: contenu sensible détecté.`;
|
||||
errorCode = 'TOXIC_CONTENT_FLAGGED'
|
||||
message = `Attention: contenu sensible détecté.`
|
||||
}
|
||||
|
||||
return { success: !result.blocked, errorCode, message, result };
|
||||
return { success: !result.blocked, errorCode, message, result }
|
||||
} catch (err) {
|
||||
console.error("analyseLyricsToxicity failed", err?.message || err);
|
||||
console.error('analyseLyricsToxicity failed', err?.message || err)
|
||||
return {
|
||||
success: false,
|
||||
errorCode: "ANALYSE_FAILED",
|
||||
message: "Erreur analyse toxicité.",
|
||||
};
|
||||
errorCode: 'ANALYSE_FAILED',
|
||||
message: 'Erreur analyse toxicité.',
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
// --- SUNO TIMESTAMPS (EXISTANT) ---
|
||||
|
||||
async function getSunoTimestamps(projectId) {
|
||||
try {
|
||||
if (!projectId || typeof projectId !== "string")
|
||||
throw new Error("projectId invalide");
|
||||
if (!projectId || typeof projectId !== 'string') throw new Error('projectId invalide')
|
||||
|
||||
const docSnap = await refList.projects.doc(projectId).get();
|
||||
if (!docSnap.exists) throw new Error("Projet introuvable");
|
||||
const docSnap = await refList.projects.doc(projectId).get()
|
||||
if (!docSnap.exists) throw new Error('Projet introuvable')
|
||||
|
||||
const { sunoTaskId, songIndex } = docSnap.data();
|
||||
if (!sunoTaskId) throw new Error("TaskId manquant");
|
||||
if (songIndex === undefined || songIndex < 0)
|
||||
throw new Error("musicIndex invalide");
|
||||
const { sunoTaskId, songIndex } = docSnap.data()
|
||||
if (!sunoTaskId) throw new Error('TaskId manquant')
|
||||
if (songIndex === undefined || songIndex < 0) throw new Error('musicIndex invalide')
|
||||
|
||||
console.log("🔎 getSunoTimestamps", { sunoTaskId, songIndex });
|
||||
console.log('🔎 getSunoTimestamps', { sunoTaskId, songIndex })
|
||||
|
||||
const response = await axios.post(
|
||||
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
|
||||
{ taskId: sunoTaskId, musicIndex: songIndex },
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${SUNO_API_KEY}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
const dataToReturn = response.data?.data || response.data || {};
|
||||
const dataToReturn = response.data?.data || response.data || {}
|
||||
|
||||
await refList.projects.doc(projectId).set(
|
||||
{
|
||||
musicTimestamps: { [songIndex]: dataToReturn },
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
return { success: true };
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("❌ getSunoTimestamps Error:", error.message);
|
||||
console.error('❌ getSunoTimestamps Error:', error.message)
|
||||
return {
|
||||
success: false,
|
||||
error: { message: error.message, type: "INTERNAL_ERROR" },
|
||||
};
|
||||
error: { message: error.message, type: 'INTERNAL_ERROR' },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.getSunoTimestamps = getSunoTimestamps;
|
||||
exports.getSunoTimestamps = getSunoTimestamps
|
||||
|
||||
+320
-367
@@ -1,28 +1,24 @@
|
||||
const {
|
||||
onCall,
|
||||
onRequest,
|
||||
HttpsError,
|
||||
} = require("firebase-functions/v2/https");
|
||||
const axios = require("axios");
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const { logger } = require("firebase-functions/logger");
|
||||
const { pipeline } = require("stream/promises");
|
||||
const { randomUUID } = require("crypto");
|
||||
const { ALERT_TYPE, refList } = require("../index");
|
||||
const { sendNotification } = require("./notifications");
|
||||
const { createOrderDocument, ORDER_TYPES } = require("./helpers/orders");
|
||||
const { SUNO_API_KEY } = require("../config/keys");
|
||||
const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https')
|
||||
const axios = require('axios')
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const { logger } = require('firebase-functions/logger')
|
||||
const { pipeline } = require('stream/promises')
|
||||
const { randomUUID } = require('crypto')
|
||||
const { ALERT_TYPE, refList } = require('../index')
|
||||
const { sendNotification } = require('./notifications')
|
||||
const { createOrderDocument, ORDER_TYPES } = require('./helpers/orders')
|
||||
const { SUNO_API_KEY } = require('../config/keys')
|
||||
const {
|
||||
SUNO_MODEL,
|
||||
SUNO_CALLBACK_URL,
|
||||
SUNO_API_BASE,
|
||||
SUNO_API_PATH,
|
||||
SUNO_STATUS_PATH,
|
||||
} = require("../config/suno");
|
||||
} = require('../config/suno')
|
||||
|
||||
const MUSIC_GENERATION_CREDIT_COST = 8;
|
||||
const MUSIC_REFUND_SOURCE = "music_generation_refund";
|
||||
const MUSIC_GENERATION_CREDIT_COST = 8
|
||||
const MUSIC_REFUND_SOURCE = 'music_generation_refund'
|
||||
|
||||
// ==========================================
|
||||
// 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO)
|
||||
@@ -30,98 +26,98 @@ const MUSIC_REFUND_SOURCE = "music_generation_refund";
|
||||
|
||||
const STYLE_MAP = {
|
||||
// Mapping du SONG_STYLE
|
||||
Upbeat: "Upbeat, Happy, Feel-good",
|
||||
"Grandios(e)": "Grand, Cinematic, Orchestral",
|
||||
Chill: "Chill, Relaxed, Downtempo",
|
||||
Epic: "Epic, Heroic, Trailer Music",
|
||||
Dramatic: "Dramatic, Intense, Theatrical",
|
||||
Comedic: "Comedy, Novelty, Funny",
|
||||
Theatrical: "Musical Theater, Broadway, Storytelling",
|
||||
Flamboyant: "Flamboyant, Glam, Exuberant",
|
||||
Mélancolique: "Melancholic, Sad, Emotional",
|
||||
Introspective: "Introspective, Deep, Thoughtful",
|
||||
"Urban Tragedy": "Urban, Dark, Grit, Cinematic",
|
||||
Eerie: "Eerie, Haunting, Spooky",
|
||||
Mysterious: "Mysterious, Enigmatic, Suspenseful",
|
||||
};
|
||||
Upbeat: 'Upbeat, Happy, Feel-good',
|
||||
'Grandios(e)': 'Grand, Cinematic, Orchestral',
|
||||
Chill: 'Chill, Relaxed, Downtempo',
|
||||
Epic: 'Epic, Heroic, Trailer Music',
|
||||
Dramatic: 'Dramatic, Intense, Theatrical',
|
||||
Comedic: 'Comedy, Novelty, Funny',
|
||||
Theatrical: 'Musical Theater, Broadway, Storytelling',
|
||||
Flamboyant: 'Flamboyant, Glam, Exuberant',
|
||||
Mélancolique: 'Melancholic, Sad, Emotional',
|
||||
Introspective: 'Introspective, Deep, Thoughtful',
|
||||
'Urban Tragedy': 'Urban, Dark, Grit, Cinematic',
|
||||
Eerie: 'Eerie, Haunting, Spooky',
|
||||
Mysterious: 'Mysterious, Enigmatic, Suspenseful',
|
||||
}
|
||||
|
||||
const GENRE_MAP = {
|
||||
// Mapping du CHOOSE_GENRE
|
||||
Pop: "Pop",
|
||||
"Hip Hop/Rap": "Hip Hop, Rap",
|
||||
Soul: "Soul, Neo-Soul",
|
||||
Blues: "Blues, Delta Blues",
|
||||
Folk: "Folk, Acoustic",
|
||||
Punk: "Punk Rock, High Energy",
|
||||
Dance: "Dance, Club",
|
||||
Grunge: "Grunge, Distorted",
|
||||
EDM: "EDM, Electronic",
|
||||
Trap: "Trap, 808s",
|
||||
Latino: "Latin, Reggaeton",
|
||||
Dancehall: "Dancehall, Island",
|
||||
"Latin Pop": "Latin Pop",
|
||||
Raggaeton: "Reggaeton, Urbano",
|
||||
Rock: "Rock",
|
||||
"Rock progressif": "Prog Rock, Complex",
|
||||
"Hard Rock": "Hard Rock",
|
||||
Metal: "Heavy Metal",
|
||||
"R&B": "R&B, Contemporary R&B",
|
||||
Phonk: "Phonk, Memphis Rap, Drift",
|
||||
House: "House, Deep House",
|
||||
Alternative: "Alternative Rock, Indie",
|
||||
Indie: "Indie Pop",
|
||||
Country: "Country, Americana",
|
||||
Synthwave: "Synthwave, Retrowave, 80s",
|
||||
Afrobeat: "Afrobeat, African Rhythms",
|
||||
"K-Pop": "K-Pop, Idol",
|
||||
Techno: "Techno, Minimal",
|
||||
Funk: "Funk, Groove",
|
||||
Disco: "Disco, Nu-Disco",
|
||||
"New wave": "New Wave, Post-Punk",
|
||||
Jazz: "Jazz, Smooth Jazz",
|
||||
"Lo-Fi": "Lo-Fi, Chillhop",
|
||||
"Bedroom Pop": "Bedroom Pop, Dreamy",
|
||||
Ambient: "Ambient, Atmospheric",
|
||||
"Dream Pop": "Dream Pop, Shoegaze",
|
||||
Grime: "Grime, UK Rap",
|
||||
Hyperpop: "Hyperpop, Glitch",
|
||||
Gospel: "Gospel, Spiritual",
|
||||
};
|
||||
Pop: 'Pop',
|
||||
'Hip Hop/Rap': 'Hip Hop, Rap',
|
||||
Soul: 'Soul, Neo-Soul',
|
||||
Blues: 'Blues, Delta Blues',
|
||||
Folk: 'Folk, Acoustic',
|
||||
Punk: 'Punk Rock, High Energy',
|
||||
Dance: 'Dance, Club',
|
||||
Grunge: 'Grunge, Distorted',
|
||||
EDM: 'EDM, Electronic',
|
||||
Trap: 'Trap, 808s',
|
||||
Latino: 'Latin, Reggaeton',
|
||||
Dancehall: 'Dancehall, Island',
|
||||
'Latin Pop': 'Latin Pop',
|
||||
Raggaeton: 'Reggaeton, Urbano',
|
||||
Rock: 'Rock',
|
||||
'Rock progressif': 'Prog Rock, Complex',
|
||||
'Hard Rock': 'Hard Rock',
|
||||
Metal: 'Heavy Metal',
|
||||
'R&B': 'R&B, Contemporary R&B',
|
||||
Phonk: 'Phonk, Memphis Rap, Drift',
|
||||
House: 'House, Deep House',
|
||||
Alternative: 'Alternative Rock, Indie',
|
||||
Indie: 'Indie Pop',
|
||||
Country: 'Country, Americana',
|
||||
Synthwave: 'Synthwave, Retrowave, 80s',
|
||||
Afrobeat: 'Afrobeat, African Rhythms',
|
||||
'K-Pop': 'K-Pop, Idol',
|
||||
Techno: 'Techno, Minimal',
|
||||
Funk: 'Funk, Groove',
|
||||
Disco: 'Disco, Nu-Disco',
|
||||
'New wave': 'New Wave, Post-Punk',
|
||||
Jazz: 'Jazz, Smooth Jazz',
|
||||
'Lo-Fi': 'Lo-Fi, Chillhop',
|
||||
'Bedroom Pop': 'Bedroom Pop, Dreamy',
|
||||
Ambient: 'Ambient, Atmospheric',
|
||||
'Dream Pop': 'Dream Pop, Shoegaze',
|
||||
Grime: 'Grime, UK Rap',
|
||||
Hyperpop: 'Hyperpop, Glitch',
|
||||
Gospel: 'Gospel, Spiritual',
|
||||
}
|
||||
|
||||
const INSTRUMENT_MAP = {
|
||||
"Piano classique": "Grand Piano",
|
||||
"Piano éléctrique": "Electric Piano, Rhodes",
|
||||
Synthétiseur: "Synthesizer",
|
||||
"Guitare acoustique": "Acoustic Guitar",
|
||||
"Guitare électrique": "Electric Guitar",
|
||||
Batterie: "Drums",
|
||||
Banjo: "Banjo",
|
||||
Violon: "Violin, Strings",
|
||||
Saxophone: "Saxophone",
|
||||
"Saxophone alto": "Alto Sax",
|
||||
Trompette: "Trumpet",
|
||||
Flûte: "Flute",
|
||||
Clarinette: "Clarinet",
|
||||
Djembe: "Percussion, Djembe",
|
||||
Bongos: "Bongos",
|
||||
Congas: "Congas",
|
||||
Harmonica: "Harmonica",
|
||||
Handpan: "Handpan",
|
||||
Harpe: "Harp",
|
||||
Xylophone: "Xylophone, Mallets",
|
||||
Mandoline: "Mandolin",
|
||||
Accordéon: "Accordion",
|
||||
Orgue: "Organ",
|
||||
Electronique: "Electronic Fx",
|
||||
};
|
||||
'Piano classique': 'Grand Piano',
|
||||
'Piano éléctrique': 'Electric Piano, Rhodes',
|
||||
Synthétiseur: 'Synthesizer',
|
||||
'Guitare acoustique': 'Acoustic Guitar',
|
||||
'Guitare électrique': 'Electric Guitar',
|
||||
Batterie: 'Drums',
|
||||
Banjo: 'Banjo',
|
||||
Violon: 'Violin, Strings',
|
||||
Saxophone: 'Saxophone',
|
||||
'Saxophone alto': 'Alto Sax',
|
||||
Trompette: 'Trumpet',
|
||||
Flûte: 'Flute',
|
||||
Clarinette: 'Clarinet',
|
||||
Djembe: 'Percussion, Djembe',
|
||||
Bongos: 'Bongos',
|
||||
Congas: 'Congas',
|
||||
Harmonica: 'Harmonica',
|
||||
Handpan: 'Handpan',
|
||||
Harpe: 'Harp',
|
||||
Xylophone: 'Xylophone, Mallets',
|
||||
Mandoline: 'Mandolin',
|
||||
Accordéon: 'Accordion',
|
||||
Orgue: 'Organ',
|
||||
Electronique: 'Electronic Fx',
|
||||
}
|
||||
|
||||
const RHYTHM_MAP = {
|
||||
"Très rapide": "Very Fast Tempo, High BPM",
|
||||
Rapide: "Fast Tempo",
|
||||
Normal: "Mid-tempo",
|
||||
Lent: "Slow Tempo, Downtempo",
|
||||
"Très lent": "Very Slow, Ballad",
|
||||
};
|
||||
'Très rapide': 'Very Fast Tempo, High BPM',
|
||||
Rapide: 'Fast Tempo',
|
||||
Normal: 'Mid-tempo',
|
||||
Lent: 'Slow Tempo, Downtempo',
|
||||
'Très lent': 'Very Slow, Ballad',
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. FONCTIONS DE PARSING (VOIX & STRUCTURE)
|
||||
@@ -131,75 +127,64 @@ const RHYTHM_MAP = {
|
||||
* Analyse les phrases longues du frontend pour extraire les tags vocaux Suno
|
||||
*/
|
||||
function parseVoiceTags(voiceSelections = []) {
|
||||
if (!Array.isArray(voiceSelections)) return [];
|
||||
if (!Array.isArray(voiceSelections)) return []
|
||||
|
||||
// Concatène tout pour recherche regex (Base + Sensibilité + Technique)
|
||||
const fullText = voiceSelections.join(" ").toLowerCase();
|
||||
const tags = [];
|
||||
const fullText = voiceSelections.join(' ').toLowerCase()
|
||||
const tags = []
|
||||
|
||||
// Genre
|
||||
if (fullText.includes("féminine") || fullText.includes("femme"))
|
||||
tags.push("Female Vocals");
|
||||
if (fullText.includes("masculine") || fullText.includes("homme"))
|
||||
tags.push("Male Vocals");
|
||||
if (fullText.includes("deux voix") || fullText.includes("duo"))
|
||||
tags.push("Duet");
|
||||
if (fullText.includes("chœur") || fullText.includes("gospel"))
|
||||
tags.push("Choir, Backing Vocals");
|
||||
if (fullText.includes("enfant")) tags.push("Youthful Vocals");
|
||||
if (fullText.includes('féminine') || fullText.includes('femme')) tags.push('Female Vocals')
|
||||
if (fullText.includes('masculine') || fullText.includes('homme')) tags.push('Male Vocals')
|
||||
if (fullText.includes('deux voix') || fullText.includes('duo')) tags.push('Duet')
|
||||
if (fullText.includes('chœur') || fullText.includes('gospel')) tags.push('Choir, Backing Vocals')
|
||||
if (fullText.includes('enfant')) tags.push('Youthful Vocals')
|
||||
|
||||
// Style / Technique
|
||||
if (fullText.includes("rap") || fullText.includes("slam"))
|
||||
tags.push("Rapping, Flow");
|
||||
if (fullText.includes("parlé") || fullText.includes("raconte"))
|
||||
tags.push("Spoken Word, Narration");
|
||||
if (fullText.includes("cri") || fullText.includes("scream"))
|
||||
tags.push("Screaming, Aggressive");
|
||||
if (fullText.includes("murmure") || fullText.includes("chuchote"))
|
||||
tags.push("Whispering, Intimate");
|
||||
if (fullText.includes("robot") || fullText.includes("synthétique"))
|
||||
tags.push("Autotune, Robotic");
|
||||
if (fullText.includes("puissant")) tags.push("Powerful, Belting");
|
||||
if (fullText.includes("aérienne") || fullText.includes("légère"))
|
||||
tags.push("Airy, Ethereal");
|
||||
if (fullText.includes("rauque") || fullText.includes("granuleuse"))
|
||||
tags.push("Raspy, Gritty");
|
||||
if (fullText.includes("opéra") || fullText.includes("soprano"))
|
||||
tags.push("Operatic");
|
||||
if (fullText.includes("sensuelle") || fullText.includes("séduisante"))
|
||||
tags.push("Seductive, Breathless");
|
||||
if (fullText.includes('rap') || fullText.includes('slam')) tags.push('Rapping, Flow')
|
||||
if (fullText.includes('parlé') || fullText.includes('raconte'))
|
||||
tags.push('Spoken Word, Narration')
|
||||
if (fullText.includes('cri') || fullText.includes('scream')) tags.push('Screaming, Aggressive')
|
||||
if (fullText.includes('murmure') || fullText.includes('chuchote'))
|
||||
tags.push('Whispering, Intimate')
|
||||
if (fullText.includes('robot') || fullText.includes('synthétique')) tags.push('Autotune, Robotic')
|
||||
if (fullText.includes('puissant')) tags.push('Powerful, Belting')
|
||||
if (fullText.includes('aérienne') || fullText.includes('légère')) tags.push('Airy, Ethereal')
|
||||
if (fullText.includes('rauque') || fullText.includes('granuleuse')) tags.push('Raspy, Gritty')
|
||||
if (fullText.includes('opéra') || fullText.includes('soprano')) tags.push('Operatic')
|
||||
if (fullText.includes('sensuelle') || fullText.includes('séduisante'))
|
||||
tags.push('Seductive, Breathless')
|
||||
|
||||
return [...new Set(tags)];
|
||||
return [...new Set(tags)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit les phrases de structure custom en Balises Suno
|
||||
*/
|
||||
function mapStructureToTag(description) {
|
||||
const d = description.toLowerCase();
|
||||
const d = description.toLowerCase()
|
||||
|
||||
if (d.includes("intro") && d.includes("court")) return "[Short Intro]";
|
||||
if (d.includes("intro")) return "[Intro]";
|
||||
if (d.includes("pré-refrain")) return "[Pre-Chorus]";
|
||||
if (d.includes("solo guitare électrique")) return "[Electric Guitar Solo]";
|
||||
if (d.includes("solo guitare")) return "[Guitar Solo]";
|
||||
if (d.includes("solo batterie")) return "[Drum Solo]";
|
||||
if (d.includes("solo saxo")) return "[Saxophone Solo]";
|
||||
if (d.includes("solo violon")) return "[Violin Solo]";
|
||||
if (d.includes("interlude")) return "[Instrumental Interlude]";
|
||||
if (d.includes("apogée") || d.includes("finition")) return "[Big Finish]";
|
||||
if (d.includes("arrêt net")) return "[Sudden End]";
|
||||
if (d.includes("baissant le volume") || d.includes("fade"))
|
||||
return "[Fade Out]";
|
||||
if (d.includes("silence")) return "[Fade to Silence]";
|
||||
if (d.includes("break") || d.includes("pause")) return "[Break]";
|
||||
if (d.includes('intro') && d.includes('court')) return '[Short Intro]'
|
||||
if (d.includes('intro')) return '[Intro]'
|
||||
if (d.includes('pré-refrain')) return '[Pre-Chorus]'
|
||||
if (d.includes('solo guitare électrique')) return '[Electric Guitar Solo]'
|
||||
if (d.includes('solo guitare')) return '[Guitar Solo]'
|
||||
if (d.includes('solo batterie')) return '[Drum Solo]'
|
||||
if (d.includes('solo saxo')) return '[Saxophone Solo]'
|
||||
if (d.includes('solo violon')) return '[Violin Solo]'
|
||||
if (d.includes('interlude')) return '[Instrumental Interlude]'
|
||||
if (d.includes('apogée') || d.includes('finition')) return '[Big Finish]'
|
||||
if (d.includes('arrêt net')) return '[Sudden End]'
|
||||
if (d.includes('baissant le volume') || d.includes('fade')) return '[Fade Out]'
|
||||
if (d.includes('silence')) return '[Fade to Silence]'
|
||||
if (d.includes('break') || d.includes('pause')) return '[Break]'
|
||||
|
||||
// Mapping standard des types lyrics
|
||||
if (d === "couplet" || d === "verse") return "[Verse]";
|
||||
if (d === "refrain" || d === "chorus") return "[Chorus]";
|
||||
if (d === "pont" || d === "bridge") return "[Bridge]";
|
||||
if (d === 'couplet' || d === 'verse') return '[Verse]'
|
||||
if (d === 'refrain' || d === 'chorus') return '[Chorus]'
|
||||
if (d === 'pont' || d === 'bridge') return '[Bridge]'
|
||||
|
||||
return null; // Pas de tag trouvé
|
||||
return null // Pas de tag trouvé
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@@ -210,162 +195,151 @@ function mapStructureToTag(description) {
|
||||
* Génère la chaîne de style optimisée (max ~200 chars)
|
||||
*/
|
||||
function buildSunoStyle({ genres, songStyle, voiceData, instruments, rhythm }) {
|
||||
const parts = [];
|
||||
const parts = []
|
||||
|
||||
// 1. Genres (Priorité 1)
|
||||
if (Array.isArray(genres)) {
|
||||
genres.forEach((g) => {
|
||||
if (GENRE_MAP[g]) parts.push(GENRE_MAP[g]);
|
||||
else parts.push(g);
|
||||
});
|
||||
if (GENRE_MAP[g]) parts.push(GENRE_MAP[g])
|
||||
else parts.push(g)
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Song Style / Vibe (Priorité 2)
|
||||
if (songStyle && STYLE_MAP[songStyle]) {
|
||||
parts.push(STYLE_MAP[songStyle]);
|
||||
} else if (songStyle && songStyle !== "Autre") {
|
||||
parts.push(songStyle);
|
||||
parts.push(STYLE_MAP[songStyle])
|
||||
} else if (songStyle && songStyle !== 'Autre') {
|
||||
parts.push(songStyle)
|
||||
}
|
||||
|
||||
// 3. Rhythm (Priorité 3)
|
||||
if (rhythm && RHYTHM_MAP[rhythm]) {
|
||||
parts.push(RHYTHM_MAP[rhythm]);
|
||||
parts.push(RHYTHM_MAP[rhythm])
|
||||
}
|
||||
|
||||
// 4. Instruments
|
||||
if (Array.isArray(instruments)) {
|
||||
instruments.slice(0, 3).forEach((i) => {
|
||||
// Max 3 instruments pour ne pas diluer
|
||||
if (INSTRUMENT_MAP[i]) parts.push(INSTRUMENT_MAP[i]);
|
||||
});
|
||||
if (INSTRUMENT_MAP[i]) parts.push(INSTRUMENT_MAP[i])
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Vocals
|
||||
const vocalTags = parseVoiceTags(voiceData);
|
||||
parts.push(...vocalTags);
|
||||
const vocalTags = parseVoiceTags(voiceData)
|
||||
parts.push(...vocalTags)
|
||||
|
||||
// Tags de qualité technique (toujours ajoutés)
|
||||
parts.push("High Fidelity", "Stereo");
|
||||
parts.push('High Fidelity', 'Stereo')
|
||||
|
||||
// Déduplication et join
|
||||
const uniqueStyle = [...new Set(parts)];
|
||||
return clampLen(uniqueStyle.join(", "), 250);
|
||||
const uniqueStyle = [...new Set(parts)]
|
||||
return clampLen(uniqueStyle.join(', '), 250)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le texte final des paroles avec les balises de structure
|
||||
*/
|
||||
function buildFormattedLyrics(lyricsArray) {
|
||||
if (!Array.isArray(lyricsArray) || lyricsArray.length === 0) return "";
|
||||
if (!Array.isArray(lyricsArray) || lyricsArray.length === 0) return ''
|
||||
|
||||
const formattedLines = lyricsArray.map((section) => {
|
||||
// Le frontend peut envoyer soit { type: "...", lyrics: "..." } soit juste une string description pour les instrumentaux
|
||||
const typeOrDescription = section.type || section.description || "";
|
||||
const textContent = section.lyrics || "";
|
||||
const typeOrDescription = section.type || section.description || ''
|
||||
const textContent = section.lyrics || ''
|
||||
|
||||
// Essayer de trouver un tag spécial (ex: "Introduction instrumentale")
|
||||
let tag = mapStructureToTag(typeOrDescription);
|
||||
let tag = mapStructureToTag(typeOrDescription)
|
||||
|
||||
// Fallback si pas de tag spécial trouvé mais type standard
|
||||
if (!tag) {
|
||||
if (typeOrDescription.toLowerCase().includes("couplet")) tag = "[Verse]";
|
||||
else if (typeOrDescription.toLowerCase().includes("refrain"))
|
||||
tag = "[Chorus]";
|
||||
else tag = `[${typeOrDescription}]`; // Fallback générique
|
||||
if (typeOrDescription.toLowerCase().includes('couplet')) tag = '[Verse]'
|
||||
else if (typeOrDescription.toLowerCase().includes('refrain')) tag = '[Chorus]'
|
||||
else tag = `[${typeOrDescription}]` // Fallback générique
|
||||
}
|
||||
|
||||
// Si c'est une section instrumentale (pas de lyrics)
|
||||
if (!textContent.trim()) {
|
||||
return `\n${tag}\n`;
|
||||
return `\n${tag}\n`
|
||||
}
|
||||
|
||||
return `\n${tag}\n${textContent.trim()}`;
|
||||
});
|
||||
return `\n${tag}\n${textContent.trim()}`
|
||||
})
|
||||
|
||||
// Sécurité: Ajouter Intro et Outro si absents (Suno best practice)
|
||||
const fullText = formattedLines.join("\n");
|
||||
let finalPrompt = fullText;
|
||||
const fullText = formattedLines.join('\n')
|
||||
let finalPrompt = fullText
|
||||
|
||||
if (!fullText.includes("[Intro]")) {
|
||||
finalPrompt = "[Intro]\n" + finalPrompt;
|
||||
if (!fullText.includes('[Intro]')) {
|
||||
finalPrompt = '[Intro]\n' + finalPrompt
|
||||
}
|
||||
if (
|
||||
!fullText.includes("[Outro]") &&
|
||||
!fullText.includes("[Fade Out]") &&
|
||||
!fullText.includes("[Sudden End]")
|
||||
!fullText.includes('[Outro]') &&
|
||||
!fullText.includes('[Fade Out]') &&
|
||||
!fullText.includes('[Sudden End]')
|
||||
) {
|
||||
finalPrompt = finalPrompt + "\n\n[Outro]";
|
||||
finalPrompt = finalPrompt + '\n\n[Outro]'
|
||||
}
|
||||
|
||||
return finalPrompt.trim();
|
||||
return finalPrompt.trim()
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. FONCTIONS UTILITAIRES DE BASE (GARDÉES)
|
||||
// ==========================================
|
||||
|
||||
function clampLen(str = "", max) {
|
||||
if (!max) return str || "";
|
||||
if (!str) return "";
|
||||
return str.length <= max ? str : str.slice(0, max);
|
||||
function clampLen(str = '', max) {
|
||||
if (!max) return str || ''
|
||||
if (!str) return ''
|
||||
return str.length <= max ? str : str.slice(0, max)
|
||||
}
|
||||
|
||||
const sanitizeField = (value, fallback = null) => {
|
||||
const cleaned = typeof value === "string" ? value.trim() : value;
|
||||
return typeof cleaned !== "string" || !cleaned ? fallback : cleaned;
|
||||
};
|
||||
const cleaned = typeof value === 'string' ? value.trim() : value
|
||||
return typeof cleaned !== 'string' || !cleaned ? fallback : cleaned
|
||||
}
|
||||
|
||||
const sanitizeMusicUrls = (urls = []) =>
|
||||
(Array.isArray(urls) ? urls : [])
|
||||
.filter((url) => typeof url === "string" && url.trim())
|
||||
.map((url) => url.trim());
|
||||
.filter((url) => typeof url === 'string' && url.trim())
|
||||
.map((url) => url.trim())
|
||||
|
||||
const formatProjectMeta = (projectData = {}) => {
|
||||
const userId = sanitizeField(projectData?.userId);
|
||||
const projectTitle = sanitizeField(projectData?.title, "ton projet");
|
||||
return { userId, projectTitle };
|
||||
};
|
||||
const userId = sanitizeField(projectData?.userId)
|
||||
const projectTitle = sanitizeField(projectData?.title, 'ton projet')
|
||||
return { userId, projectTitle }
|
||||
}
|
||||
|
||||
const parseSunoCallbackPayload = (rawBody = {}) => {
|
||||
const body = rawBody || {};
|
||||
const code = body.code ?? body.statusCode ?? null;
|
||||
const callbackType = (body?.data?.callbackType || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
const status = (body.status || body.state || callbackType || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
const taskId = sanitizeField(body?.data?.task_id);
|
||||
const body = rawBody || {}
|
||||
const code = body.code ?? body.statusCode ?? null
|
||||
const callbackType = (body?.data?.callbackType || '').toString().toLowerCase()
|
||||
const status = (body.status || body.state || callbackType || '').toString().toLowerCase()
|
||||
const taskId = sanitizeField(body?.data?.task_id)
|
||||
const tracks = Array.isArray(body?.data?.data)
|
||||
? body.data.data
|
||||
: Array.isArray(body.data)
|
||||
? body.data
|
||||
: [];
|
||||
return { code, status, taskId, tracks };
|
||||
};
|
||||
: []
|
||||
return { code, status, taskId, tracks }
|
||||
}
|
||||
|
||||
const extractAudioUrlsFromTracks = (tracks = []) =>
|
||||
tracks
|
||||
.map(
|
||||
(t) =>
|
||||
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl,
|
||||
)
|
||||
.map((t) => t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2);
|
||||
.slice(0, 2)
|
||||
|
||||
const fetchProjectByTaskId = async (taskId) => {
|
||||
const snapshot = await refList.projects
|
||||
.where("sunoTaskId", "==", taskId)
|
||||
.limit(1)
|
||||
.get();
|
||||
if (snapshot.empty) throw new Error("PROJECT_NOT_FOUND_FOR_TASK");
|
||||
const doc = snapshot.docs[0];
|
||||
const snapshot = await refList.projects.where('sunoTaskId', '==', taskId).limit(1).get()
|
||||
if (snapshot.empty) throw new Error('PROJECT_NOT_FOUND_FOR_TASK')
|
||||
const doc = snapshot.docs[0]
|
||||
return {
|
||||
projectId: doc.id,
|
||||
projectData: doc.data() || {},
|
||||
projectRef: doc.ref,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ... (Garde refundMusicCredits, markProjectMusicFailure, downloadTrackToStorage, saveTracksToStorage, mergeMusicUrls inchangés) ...
|
||||
// Je remets les versions raccourcies pour l'exemple mais utilise tes fonctions existantes pour le stockage/db
|
||||
@@ -373,130 +347,122 @@ const fetchProjectByTaskId = async (taskId) => {
|
||||
const refundMusicCredits = async ({
|
||||
projectId,
|
||||
userId,
|
||||
reason = "music_generation_failed",
|
||||
reason = 'music_generation_failed',
|
||||
context = {},
|
||||
}) => {
|
||||
if (!projectId || !userId || MUSIC_GENERATION_CREDIT_COST <= 0) return null;
|
||||
if (!projectId || !userId || MUSIC_GENERATION_CREDIT_COST <= 0) return null
|
||||
try {
|
||||
const metadata = {
|
||||
source: MUSIC_REFUND_SOURCE,
|
||||
reason,
|
||||
projectId,
|
||||
...context,
|
||||
};
|
||||
}
|
||||
const { orderId } = await createOrderDocument({
|
||||
userId,
|
||||
type: ORDER_TYPES.SONG,
|
||||
amount: MUSIC_GENERATION_CREDIT_COST,
|
||||
songId: projectId,
|
||||
createdBy: "system",
|
||||
createdBy: 'system',
|
||||
metadata,
|
||||
});
|
||||
logger.log("💸 [Music] Crédits remboursés", { projectId, userId, orderId });
|
||||
return { orderId };
|
||||
})
|
||||
logger.log('💸 [Music] Crédits remboursés', { projectId, userId, orderId })
|
||||
return { orderId }
|
||||
} catch (error) {
|
||||
logger.error("❌ [Music] Échec remboursement", {
|
||||
logger.error('❌ [Music] Échec remboursement', {
|
||||
projectId,
|
||||
userId,
|
||||
error: error?.message,
|
||||
});
|
||||
return null;
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function markProjectMusicFailure(projectId, error) {
|
||||
if (!projectId) return;
|
||||
if (!projectId) return
|
||||
try {
|
||||
const docRef = refList.projects.doc(projectId);
|
||||
const projectSnap = await docRef.get();
|
||||
const projectData = projectSnap?.data() || {};
|
||||
const status = error?.response?.status || error?.status || null;
|
||||
const docRef = refList.projects.doc(projectId)
|
||||
const projectSnap = await docRef.get()
|
||||
const projectData = projectSnap?.data() || {}
|
||||
const status = error?.response?.status || error?.status || null
|
||||
const sunoMessage =
|
||||
error?.response?.data?.msg ||
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
"Erreur";
|
||||
const errorPayload = { source: "SUNO_API", message: sunoMessage };
|
||||
error?.response?.data?.msg || error?.response?.data?.message || error?.message || 'Erreur'
|
||||
const errorPayload = { source: 'SUNO_API', message: sunoMessage }
|
||||
|
||||
const receiverId = sanitizeField(projectData?.userId);
|
||||
const alreadyRefunded = projectData?.musicCreditsRefunded === true;
|
||||
let refundResult = null;
|
||||
const receiverId = sanitizeField(projectData?.userId)
|
||||
const alreadyRefunded = projectData?.musicCreditsRefunded === true
|
||||
let refundResult = null
|
||||
if (receiverId && !alreadyRefunded) {
|
||||
refundResult = await refundMusicCredits({
|
||||
projectId,
|
||||
userId: receiverId,
|
||||
reason: sunoMessage,
|
||||
});
|
||||
})
|
||||
}
|
||||
await docRef.set(
|
||||
{
|
||||
musicStatus: "FAILED",
|
||||
musicStatus: 'FAILED',
|
||||
musicError: errorPayload,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
{ merge: true }
|
||||
)
|
||||
// (Notification logic here...)
|
||||
} catch (err) {
|
||||
console.error("Error marking failure", err);
|
||||
console.error('Error marking failure', err)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadTrackToStorage = async (
|
||||
url,
|
||||
{ userId, projectId, taskId, bucket, index },
|
||||
) => {
|
||||
if (!url) return null;
|
||||
const downloadTrackToStorage = async (url, { userId, projectId, taskId, bucket, index }) => {
|
||||
if (!url) return null
|
||||
try {
|
||||
const resp = await axios.get(url, { responseType: "stream" });
|
||||
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`;
|
||||
const token = randomUUID();
|
||||
const file = bucket.file(path);
|
||||
const resp = await axios.get(url, { responseType: 'stream' })
|
||||
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`
|
||||
const token = randomUUID()
|
||||
const file = bucket.file(path)
|
||||
const writeStream = file.createWriteStream({
|
||||
resumable: false,
|
||||
metadata: {
|
||||
contentType: "audio/mpeg",
|
||||
contentType: 'audio/mpeg',
|
||||
metadata: { firebaseStorageDownloadTokens: token },
|
||||
},
|
||||
});
|
||||
await pipeline(resp.data, writeStream);
|
||||
})
|
||||
await pipeline(resp.data, writeStream)
|
||||
return {
|
||||
path,
|
||||
url: `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const saveTracksToStorage = async (audioUrls, meta) => {
|
||||
if (!audioUrls?.length) return [];
|
||||
const bucket = admin.storage().bucket();
|
||||
if (!audioUrls?.length) return []
|
||||
const bucket = admin.storage().bucket()
|
||||
const results = await Promise.all(
|
||||
audioUrls.map((url, index) =>
|
||||
downloadTrackToStorage(url, { ...meta, bucket, index }),
|
||||
),
|
||||
);
|
||||
return results.filter(Boolean).map((entry) => entry.url);
|
||||
};
|
||||
audioUrls.map((url, index) => downloadTrackToStorage(url, { ...meta, bucket, index }))
|
||||
)
|
||||
return results.filter(Boolean).map((entry) => entry.url)
|
||||
}
|
||||
|
||||
const mergeMusicUrls = async (projectRef, newUrls) => {
|
||||
const sanitized = sanitizeMusicUrls(newUrls);
|
||||
let existing = [];
|
||||
const sanitized = sanitizeMusicUrls(newUrls)
|
||||
let existing = []
|
||||
try {
|
||||
existing = sanitizeMusicUrls((await projectRef.get())?.data()?.musicUrls);
|
||||
existing = sanitizeMusicUrls((await projectRef.get())?.data()?.musicUrls)
|
||||
} catch (e) {}
|
||||
const final = [...new Set([...existing, ...sanitized])];
|
||||
const final = [...new Set([...existing, ...sanitized])]
|
||||
await projectRef.set(
|
||||
{
|
||||
musicStatus: "GENERATED",
|
||||
musicStatus: 'GENERATED',
|
||||
musicUrls: final,
|
||||
musicError: FieldValue.delete(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
return final;
|
||||
};
|
||||
{ merge: true }
|
||||
)
|
||||
return final
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 5. FONCTIONS PRINCIPALES (CLOUD FUNCTIONS)
|
||||
@@ -506,24 +472,23 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
|
||||
try {
|
||||
// Extraction des données du Frontend
|
||||
const {
|
||||
title = "",
|
||||
title = '',
|
||||
lyrics = [], // Tableau d'objets {type, lyrics} ou strings
|
||||
genres = [], // ["Pop", "Rock"]
|
||||
songStyle = "", // "Upbeat"
|
||||
songStyle = '', // "Upbeat"
|
||||
voice = [], // ["Une voix féminine...", "Un cri brut..."] (array ou objet)
|
||||
instruments = [], // ["Piano", "Violon"]
|
||||
rhythm = "", // "Très rapide"
|
||||
rhythm = '', // "Très rapide"
|
||||
// Les champs suivants (step 1) ne sont plus utilisés dans le style Suno direct pour éviter le bruit,
|
||||
// mais ont déjà servi à générer les paroles (lyrics).
|
||||
// audience, context, objective, etc.
|
||||
} = data;
|
||||
} = data
|
||||
|
||||
// Normalisation input Voix (peut être array, string ou objet selon ta spec)
|
||||
let voiceData = [];
|
||||
if (Array.isArray(voice)) voiceData = voice;
|
||||
else if (typeof voice === "object" && voice !== null)
|
||||
voiceData = Object.values(voice).flat();
|
||||
else if (typeof voice === "string") voiceData = [voice];
|
||||
let voiceData = []
|
||||
if (Array.isArray(voice)) voiceData = voice
|
||||
else if (typeof voice === 'object' && voice !== null) voiceData = Object.values(voice).flat()
|
||||
else if (typeof voice === 'string') voiceData = [voice]
|
||||
|
||||
// 1. Construction du STYLE MUSICAL (Tags)
|
||||
const optimizedStyle = buildSunoStyle({
|
||||
@@ -532,50 +497,46 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
|
||||
voiceData,
|
||||
instruments,
|
||||
rhythm,
|
||||
});
|
||||
})
|
||||
|
||||
// 2. Construction du PROMPT (Paroles + Structure)
|
||||
const formattedPrompt = buildFormattedLyrics(lyrics);
|
||||
const safeTitle = clampLen(title, 80);
|
||||
const formattedPrompt = buildFormattedLyrics(lyrics)
|
||||
const safeTitle = clampLen(title, 80)
|
||||
|
||||
// 3. Détection du genre vocal pour le param vocalGender (optimisation v3.5)
|
||||
// On regarde si on trouve 'male' ou 'female' dans les tags générés
|
||||
let vGender = null;
|
||||
if (optimizedStyle.includes("Female")) vGender = "female";
|
||||
else if (optimizedStyle.includes("Male")) vGender = "male";
|
||||
let vGender = null
|
||||
if (optimizedStyle.includes('Female')) vGender = 'female'
|
||||
else if (optimizedStyle.includes('Male')) vGender = 'male'
|
||||
|
||||
console.log("🎵 [Suno Optimisation] Result:", {
|
||||
console.log('🎵 [Suno Optimisation] Result:', {
|
||||
style: optimizedStyle,
|
||||
gender: vGender,
|
||||
title: safeTitle,
|
||||
promptStructure: formattedPrompt.substring(0, 150) + "...", // Aperçu
|
||||
});
|
||||
promptStructure: formattedPrompt.substring(0, 150) + '...', // Aperçu
|
||||
})
|
||||
|
||||
// 4. Appel API
|
||||
const payload = {
|
||||
customMode: true,
|
||||
instrumental: false,
|
||||
model: SUNO_MODEL || "chirp-v3-5", // Toujours viser le dernier modèle
|
||||
model: SUNO_MODEL || 'chirp-v3-5', // Toujours viser le dernier modèle
|
||||
prompt: clampLen(formattedPrompt, 3000),
|
||||
title: safeTitle,
|
||||
style: optimizedStyle,
|
||||
callBackUrl: SUNO_CALLBACK_URL || "",
|
||||
};
|
||||
callBackUrl: SUNO_CALLBACK_URL || '',
|
||||
}
|
||||
|
||||
if (vGender) payload.vocalGender = vGender;
|
||||
if (vGender) payload.vocalGender = vGender
|
||||
|
||||
const response = await axios.post(
|
||||
`${SUNO_API_BASE}${SUNO_API_PATH}`,
|
||||
payload,
|
||||
{
|
||||
const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${SUNO_API_KEY}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
const parsed = response.data;
|
||||
const parsed = response.data
|
||||
|
||||
return {
|
||||
success: !!parsed?.data?.taskId,
|
||||
@@ -583,80 +544,73 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
|
||||
model: payload.model,
|
||||
title: safeTitle,
|
||||
style: optimizedStyle,
|
||||
promptPreview: formattedPrompt.substring(0, 50) + "...",
|
||||
promptPreview: formattedPrompt.substring(0, 50) + '...',
|
||||
},
|
||||
response: parsed || {},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur generateMusic:", error);
|
||||
console.error('❌ Erreur generateMusic:', error)
|
||||
try {
|
||||
await markProjectMusicFailure(data?.projectId, error);
|
||||
await markProjectMusicFailure(data?.projectId, error)
|
||||
} catch (e) {}
|
||||
|
||||
const status = error?.response?.status || "INTERNAL_ERROR";
|
||||
const message =
|
||||
error?.response?.data?.msg || error?.message || "Erreur Suno";
|
||||
throw new HttpsError("internal", message, { status, source: "SUNO_API" });
|
||||
const status = error?.response?.status || 'INTERNAL_ERROR'
|
||||
const message = error?.response?.data?.msg || error?.message || 'Erreur Suno'
|
||||
throw new HttpsError('internal', message, { status, source: 'SUNO_API' })
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// GET STATUS (inchangé mais inclus pour complétude)
|
||||
exports.getSunoStatus = onCall(async ({ data = {} }) => {
|
||||
const { taskId } = data;
|
||||
if (!taskId) throw new Error("TaskId manquant");
|
||||
const { taskId } = data
|
||||
if (!taskId) throw new Error('TaskId manquant')
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`,
|
||||
{
|
||||
const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, {
|
||||
headers: { Authorization: `Bearer ${SUNO_API_KEY}` },
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
taskId,
|
||||
data: response.data?.data || response.data,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (taskId.includes("test"))
|
||||
if (taskId.includes('test'))
|
||||
return {
|
||||
success: true,
|
||||
taskId,
|
||||
data: { status: "not_found", isTestId: true },
|
||||
};
|
||||
return { success: false, taskId, error: { message: error.message } };
|
||||
data: { status: 'not_found', isTestId: true },
|
||||
}
|
||||
});
|
||||
return { success: false, taskId, error: { message: error.message } }
|
||||
}
|
||||
})
|
||||
|
||||
// CALLBACK (Standard)
|
||||
exports.sunoCallback = onRequest(
|
||||
{ methods: ["POST"], memory: "1GiB" },
|
||||
async (req, res) => {
|
||||
if (req.method !== "POST")
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body);
|
||||
exports.sunoCallback = onRequest({ methods: ['POST'], memory: '1GiB' }, async (req, res) => {
|
||||
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })
|
||||
const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body)
|
||||
|
||||
if (code !== 200 || status !== "complete" || !taskId)
|
||||
return res.status(200).json({ success: true, ignored: true });
|
||||
if (code !== 200 || status !== 'complete' || !taskId)
|
||||
return res.status(200).json({ success: true, ignored: true })
|
||||
|
||||
try {
|
||||
const { projectId, projectData, projectRef } =
|
||||
await fetchProjectByTaskId(taskId);
|
||||
const { userId, projectTitle } = formatProjectMeta(projectData);
|
||||
const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId)
|
||||
const { userId, projectTitle } = formatProjectMeta(projectData)
|
||||
|
||||
const storedUrls = await saveTracksToStorage(
|
||||
extractAudioUrlsFromTracks(tracks),
|
||||
{ userId, projectId, taskId },
|
||||
);
|
||||
const musicUrls = await mergeMusicUrls(projectRef, storedUrls);
|
||||
const storedUrls = await saveTracksToStorage(extractAudioUrlsFromTracks(tracks), {
|
||||
userId,
|
||||
projectId,
|
||||
taskId,
|
||||
})
|
||||
const musicUrls = await mergeMusicUrls(projectRef, storedUrls)
|
||||
|
||||
if (userId) {
|
||||
try {
|
||||
await sendNotification({
|
||||
sender: "SYSTEM",
|
||||
sender: 'SYSTEM',
|
||||
receiver: userId,
|
||||
receiverCollection: "users",
|
||||
title: "Musique prête",
|
||||
receiverCollection: 'users',
|
||||
title: 'Musique prête',
|
||||
message: `Ta musique pour "${projectTitle}" est prête.`,
|
||||
data: {
|
||||
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
||||
@@ -665,15 +619,14 @@ exports.sunoCallback = onRequest(
|
||||
musicUrls,
|
||||
taskId,
|
||||
},
|
||||
});
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
return res.status(200).json({ success: true, projectId });
|
||||
return res.status(200).json({ success: true, projectId })
|
||||
} catch (error) {
|
||||
// Gestion erreur silencieuse pour le webhook
|
||||
return res.status(500).json({ error: error.message });
|
||||
return res.status(500).json({ error: error.message })
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
+242
-287
@@ -1,100 +1,88 @@
|
||||
const {
|
||||
onDocumentCreated,
|
||||
onDocumentWritten,
|
||||
} = require("firebase-functions/v2/firestore");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const { refList, ALERT_TYPE } = require("../index");
|
||||
const { Expo } = require("expo-server-sdk");
|
||||
const { Resend } = require("resend");
|
||||
const { basicTemplate } = require("../helpers/email");
|
||||
const { RESEND_API_KEY } = require("../config/keys");
|
||||
const { onDocumentCreated, onDocumentWritten } = require('firebase-functions/v2/firestore')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const { refList, ALERT_TYPE } = require('../index')
|
||||
const { Expo } = require('expo-server-sdk')
|
||||
const { Resend } = require('resend')
|
||||
const { basicTemplate } = require('../helpers/email')
|
||||
const { RESEND_API_KEY } = require('../config/keys')
|
||||
|
||||
const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null;
|
||||
const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null
|
||||
|
||||
// Initialisation de Expo SDK
|
||||
let expo = new Expo();
|
||||
const EMAIL_FROM = "MusicLand <musicland@musicland.ai>";
|
||||
const DEFAULT_EMAIL_TITLE = "MusicLand";
|
||||
let expo = new Expo()
|
||||
const EMAIL_FROM = 'MusicLand <musicland@musicland.ai>'
|
||||
const DEFAULT_EMAIL_TITLE = 'MusicLand'
|
||||
|
||||
function getCollectionRef(collectionName = "") {
|
||||
const ref = refList?.[collectionName];
|
||||
function getCollectionRef(collectionName = '') {
|
||||
const ref = refList?.[collectionName]
|
||||
if (!ref) {
|
||||
throw new Error(`Unknown collection "${collectionName}"`);
|
||||
throw new Error(`Unknown collection "${collectionName}"`)
|
||||
}
|
||||
return ref;
|
||||
return ref
|
||||
}
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
function buildNotificationEmailPayload({
|
||||
title = "",
|
||||
message = "",
|
||||
template = {},
|
||||
} = {}) {
|
||||
const fallbackTitle = cleanString(title) || DEFAULT_EMAIL_TITLE;
|
||||
const fallbackContent = cleanString(message) || "";
|
||||
const overrides = template && typeof template === "object" ? template : {};
|
||||
function buildNotificationEmailPayload({ title = '', message = '', template = {} } = {}) {
|
||||
const fallbackTitle = cleanString(title) || DEFAULT_EMAIL_TITLE
|
||||
const fallbackContent = cleanString(message) || ''
|
||||
const overrides = template && typeof template === 'object' ? template : {}
|
||||
|
||||
const subject = cleanString(overrides.subject) || fallbackTitle;
|
||||
const emailTitle = cleanString(overrides.title) || fallbackTitle;
|
||||
const content = cleanString(overrides.content) || fallbackContent;
|
||||
const subject = cleanString(overrides.subject) || fallbackTitle
|
||||
const emailTitle = cleanString(overrides.title) || fallbackTitle
|
||||
const content = cleanString(overrides.content) || fallbackContent
|
||||
|
||||
let button = null;
|
||||
if (overrides.button && typeof overrides.button === "object") {
|
||||
const buttonUrl =
|
||||
cleanString(overrides.button.url) || cleanString(overrides.button.href);
|
||||
let button = null
|
||||
if (overrides.button && typeof overrides.button === 'object') {
|
||||
const buttonUrl = cleanString(overrides.button.url) || cleanString(overrides.button.href)
|
||||
if (buttonUrl) {
|
||||
button = {
|
||||
url: buttonUrl,
|
||||
label:
|
||||
cleanString(overrides.button.label) ||
|
||||
cleanString(overrides.button.text) ||
|
||||
undefined,
|
||||
};
|
||||
cleanString(overrides.button.label) || cleanString(overrides.button.text) || undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const templatePayload = { title: emailTitle, content };
|
||||
const templatePayload = { title: emailTitle, content }
|
||||
if (button) {
|
||||
templatePayload.button = button;
|
||||
templatePayload.button = button
|
||||
}
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: basicTemplate(templatePayload),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
||||
{ region: "europe-west1", document: "notifications/{notificationId}" },
|
||||
{ region: 'europe-west1', document: 'notifications/{notificationId}' },
|
||||
async (event) => {
|
||||
try {
|
||||
const {
|
||||
receiver = null,
|
||||
title = "MusicLand",
|
||||
receiverCollection = "users",
|
||||
message = "",
|
||||
title = 'MusicLand',
|
||||
receiverCollection = 'users',
|
||||
message = '',
|
||||
data: notifData = {},
|
||||
mailOnly = false,
|
||||
} = event.data.data();
|
||||
} = event.data.data()
|
||||
|
||||
if (!receiver || !message) {
|
||||
throw new Error("Receiver and message are required");
|
||||
throw new Error('Receiver and message are required')
|
||||
}
|
||||
|
||||
const receiverSnap = await getCollectionRef(receiverCollection)
|
||||
.doc(receiver)
|
||||
.get();
|
||||
const receiverData = receiverSnap.exists ? receiverSnap.data() : {};
|
||||
const receiverSnap = await getCollectionRef(receiverCollection).doc(receiver).get()
|
||||
const receiverData = receiverSnap.exists ? receiverSnap.data() : {}
|
||||
|
||||
const {
|
||||
pushToken = null,
|
||||
pushTokens = [],
|
||||
email: receiverEmail = "",
|
||||
email: receiverEmail = '',
|
||||
emailNotifications = false,
|
||||
} = receiverData;
|
||||
} = receiverData
|
||||
|
||||
if (!mailOnly) {
|
||||
try {
|
||||
@@ -102,129 +90,124 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
||||
[]
|
||||
.concat(Array.isArray(pushTokens) ? pushTokens : [])
|
||||
.concat(pushToken ? [pushToken] : [])
|
||||
.filter(Boolean),
|
||||
);
|
||||
const tokens = Array.from(tokensSet);
|
||||
.filter(Boolean)
|
||||
)
|
||||
const tokens = Array.from(tokensSet)
|
||||
|
||||
if (!tokens?.length) {
|
||||
await sendExpoNotification({
|
||||
tokens,
|
||||
receiverId: receiver,
|
||||
receiverCollection,
|
||||
title: title || "MusicLand",
|
||||
title: title || 'MusicLand',
|
||||
message: message,
|
||||
data: notifData || {},
|
||||
});
|
||||
})
|
||||
} else {
|
||||
console.log("User push token not found");
|
||||
console.log('User push token not found')
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Error sending notif:", e);
|
||||
console.log('Error sending notif:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if ((emailNotifications || mailOnly) && !!receiverEmail) {
|
||||
if (!resendInstance) {
|
||||
console.warn(
|
||||
"Resend client not configured; unable to send notification email.",
|
||||
);
|
||||
console.warn('Resend client not configured; unable to send notification email.')
|
||||
} else {
|
||||
try {
|
||||
const { subject, html } = buildNotificationEmailPayload({
|
||||
title,
|
||||
message,
|
||||
template: notifData?.email,
|
||||
});
|
||||
})
|
||||
await resendInstance.emails.send({
|
||||
from: EMAIL_FROM,
|
||||
to: [receiverEmail],
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
})
|
||||
} catch (e) {
|
||||
console.log("Error sending email:", e);
|
||||
console.log('Error sending email:', e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[sendNotificationWhenDocIsCreated] Email not sent (${receiverEmail ? "user preference" : "missing email"}) for user ${receiver} and type ${notifData?.type || "UNKNOWN"}`,
|
||||
);
|
||||
`[sendNotificationWhenDocIsCreated] Email not sent (${receiverEmail ? 'user preference' : 'missing email'}) for user ${receiver} and type ${notifData?.type || 'UNKNOWN'}`
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return e;
|
||||
console.log(e)
|
||||
return e
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
// Fonction pour envoyer la notification via Expo SDK
|
||||
async function sendExpoNotification({
|
||||
tokens = [],
|
||||
title = "",
|
||||
message = "",
|
||||
title = '',
|
||||
message = '',
|
||||
data = {},
|
||||
receiverId = null,
|
||||
receiverCollection = "users",
|
||||
receiverCollection = 'users',
|
||||
}) {
|
||||
try {
|
||||
const candidateTokens = Array.isArray(tokens) ? tokens : [tokens];
|
||||
const validTokens = [];
|
||||
const invalidTokens = [];
|
||||
const candidateTokens = Array.isArray(tokens) ? tokens : [tokens]
|
||||
const validTokens = []
|
||||
const invalidTokens = []
|
||||
|
||||
candidateTokens.forEach((token) => {
|
||||
if (Expo.isExpoPushToken(token)) {
|
||||
validTokens.push(token);
|
||||
validTokens.push(token)
|
||||
} else if (token) {
|
||||
invalidTokens.push(token);
|
||||
invalidTokens.push(token)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
if (invalidTokens.length && receiverId) {
|
||||
await removeInvalidTokens({
|
||||
tokens: invalidTokens,
|
||||
receiverId,
|
||||
receiverCollection,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
if (!validTokens.length) {
|
||||
console.warn("No valid Expo push tokens to send notification");
|
||||
return { sent: false };
|
||||
console.warn('No valid Expo push tokens to send notification')
|
||||
return { sent: false }
|
||||
}
|
||||
|
||||
const messages = validTokens.map((token) => ({
|
||||
to: token,
|
||||
sound: "default",
|
||||
sound: 'default',
|
||||
title: title,
|
||||
body: message,
|
||||
data: data || {},
|
||||
priority: "high",
|
||||
priority: 'high',
|
||||
badge: 1,
|
||||
channelId: "default",
|
||||
}));
|
||||
channelId: 'default',
|
||||
}))
|
||||
|
||||
const chunks = expo.chunkPushNotifications(messages);
|
||||
const receipts = [];
|
||||
const tokensToPrune = new Set();
|
||||
const chunks = expo.chunkPushNotifications(messages)
|
||||
const receipts = []
|
||||
const tokensToPrune = new Set()
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const chunkReceipts = await expo.sendPushNotificationsAsync(chunk);
|
||||
const chunkReceipts = await expo.sendPushNotificationsAsync(chunk)
|
||||
chunkReceipts.forEach((receipt, index) => {
|
||||
if (receipt?.status === "error") {
|
||||
const errorCode = receipt?.details?.error || receipt?.details?.code;
|
||||
console.log("Error sending notification:", receipt);
|
||||
if (
|
||||
errorCode === "DeviceNotRegistered" ||
|
||||
errorCode === "PushTokenNotRegistered"
|
||||
) {
|
||||
const token = chunk[index]?.to;
|
||||
if (receipt?.status === 'error') {
|
||||
const errorCode = receipt?.details?.error || receipt?.details?.code
|
||||
console.log('Error sending notification:', receipt)
|
||||
if (errorCode === 'DeviceNotRegistered' || errorCode === 'PushTokenNotRegistered') {
|
||||
const token = chunk[index]?.to
|
||||
if (token) {
|
||||
tokensToPrune.add(token);
|
||||
tokensToPrune.add(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
receipts.push(...chunkReceipts);
|
||||
})
|
||||
receipts.push(...chunkReceipts)
|
||||
}
|
||||
|
||||
if (tokensToPrune.size && receiverId) {
|
||||
@@ -232,68 +215,64 @@ async function sendExpoNotification({
|
||||
tokens: Array.from(tokensToPrune),
|
||||
receiverId,
|
||||
receiverCollection,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
console.log("Sent push notifications:", receipts);
|
||||
console.log('Sent push notifications:', receipts)
|
||||
|
||||
return { sent: true };
|
||||
return { sent: true }
|
||||
} catch (e) {
|
||||
console.log("Error sending notification:", e);
|
||||
throw e;
|
||||
console.log('Error sending notification:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function removeInvalidTokens({
|
||||
tokens = [],
|
||||
receiverId,
|
||||
receiverCollection,
|
||||
}) {
|
||||
async function removeInvalidTokens({ tokens = [], receiverId, receiverCollection }) {
|
||||
try {
|
||||
if (!receiverId || !tokens.length) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const uniqueTokens = Array.from(new Set(tokens.filter(Boolean)));
|
||||
const uniqueTokens = Array.from(new Set(tokens.filter(Boolean)))
|
||||
if (!uniqueTokens.length) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const docRef = getCollectionRef(receiverCollection).doc(receiverId);
|
||||
const userSnap = await docRef.get();
|
||||
const userData = userSnap?.data() || {};
|
||||
const docRef = getCollectionRef(receiverCollection).doc(receiverId)
|
||||
const userSnap = await docRef.get()
|
||||
const userData = userSnap?.data() || {}
|
||||
|
||||
const updates = {
|
||||
pushTokens: FieldValue.arrayRemove(...uniqueTokens),
|
||||
};
|
||||
|
||||
if (uniqueTokens.includes(userData?.pushToken)) {
|
||||
updates.pushToken = FieldValue.delete();
|
||||
}
|
||||
|
||||
await docRef.set(updates, { merge: true });
|
||||
if (uniqueTokens.includes(userData?.pushToken)) {
|
||||
updates.pushToken = FieldValue.delete()
|
||||
}
|
||||
|
||||
await docRef.set(updates, { merge: true })
|
||||
console.log(
|
||||
"Pruned invalid push tokens",
|
||||
JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2),
|
||||
);
|
||||
'Pruned invalid push tokens',
|
||||
JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2)
|
||||
)
|
||||
} catch (error) {
|
||||
console.log("Failed to prune invalid push tokens:", error);
|
||||
console.log('Failed to prune invalid push tokens:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour ajouter une notification à la base de données
|
||||
const sendNotification = async ({
|
||||
sender = "SYSTEM",
|
||||
sender = 'SYSTEM',
|
||||
receiver = null,
|
||||
receiverCollection = "users",
|
||||
title = "",
|
||||
receiverCollection = 'users',
|
||||
title = '',
|
||||
message = null,
|
||||
mailOnly = false,
|
||||
data = {},
|
||||
}) => {
|
||||
try {
|
||||
if (!receiver || !message) {
|
||||
throw new Error("Receiver and message are required");
|
||||
throw new Error('Receiver and message are required')
|
||||
}
|
||||
const payload = {
|
||||
sender,
|
||||
@@ -306,81 +285,78 @@ const sendNotification = async ({
|
||||
readAt: null,
|
||||
mailOnly,
|
||||
data,
|
||||
};
|
||||
const { id } = await refList.notifications.add(payload);
|
||||
console.log(
|
||||
"[sendNotification] Notification created",
|
||||
JSON.stringify({ id, receiver, receiverCollection }, null, 2),
|
||||
);
|
||||
|
||||
return id;
|
||||
} catch (e) {
|
||||
console.log("[sendNotification] Error creating notification:", e);
|
||||
}
|
||||
};
|
||||
const { id } = await refList.notifications.add(payload)
|
||||
console.log(
|
||||
'[sendNotification] Notification created',
|
||||
JSON.stringify({ id, receiver, receiverCollection }, null, 2)
|
||||
)
|
||||
|
||||
exports.sendNotification = sendNotification;
|
||||
return id
|
||||
} catch (e) {
|
||||
console.log('[sendNotification] Error creating notification:', e)
|
||||
}
|
||||
}
|
||||
|
||||
exports.sendNotification = sendNotification
|
||||
|
||||
exports.createProjectCommentNotification = onDocumentCreated(
|
||||
{
|
||||
region: "europe-west1",
|
||||
document: "projects/{projectId}/comments/{commentId}",
|
||||
region: 'europe-west1',
|
||||
document: 'projects/{projectId}/comments/{commentId}',
|
||||
},
|
||||
async (event) => {
|
||||
try {
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Trigger received",
|
||||
JSON.stringify(event.params || {}, null, 2),
|
||||
);
|
||||
const { data: snap } = event;
|
||||
const { projectId, commentId } = event.params || {};
|
||||
const comment = snap?.data();
|
||||
'[createProjectCommentNotification] Trigger received',
|
||||
JSON.stringify(event.params || {}, null, 2)
|
||||
)
|
||||
const { data: snap } = event
|
||||
const { projectId, commentId } = event.params || {}
|
||||
const comment = snap?.data()
|
||||
|
||||
if (!projectId || !comment) {
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Missing project/comment data",
|
||||
{ hasProjectId: !!projectId, hasComment: !!comment },
|
||||
);
|
||||
return null;
|
||||
console.log('[createProjectCommentNotification] Missing project/comment data', {
|
||||
hasProjectId: !!projectId,
|
||||
hasComment: !!comment,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Comment payload",
|
||||
JSON.stringify(comment, null, 2),
|
||||
);
|
||||
'[createProjectCommentNotification] Comment payload',
|
||||
JSON.stringify(comment, null, 2)
|
||||
)
|
||||
|
||||
const projectSnap = await refList.projects.doc(projectId).get();
|
||||
const projectSnap = await refList.projects.doc(projectId).get()
|
||||
if (!projectSnap.exists) {
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Project not found",
|
||||
projectId,
|
||||
);
|
||||
return null;
|
||||
console.log('[createProjectCommentNotification] Project not found', projectId)
|
||||
return null
|
||||
}
|
||||
|
||||
const project = projectSnap.data() || {};
|
||||
const receiver = project.userId || null;
|
||||
const project = projectSnap.data() || {}
|
||||
const receiver = project.userId || null
|
||||
|
||||
if (!receiver || receiver === comment.userId) {
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Invalid receiver",
|
||||
JSON.stringify({ receiver, commentUserId: comment.userId }),
|
||||
);
|
||||
return null;
|
||||
'[createProjectCommentNotification] Invalid receiver',
|
||||
JSON.stringify({ receiver, commentUserId: comment.userId })
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const commenterName =
|
||||
typeof comment?.userName === "string" && comment.userName.trim()
|
||||
typeof comment?.userName === 'string' && comment.userName.trim()
|
||||
? comment.userName.trim()
|
||||
: "Un utilisateur";
|
||||
: 'Un utilisateur'
|
||||
const projectTitle =
|
||||
typeof project.title === "string" && project.title.trim()
|
||||
typeof project.title === 'string' && project.title.trim()
|
||||
? project.title.trim()
|
||||
: "ton projet";
|
||||
const message = `${commenterName} a commenté ton projet "${projectTitle}"`;
|
||||
: 'ton projet'
|
||||
const message = `${commenterName} a commenté ton projet "${projectTitle}"`
|
||||
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Creating notification",
|
||||
'[createProjectCommentNotification] Creating notification',
|
||||
JSON.stringify(
|
||||
{
|
||||
receiver,
|
||||
@@ -389,15 +365,15 @@ exports.createProjectCommentNotification = onDocumentCreated(
|
||||
projectId,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
await sendNotification({
|
||||
sender: comment.userId || "SYSTEM",
|
||||
sender: comment.userId || 'SYSTEM',
|
||||
receiver,
|
||||
receiverCollection: "users",
|
||||
title: "Nouveau commentaire",
|
||||
receiverCollection: 'users',
|
||||
title: 'Nouveau commentaire',
|
||||
message,
|
||||
data: {
|
||||
type: ALERT_TYPE?.NEW_COMMENT,
|
||||
@@ -405,109 +381,94 @@ exports.createProjectCommentNotification = onDocumentCreated(
|
||||
commentId,
|
||||
commenterId: comment.userId || null,
|
||||
commenterName: commenterName,
|
||||
commenterProfilePicture: comment?.profilePicture || "",
|
||||
text:
|
||||
typeof comment?.text === "string" && comment.text.trim()
|
||||
? comment.text.trim()
|
||||
: "",
|
||||
commenterProfilePicture: comment?.profilePicture || '',
|
||||
text: typeof comment?.text === 'string' && comment.text.trim() ? comment.text.trim() : '',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Notification creation complete",
|
||||
);
|
||||
console.log('[createProjectCommentNotification] Notification creation complete')
|
||||
|
||||
return null;
|
||||
return null
|
||||
} catch (error) {
|
||||
console.log("createProjectCommentNotification error:", error);
|
||||
return error;
|
||||
console.log('createProjectCommentNotification error:', error)
|
||||
return error
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
exports.createProjectLikeNotification = onDocumentWritten(
|
||||
{
|
||||
region: "europe-west1",
|
||||
document: "projects/{projectId}",
|
||||
region: 'europe-west1',
|
||||
document: 'projects/{projectId}',
|
||||
},
|
||||
async (event) => {
|
||||
try {
|
||||
const { projectId } = event.params || {};
|
||||
const before = event?.data?.before?.data() || {};
|
||||
const after = event?.data?.after?.data() || {};
|
||||
const { projectId } = event.params || {}
|
||||
const before = event?.data?.before?.data() || {}
|
||||
const after = event?.data?.after?.data() || {}
|
||||
|
||||
if (!projectId || !after) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const ownerId = after.userId || null;
|
||||
const ownerId = after.userId || null
|
||||
if (!ownerId) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const beforeSongLikes = Array.isArray(before?.likes?.song)
|
||||
? before.likes.song
|
||||
: [];
|
||||
const afterSongLikes = Array.isArray(after?.likes?.song)
|
||||
? after.likes.song
|
||||
: [];
|
||||
const beforeSongLikes = Array.isArray(before?.likes?.song) ? before.likes.song : []
|
||||
const afterSongLikes = Array.isArray(after?.likes?.song) ? after.likes.song : []
|
||||
const beforePlaybackLikes = Array.isArray(before?.likes?.playback)
|
||||
? before.likes.playback
|
||||
: [];
|
||||
const afterPlaybackLikes = Array.isArray(after?.likes?.playback)
|
||||
? after.likes.playback
|
||||
: [];
|
||||
: []
|
||||
const afterPlaybackLikes = Array.isArray(after?.likes?.playback) ? after.likes.playback : []
|
||||
|
||||
const beforeSongSet = new Set(beforeSongLikes);
|
||||
const beforePlaybackSet = new Set(beforePlaybackLikes);
|
||||
const beforeSongSet = new Set(beforeSongLikes)
|
||||
const beforePlaybackSet = new Set(beforePlaybackLikes)
|
||||
|
||||
const newSongLikers = afterSongLikes.filter(
|
||||
(uid) => uid && !beforeSongSet.has(uid),
|
||||
);
|
||||
const newSongLikers = afterSongLikes.filter((uid) => uid && !beforeSongSet.has(uid))
|
||||
const newPlaybackLikers = afterPlaybackLikes.filter(
|
||||
(uid) => uid && !beforePlaybackSet.has(uid),
|
||||
);
|
||||
(uid) => uid && !beforePlaybackSet.has(uid)
|
||||
)
|
||||
|
||||
const newLikers = [];
|
||||
const newLikers = []
|
||||
|
||||
newSongLikers.forEach((uid) => {
|
||||
newLikers.push({ likerId: uid, likeType: "song" });
|
||||
});
|
||||
newLikers.push({ likerId: uid, likeType: 'song' })
|
||||
})
|
||||
newPlaybackLikers.forEach((uid) => {
|
||||
newLikers.push({ likerId: uid, likeType: "playback" });
|
||||
});
|
||||
newLikers.push({ likerId: uid, likeType: 'playback' })
|
||||
})
|
||||
|
||||
if (!newLikers.length) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const projectTitle =
|
||||
typeof after.title === "string" && after.title.trim()
|
||||
? after.title.trim()
|
||||
: "ton projet";
|
||||
typeof after.title === 'string' && after.title.trim() ? after.title.trim() : 'ton projet'
|
||||
|
||||
await Promise.all(
|
||||
newLikers.map(async ({ likerId, likeType }) => {
|
||||
if (!likerId || likerId === ownerId) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const likerSnap = await refList.users.doc(likerId).get();
|
||||
const liker = likerSnap?.data() || {};
|
||||
const likerSnap = await refList.users.doc(likerId).get()
|
||||
const liker = likerSnap?.data() || {}
|
||||
const likerName =
|
||||
typeof liker?.userName === "string" && liker.userName.trim()
|
||||
typeof liker?.userName === 'string' && liker.userName.trim()
|
||||
? liker.userName.trim()
|
||||
: "Un utilisateur";
|
||||
: 'Un utilisateur'
|
||||
|
||||
const isPlaybackLike = likeType === "playback";
|
||||
const assetLabel = isPlaybackLike ? "ton playback" : "ta musique";
|
||||
const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"`;
|
||||
const isPlaybackLike = likeType === 'playback'
|
||||
const assetLabel = isPlaybackLike ? 'ton playback' : 'ta musique'
|
||||
const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"`
|
||||
|
||||
await sendNotification({
|
||||
sender: likerId,
|
||||
receiver: ownerId,
|
||||
receiverCollection: "users",
|
||||
title: "Nouveau like",
|
||||
receiverCollection: 'users',
|
||||
title: 'Nouveau like',
|
||||
message,
|
||||
data: {
|
||||
type: ALERT_TYPE?.NEW_LIKE,
|
||||
@@ -516,92 +477,86 @@ exports.createProjectLikeNotification = onDocumentWritten(
|
||||
likerName,
|
||||
likeType,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
return null
|
||||
})
|
||||
)
|
||||
|
||||
return null;
|
||||
return null
|
||||
} catch (error) {
|
||||
console.log("[createProjectLikeNotification] error:", error);
|
||||
return error;
|
||||
console.log('[createProjectLikeNotification] error:', error)
|
||||
return error
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
exports.createNewFollowerNotification = onDocumentWritten(
|
||||
{
|
||||
region: "europe-west1",
|
||||
document: "users/{userId}",
|
||||
region: 'europe-west1',
|
||||
document: 'users/{userId}',
|
||||
},
|
||||
async (event) => {
|
||||
try {
|
||||
const { userId } = event.params || {};
|
||||
const before = event?.data?.before?.data() || {};
|
||||
const after = event?.data?.after?.data() || {};
|
||||
const { userId } = event.params || {}
|
||||
const before = event?.data?.before?.data() || {}
|
||||
const after = event?.data?.after?.data() || {}
|
||||
|
||||
if (!userId || !after) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const beforeFollowers = Array.isArray(before?.followedBy)
|
||||
? before.followedBy
|
||||
: [];
|
||||
const afterFollowers = Array.isArray(after?.followedBy)
|
||||
? after.followedBy
|
||||
: [];
|
||||
const beforeFollowers = Array.isArray(before?.followedBy) ? before.followedBy : []
|
||||
const afterFollowers = Array.isArray(after?.followedBy) ? after.followedBy : []
|
||||
|
||||
if (afterFollowers.length <= beforeFollowers.length) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const previousSet = new Set(beforeFollowers);
|
||||
const newFollowers = afterFollowers.filter(
|
||||
(uid) => !previousSet.has(uid),
|
||||
);
|
||||
const previousSet = new Set(beforeFollowers)
|
||||
const newFollowers = afterFollowers.filter((uid) => !previousSet.has(uid))
|
||||
|
||||
if (!newFollowers.length) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
newFollowers.map(async (followerId) => {
|
||||
if (!followerId || followerId === userId) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const followerSnap = await refList.users.doc(followerId).get();
|
||||
const follower = followerSnap?.data() || {};
|
||||
const followerSnap = await refList.users.doc(followerId).get()
|
||||
const follower = followerSnap?.data() || {}
|
||||
const followerName =
|
||||
typeof follower?.userName === "string" && follower.userName.trim()
|
||||
typeof follower?.userName === 'string' && follower.userName.trim()
|
||||
? follower.userName.trim()
|
||||
: "Un utilisateur";
|
||||
: 'Un utilisateur'
|
||||
|
||||
const message = `${followerName} te suit maintenant`;
|
||||
const message = `${followerName} te suit maintenant`
|
||||
|
||||
await sendNotification({
|
||||
sender: followerId,
|
||||
receiver: userId,
|
||||
receiverCollection: "users",
|
||||
title: "Nouvel abonné",
|
||||
receiverCollection: 'users',
|
||||
title: 'Nouvel abonné',
|
||||
message,
|
||||
data: {
|
||||
type: ALERT_TYPE?.NEW_FOLLOWER,
|
||||
followerId,
|
||||
followerName,
|
||||
followerProfilePicture: follower?.profilePicture || "",
|
||||
followerProfilePicture: follower?.profilePicture || '',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
return null
|
||||
})
|
||||
)
|
||||
|
||||
return null;
|
||||
return null
|
||||
} catch (error) {
|
||||
console.log("[createNewFollowerNotification] error:", error);
|
||||
return error;
|
||||
console.log('[createNewFollowerNotification] error:', error)
|
||||
return error
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
+107
-138
@@ -1,86 +1,81 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const { onDocumentCreated } = require("firebase-functions/firestore");
|
||||
const { HttpsError, onCall } = require("firebase-functions/https");
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const { onDocumentCreated } = require('firebase-functions/firestore')
|
||||
const { HttpsError, onCall } = require('firebase-functions/https')
|
||||
|
||||
const { REGION, ALERT_TYPE } = require("../index");
|
||||
const { sendNotification } = require("./notifications");
|
||||
const { REGION, ALERT_TYPE } = require('../index')
|
||||
const { sendNotification } = require('./notifications')
|
||||
const {
|
||||
ORDER_TYPES,
|
||||
ORDER_STATUS,
|
||||
ORDERS_COLLECTION,
|
||||
createOrderDocument,
|
||||
normalizeAmount,
|
||||
} = require("./helpers/orders");
|
||||
} = require('./helpers/orders')
|
||||
|
||||
const USERS_COLLECTION = "users";
|
||||
const USERS_COLLECTION = 'users'
|
||||
|
||||
const formatCoinsText = (value) => {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const absoluteValue = Math.abs(value);
|
||||
const absoluteValue = Math.abs(value)
|
||||
if (!Number.isFinite(absoluteValue)) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const formatted = Number.isInteger(absoluteValue)
|
||||
? `${absoluteValue}`
|
||||
: absoluteValue.toFixed(2);
|
||||
const suffix = absoluteValue === 1 ? "crédit" : "crédits";
|
||||
const formatted = Number.isInteger(absoluteValue) ? `${absoluteValue}` : absoluteValue.toFixed(2)
|
||||
const suffix = absoluteValue === 1 ? 'crédit' : 'crédits'
|
||||
|
||||
return `${formatted} ${suffix}`;
|
||||
};
|
||||
return `${formatted} ${suffix}`
|
||||
}
|
||||
|
||||
const buildOrderNotificationContent = ({ amount, orderType, balanceAfter }) => {
|
||||
if (typeof amount !== "number" || Number.isNaN(amount) || amount === 0) {
|
||||
return null;
|
||||
if (typeof amount !== 'number' || Number.isNaN(amount) || amount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const coinsText = formatCoinsText(amount);
|
||||
const coinsText = formatCoinsText(amount)
|
||||
if (!coinsText) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const balanceText = formatCoinsText(balanceAfter);
|
||||
const balanceSentence = balanceText
|
||||
? ` Ton solde est maintenant de ${balanceText}.`
|
||||
: "";
|
||||
const balanceText = formatCoinsText(balanceAfter)
|
||||
const balanceSentence = balanceText ? ` Ton solde est maintenant de ${balanceText}.` : ''
|
||||
|
||||
if (amount > 0) {
|
||||
if (orderType === ORDER_TYPES.COINS) {
|
||||
return {
|
||||
title: "Crédits achetés",
|
||||
title: 'Crédits achetés',
|
||||
message: `Ton achat de ${coinsText} est confirmé.${balanceSentence}`,
|
||||
action: "PURCHASED",
|
||||
};
|
||||
action: 'PURCHASED',
|
||||
}
|
||||
}
|
||||
|
||||
if (orderType === ORDER_TYPES.GIFT) {
|
||||
return {
|
||||
title: "Crédits reçus",
|
||||
title: 'Crédits reçus',
|
||||
message: `Tu as reçu ${coinsText}.${balanceSentence}`,
|
||||
action: "EARNED",
|
||||
};
|
||||
action: 'EARNED',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Crédits ajoutés",
|
||||
title: 'Crédits ajoutés',
|
||||
message: `Ton solde augmente de ${coinsText}.${balanceSentence}`,
|
||||
action: "CREDITED",
|
||||
};
|
||||
action: 'CREDITED',
|
||||
}
|
||||
}
|
||||
|
||||
const reason =
|
||||
orderType === ORDER_TYPES.SONG ? " pour générer un nouveau son" : "";
|
||||
const reason = orderType === ORDER_TYPES.SONG ? ' pour générer un nouveau son' : ''
|
||||
|
||||
return {
|
||||
title: "Crédits dépensés",
|
||||
title: 'Crédits dépensés',
|
||||
message: `Tu as dépensé ${coinsText}${reason}.${balanceSentence}`,
|
||||
action: "SPENT",
|
||||
};
|
||||
};
|
||||
action: 'SPENT',
|
||||
}
|
||||
}
|
||||
|
||||
const notifyOrderApplied = async ({
|
||||
userId,
|
||||
@@ -95,69 +90,61 @@ const notifyOrderApplied = async ({
|
||||
amount,
|
||||
orderType,
|
||||
balanceAfter,
|
||||
});
|
||||
})
|
||||
|
||||
if (!content || !userId) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await sendNotification({
|
||||
sender: "SYSTEM",
|
||||
sender: 'SYSTEM',
|
||||
receiver: userId,
|
||||
receiverCollection: USERS_COLLECTION,
|
||||
title: content.title,
|
||||
message: content.message,
|
||||
data: {
|
||||
type: ALERT_TYPE?.CREDITS_UPDATED || "CREDITS_UPDATED",
|
||||
type: ALERT_TYPE?.CREDITS_UPDATED || 'CREDITS_UPDATED',
|
||||
orderId,
|
||||
orderType: orderType || null,
|
||||
amount,
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
action: content.action,
|
||||
source:
|
||||
typeof metadata?.source === "string" ? metadata.source : null,
|
||||
source: typeof metadata?.source === 'string' ? metadata.source : null,
|
||||
metadata: metadata || {},
|
||||
},
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[orders-onOrderCreated] Failed to send notification",
|
||||
orderId,
|
||||
error,
|
||||
);
|
||||
console.error('[orders-onOrderCreated] Failed to send notification', orderId, error)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onOrderCreated = onDocumentCreated(
|
||||
`${ORDERS_COLLECTION}/{orderId}`,
|
||||
async (event) => {
|
||||
const orderRef = event?.data?.ref;
|
||||
const orderData = event?.data?.data();
|
||||
const onOrderCreated = onDocumentCreated(`${ORDERS_COLLECTION}/{orderId}`, async (event) => {
|
||||
const orderRef = event?.data?.ref
|
||||
const orderData = event?.data?.data()
|
||||
|
||||
if (!orderRef || !orderData) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
if (orderData?.processedAt) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const userId =
|
||||
typeof orderData.userId === "string" ? orderData.userId.trim() : "";
|
||||
const amount = normalizeAmount(orderData.amount);
|
||||
const userId = typeof orderData.userId === 'string' ? orderData.userId.trim() : ''
|
||||
const amount = normalizeAmount(orderData.amount)
|
||||
|
||||
if (!userId) {
|
||||
await orderRef.set(
|
||||
{
|
||||
status: ORDER_STATUS.REJECTED,
|
||||
processedAt: FieldValue.serverTimestamp(),
|
||||
failureReason: "USER_NOT_FOUND",
|
||||
failureReason: 'USER_NOT_FOUND',
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
return;
|
||||
{ merge: true }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (amount === null || amount === 0) {
|
||||
@@ -165,51 +152,46 @@ const onOrderCreated = onDocumentCreated(
|
||||
{
|
||||
status: ORDER_STATUS.REJECTED,
|
||||
processedAt: FieldValue.serverTimestamp(),
|
||||
failureReason: "INVALID_AMOUNT",
|
||||
failureReason: 'INVALID_AMOUNT',
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
return;
|
||||
{ merge: true }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId);
|
||||
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId)
|
||||
|
||||
let notificationContext = null;
|
||||
let notificationContext = null
|
||||
|
||||
try {
|
||||
await admin.firestore().runTransaction(async (transaction) => {
|
||||
const userSnapshot = await transaction.get(userRef);
|
||||
const userData = userSnapshot?.data() || {};
|
||||
const currentBalanceValue = normalizeAmount(userData?.coins);
|
||||
const currentBalance =
|
||||
currentBalanceValue !== null ? currentBalanceValue : 0;
|
||||
const userSnapshot = await transaction.get(userRef)
|
||||
const userData = userSnapshot?.data() || {}
|
||||
const currentBalanceValue = normalizeAmount(userData?.coins)
|
||||
const currentBalance = currentBalanceValue !== null ? currentBalanceValue : 0
|
||||
|
||||
const nextBalance = currentBalance + amount;
|
||||
const nextBalance = currentBalance + amount
|
||||
|
||||
if (
|
||||
amount < 0 &&
|
||||
nextBalance < 0 &&
|
||||
orderData?.type === ORDER_TYPES.SONG
|
||||
) {
|
||||
if (amount < 0 && nextBalance < 0 && orderData?.type === ORDER_TYPES.SONG) {
|
||||
transaction.set(
|
||||
orderRef,
|
||||
{
|
||||
status: ORDER_STATUS.REJECTED,
|
||||
processedAt: FieldValue.serverTimestamp(),
|
||||
failureReason: "INSUFFICIENT_FUNDS",
|
||||
failureReason: 'INSUFFICIENT_FUNDS',
|
||||
balanceBefore: currentBalance,
|
||||
balanceAfter: currentBalance,
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
return;
|
||||
{ merge: true }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (userSnapshot?.exists) {
|
||||
transaction.update(userRef, {
|
||||
coins: nextBalance,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
})
|
||||
} else {
|
||||
transaction.set(
|
||||
userRef,
|
||||
@@ -218,8 +200,8 @@ const onOrderCreated = onDocumentCreated(
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
{ merge: true }
|
||||
)
|
||||
}
|
||||
|
||||
transaction.set(
|
||||
@@ -230,31 +212,27 @@ const onOrderCreated = onDocumentCreated(
|
||||
balanceBefore: currentBalance,
|
||||
balanceAfter: nextBalance,
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
notificationContext = {
|
||||
balanceBefore: currentBalance,
|
||||
balanceAfter: nextBalance,
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[orders-onOrderCreated] Failed to process order",
|
||||
orderRef.id,
|
||||
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",
|
||||
failureReason: 'PROCESSING_ERROR',
|
||||
errorMessage: error?.message || String(error),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
return;
|
||||
{ merge: true }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (notificationContext) {
|
||||
@@ -262,59 +240,50 @@ const onOrderCreated = onDocumentCreated(
|
||||
userId,
|
||||
orderId: orderRef.id,
|
||||
amount,
|
||||
orderType:
|
||||
typeof orderData?.type === "string" ? orderData.type : null,
|
||||
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 || {};
|
||||
const { auth, data } = request || {}
|
||||
|
||||
if (!auth?.uid) {
|
||||
throw new HttpsError("unauthenticated", "Authentification requise.");
|
||||
throw new HttpsError('unauthenticated', 'Authentification requise.')
|
||||
}
|
||||
|
||||
const amount = normalizeAmount(data?.amount);
|
||||
const amount = normalizeAmount(data?.amount)
|
||||
|
||||
if (amount === null || amount >= 0) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
"Le montant doit être négatif pour un achat de musique.",
|
||||
);
|
||||
'invalid-argument',
|
||||
'Le montant doit être négatif pour un achat de musique.'
|
||||
)
|
||||
}
|
||||
|
||||
const songId =
|
||||
typeof data?.songId === "string" && data.songId.trim()
|
||||
? data.songId.trim()
|
||||
: null;
|
||||
const songId = typeof data?.songId === 'string' && data.songId.trim() ? data.songId.trim() : null
|
||||
|
||||
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid);
|
||||
const userSnapshot = await userRef.get();
|
||||
const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins);
|
||||
const currentCoins =
|
||||
currentCoinsValue !== null ? currentCoinsValue : 0;
|
||||
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid)
|
||||
const userSnapshot = await userRef.get()
|
||||
const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins)
|
||||
const currentCoins = currentCoinsValue !== null ? currentCoinsValue : 0
|
||||
|
||||
if (currentCoins + amount < 0) {
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"Crédits insuffisants pour finaliser l'opération.",
|
||||
);
|
||||
throw new HttpsError('failed-precondition', "Crédits insuffisants pour finaliser l'opération.")
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
source:
|
||||
typeof data?.source === "string" && data.source.trim()
|
||||
typeof data?.source === 'string' && data.source.trim()
|
||||
? data.source.trim()
|
||||
: "music_generation",
|
||||
};
|
||||
: 'music_generation',
|
||||
}
|
||||
|
||||
if (typeof data?.requestId === "string" && data.requestId.trim()) {
|
||||
metadata.requestId = data.requestId.trim();
|
||||
if (typeof data?.requestId === 'string' && data.requestId.trim()) {
|
||||
metadata.requestId = data.requestId.trim()
|
||||
}
|
||||
|
||||
const { orderId } = await createOrderDocument({
|
||||
@@ -324,12 +293,12 @@ const createSongOrder = onCall({ region: REGION }, async (request) => {
|
||||
songId,
|
||||
createdBy: auth.uid,
|
||||
metadata,
|
||||
});
|
||||
})
|
||||
|
||||
return { orderId };
|
||||
});
|
||||
return { orderId }
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
onOrderCreated,
|
||||
createSongOrder,
|
||||
};
|
||||
}
|
||||
|
||||
+109
-135
@@ -1,151 +1,134 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
||||
const _ = require("lodash");
|
||||
const { refList, db, ALERT_TYPE } = require("../index");
|
||||
const { batchFirestore } = require("../helpers/firebase");
|
||||
const { BATCH_TYPE } = require("../config/types");
|
||||
const {
|
||||
buildMonthKey,
|
||||
buildPreviousMonthContext,
|
||||
} = require("../helpers/stats");
|
||||
const { sendNotification } = require("./notifications");
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const { onSchedule } = require('firebase-functions/v2/scheduler')
|
||||
const _ = require('lodash')
|
||||
const { refList, db, ALERT_TYPE } = require('../index')
|
||||
const { batchFirestore } = require('../helpers/firebase')
|
||||
const { BATCH_TYPE } = require('../config/types')
|
||||
const { buildMonthKey, buildPreviousMonthContext } = require('../helpers/stats')
|
||||
const { sendNotification } = require('./notifications')
|
||||
|
||||
const DISTRIBUTION_REVENUE_BASELINE = 1000;
|
||||
const DISTRIBUTION_RATIO = 0.3;
|
||||
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(["active"]);
|
||||
const DISTRIBUTION_REVENUE_BASELINE = 1000
|
||||
const DISTRIBUTION_RATIO = 0.3
|
||||
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['active'])
|
||||
|
||||
const hasActiveSubscription = (userData) => {
|
||||
if (!userData || typeof userData !== "object") {
|
||||
return false;
|
||||
if (!userData || typeof userData !== 'object') {
|
||||
return false
|
||||
}
|
||||
const isPremium = userData.isPremium === true;
|
||||
const isPremium = userData.isPremium === true
|
||||
if (!isPremium) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
const status =
|
||||
typeof userData.stripeSubscriptionStatus === "string"
|
||||
typeof userData.stripeSubscriptionStatus === 'string'
|
||||
? userData.stripeSubscriptionStatus.trim().toLowerCase()
|
||||
: null;
|
||||
: null
|
||||
if (status && ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
const billingPeriod =
|
||||
typeof userData.premiumBillingPeriod === "string"
|
||||
typeof userData.premiumBillingPeriod === 'string'
|
||||
? userData.premiumBillingPeriod.trim().toLowerCase()
|
||||
: null;
|
||||
if (billingPeriod === "monthly" || billingPeriod === "annual") {
|
||||
: null
|
||||
if (billingPeriod === 'monthly' || billingPeriod === 'annual') {
|
||||
// Fallback: billing period is set only for active subscribers.
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
exports.distributeMonthlyPayouts = onSchedule(
|
||||
{
|
||||
schedule: "0 1 1 * *",
|
||||
timeZone: "Europe/Paris",
|
||||
schedule: '0 1 1 * *',
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
async (event) => {
|
||||
const { scheduleTime } = event;
|
||||
const context = buildPreviousMonthContext(
|
||||
scheduleTime ? new Date(scheduleTime) : new Date(),
|
||||
);
|
||||
const monthKey = buildMonthKey(context.rangeStart);
|
||||
const now = admin.firestore.Timestamp.now();
|
||||
const { scheduleTime } = event
|
||||
const context = buildPreviousMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
|
||||
const monthKey = buildMonthKey(context.rangeStart)
|
||||
const now = admin.firestore.Timestamp.now()
|
||||
|
||||
const statsSnapshot = await db
|
||||
.collectionGroup("monthlyListens")
|
||||
.where("monthKey", "==", monthKey)
|
||||
.orderBy("streams", "desc")
|
||||
.get();
|
||||
.collectionGroup('monthlyListens')
|
||||
.where('monthKey', '==', monthKey)
|
||||
.orderBy('streams', 'desc')
|
||||
.get()
|
||||
|
||||
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey);
|
||||
const totalsSnapshot = await totalsDocRef.get();
|
||||
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey)
|
||||
const totalsSnapshot = await totalsDocRef.get()
|
||||
|
||||
const entries = _.chain(statsSnapshot.docs)
|
||||
.map((doc) => {
|
||||
const data = doc.data() || {};
|
||||
const data = doc.data() || {}
|
||||
return {
|
||||
projectId:
|
||||
_.get(data, "projectId") || doc.ref.parent.parent?.id || null,
|
||||
userId: _.get(data, "userId", null),
|
||||
streams: _.toFinite(_.get(data, "streams", 0)),
|
||||
projectId: _.get(data, 'projectId') || doc.ref.parent.parent?.id || null,
|
||||
userId: _.get(data, 'userId', null),
|
||||
streams: _.toFinite(_.get(data, 'streams', 0)),
|
||||
statsDocPath: doc.ref.path,
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.projectId && entry.streams > 0)
|
||||
.orderBy(["streams"], ["desc"])
|
||||
.value();
|
||||
.orderBy(['streams'], ['desc'])
|
||||
.value()
|
||||
|
||||
const userEligibilityMap = {};
|
||||
const userIds = _.uniq(
|
||||
entries.map((entry) => entry.userId).filter((userId) => !!userId),
|
||||
);
|
||||
const userEligibilityMap = {}
|
||||
const userIds = _.uniq(entries.map((entry) => entry.userId).filter((userId) => !!userId))
|
||||
|
||||
if (userIds.length) {
|
||||
const chunkSize = 300;
|
||||
const chunkSize = 300
|
||||
for (let index = 0; index < userIds.length; index += chunkSize) {
|
||||
const chunk = userIds.slice(index, index + chunkSize);
|
||||
const chunk = userIds.slice(index, index + chunkSize)
|
||||
const snapshots = await Promise.all(
|
||||
chunk.map(async (userId) => {
|
||||
try {
|
||||
return await refList.users.doc(userId).get();
|
||||
return await refList.users.doc(userId).get()
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[distributeMonthlyPayouts] Unable to load user profile",
|
||||
{
|
||||
console.warn('[distributeMonthlyPayouts] Unable to load user profile', {
|
||||
userId,
|
||||
error: error?.message || String(error),
|
||||
},
|
||||
);
|
||||
return null;
|
||||
})
|
||||
return null
|
||||
}
|
||||
}),
|
||||
);
|
||||
})
|
||||
)
|
||||
|
||||
snapshots.forEach((snapshot, snapshotIndex) => {
|
||||
const userId = chunk[snapshotIndex];
|
||||
const userId = chunk[snapshotIndex]
|
||||
if (snapshot?.exists) {
|
||||
userEligibilityMap[userId] = hasActiveSubscription(
|
||||
snapshot.data(),
|
||||
);
|
||||
userEligibilityMap[userId] = hasActiveSubscription(snapshot.data())
|
||||
} else {
|
||||
userEligibilityMap[userId] = false;
|
||||
userEligibilityMap[userId] = false
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const eligibleEntries = entries.filter(
|
||||
(entry) =>
|
||||
!!entry.userId && userEligibilityMap[entry.userId] === true,
|
||||
);
|
||||
const eligibleTotalStreams = _.sumBy(eligibleEntries, "streams");
|
||||
(entry) => !!entry.userId && userEligibilityMap[entry.userId] === true
|
||||
)
|
||||
const eligibleTotalStreams = _.sumBy(eligibleEntries, 'streams')
|
||||
|
||||
const payoutsTotalStreamsFromDocs = _.sumBy(entries, "streams");
|
||||
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null;
|
||||
let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0));
|
||||
const payoutsTotalStreamsFromDocs = _.sumBy(entries, 'streams')
|
||||
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null
|
||||
let totalStreams = _.toFinite(_.get(totalsData, 'totalStreams', 0))
|
||||
if (!totalStreams || totalStreams < payoutsTotalStreamsFromDocs) {
|
||||
totalStreams = payoutsTotalStreamsFromDocs;
|
||||
totalStreams = payoutsTotalStreamsFromDocs
|
||||
}
|
||||
|
||||
const payoutPool = _.round(
|
||||
DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO,
|
||||
2,
|
||||
);
|
||||
const payoutPool = _.round(DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO, 2)
|
||||
|
||||
let allocations = _.map(eligibleEntries, (entry) => {
|
||||
if (!eligibleTotalStreams) return 0;
|
||||
const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams;
|
||||
return _.round(rawAmount, 2);
|
||||
});
|
||||
if (!eligibleTotalStreams) return 0
|
||||
const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams
|
||||
return _.round(rawAmount, 2)
|
||||
})
|
||||
|
||||
if (allocations.length > 0) {
|
||||
const allocatedTotal = _.round(_.sum(allocations), 2);
|
||||
const remainder = _.round(payoutPool - allocatedTotal, 2);
|
||||
const allocatedTotal = _.round(_.sum(allocations), 2)
|
||||
const remainder = _.round(payoutPool - allocatedTotal, 2)
|
||||
if (remainder !== 0) {
|
||||
allocations[0] = _.round(allocations[0] + remainder, 2);
|
||||
allocations[0] = _.round(allocations[0] + remainder, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,40 +137,36 @@ exports.distributeMonthlyPayouts = onSchedule(
|
||||
projectId: entry.projectId,
|
||||
userId: entry.userId,
|
||||
streams: entry.streams,
|
||||
share: eligibleTotalStreams
|
||||
? _.round(entry.streams / eligibleTotalStreams, 6)
|
||||
: 0,
|
||||
share: eligibleTotalStreams ? _.round(entry.streams / eligibleTotalStreams, 6) : 0,
|
||||
amount: allocations[idx],
|
||||
statsDocPath: entry.statsDocPath,
|
||||
}));
|
||||
}))
|
||||
|
||||
const userDocs = _(payouts)
|
||||
.filter((payout) => !!payout.userId)
|
||||
.groupBy("userId")
|
||||
.groupBy('userId')
|
||||
.map((projects, userId) => {
|
||||
const sortedProjects = _.orderBy(projects, ["amount"], ["desc"]).map(
|
||||
(project) => ({
|
||||
const sortedProjects = _.orderBy(projects, ['amount'], ['desc']).map((project) => ({
|
||||
projectId: project.projectId,
|
||||
rank: project.rank,
|
||||
amount: project.amount,
|
||||
streams: project.streams,
|
||||
share: project.share,
|
||||
statsDocPath: project.statsDocPath,
|
||||
}),
|
||||
);
|
||||
}))
|
||||
|
||||
return {
|
||||
userId,
|
||||
totalAmount: _.round(_.sumBy(projects, "amount"), 2),
|
||||
totalStreams: _.sumBy(projects, "streams"),
|
||||
totalAmount: _.round(_.sumBy(projects, 'amount'), 2),
|
||||
totalStreams: _.sumBy(projects, 'streams'),
|
||||
projects: sortedProjects,
|
||||
};
|
||||
}
|
||||
})
|
||||
.value();
|
||||
.value()
|
||||
|
||||
if (userDocs.length) {
|
||||
const docs = userDocs.map((userData) => {
|
||||
const docId = `${monthKey}_${userData.userId}`;
|
||||
const docId = `${monthKey}_${userData.userId}`
|
||||
return {
|
||||
ref: refList.monthlyPayoutEntries.doc(docId),
|
||||
data: {
|
||||
@@ -200,35 +179,33 @@ exports.distributeMonthlyPayouts = onSchedule(
|
||||
computedAt: now,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
await batchFirestore({
|
||||
docs,
|
||||
type: BATCH_TYPE.UPDATE,
|
||||
});
|
||||
})
|
||||
|
||||
const payoutLabel = `${String(context.month).padStart(2, "0")}/${
|
||||
context.year
|
||||
}`;
|
||||
const payoutLabel = `${String(context.month).padStart(2, '0')}/${context.year}`
|
||||
await Promise.all(
|
||||
userDocs.map(async (userData) => {
|
||||
const receiverId =
|
||||
typeof userData.userId === "string" && userData.userId.trim()
|
||||
typeof userData.userId === 'string' && userData.userId.trim()
|
||||
? userData.userId.trim()
|
||||
: null;
|
||||
const amount = Number(userData.totalAmount) || 0;
|
||||
: null
|
||||
const amount = Number(userData.totalAmount) || 0
|
||||
if (!receiverId || amount <= 0) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
const amountLabel = amount.toFixed(2);
|
||||
const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`;
|
||||
const amountLabel = amount.toFixed(2)
|
||||
const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`
|
||||
try {
|
||||
await sendNotification({
|
||||
sender: "SYSTEM",
|
||||
sender: 'SYSTEM',
|
||||
receiver: receiverId,
|
||||
receiverCollection: "users",
|
||||
title: "Revenus disponibles",
|
||||
receiverCollection: 'users',
|
||||
title: 'Revenus disponibles',
|
||||
message,
|
||||
data: {
|
||||
type: ALERT_TYPE?.PAYOUT_AVAILABLE,
|
||||
@@ -239,19 +216,16 @@ exports.distributeMonthlyPayouts = onSchedule(
|
||||
totalStreams: userData.totalStreams,
|
||||
projects: userData.projects,
|
||||
},
|
||||
});
|
||||
})
|
||||
} catch (notifError) {
|
||||
console.log(
|
||||
"[distributeMonthlyPayouts] Failed to send payout notification:",
|
||||
{
|
||||
console.log('[distributeMonthlyPayouts] Failed to send payout notification:', {
|
||||
userId: receiverId,
|
||||
error: notifError?.message || String(notifError),
|
||||
},
|
||||
);
|
||||
})
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
return null
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const summary = {
|
||||
@@ -270,14 +244,14 @@ exports.distributeMonthlyPayouts = onSchedule(
|
||||
totalRecipients: payouts.length,
|
||||
totalEntries: entries.length,
|
||||
eligibleEntries: eligibleEntries.length,
|
||||
totalAllocated: _.round(_.sumBy(payouts, "amount"), 2),
|
||||
totalAllocated: _.round(_.sumBy(payouts, 'amount'), 2),
|
||||
payouts,
|
||||
status: payouts.length ? "computed" : "no-data",
|
||||
status: payouts.length ? 'computed' : 'no-data',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
computedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
const docRef = refList.monthlyPayouts.doc(monthKey);
|
||||
await docRef.set(summary, { merge: true });
|
||||
},
|
||||
);
|
||||
const docRef = refList.monthlyPayouts.doc(monthKey)
|
||||
await docRef.set(summary, { merge: true })
|
||||
}
|
||||
)
|
||||
|
||||
+53
-64
@@ -1,75 +1,67 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const { onDocumentWritten } = require("firebase-functions/firestore");
|
||||
const _ = require("lodash");
|
||||
const { refList, db } = require("../index");
|
||||
const { getSunoTimestamps } = require("./lyrics");
|
||||
const { deleteFolder } = require("../helpers/firebase");
|
||||
const { buildMonthKey } = require("../helpers/stats");
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const { onDocumentWritten } = require('firebase-functions/firestore')
|
||||
const _ = require('lodash')
|
||||
const { refList, db } = require('../index')
|
||||
const { getSunoTimestamps } = require('./lyrics')
|
||||
const { deleteFolder } = require('../helpers/firebase')
|
||||
const { buildMonthKey } = require('../helpers/stats')
|
||||
|
||||
exports.onProjectWritten = onDocumentWritten(
|
||||
"projects/{projectId}",
|
||||
async (projectSnap) => {
|
||||
exports.onProjectWritten = onDocumentWritten('projects/{projectId}', async (projectSnap) => {
|
||||
try {
|
||||
const { projectId } = projectSnap.params;
|
||||
const beforeData = projectSnap?.data?.before?.data() || null;
|
||||
const afterData = projectSnap?.data?.after?.data() || null;
|
||||
const { projectId } = projectSnap.params
|
||||
const beforeData = projectSnap?.data?.before?.data() || null
|
||||
const afterData = projectSnap?.data?.after?.data() || null
|
||||
|
||||
if (!afterData) {
|
||||
const { userId } = beforeData || {};
|
||||
const { userId } = beforeData || {}
|
||||
|
||||
if (userId) {
|
||||
await deleteFolder(`users/${userId}/projects/${projectId}/`);
|
||||
await deleteFolder(`users/${userId}/projects/${projectId}/`)
|
||||
}
|
||||
|
||||
const snapshot = await refList.tasks
|
||||
.where("projectId", "==", projectId)
|
||||
.get();
|
||||
const snapshot = await refList.tasks.where('projectId', '==', projectId).get()
|
||||
if (!snapshot.empty) {
|
||||
const batch = db.batch();
|
||||
const batch = db.batch()
|
||||
snapshot.forEach((doc) => {
|
||||
batch.delete(doc.ref);
|
||||
});
|
||||
await batch.commit();
|
||||
batch.delete(doc.ref)
|
||||
})
|
||||
await batch.commit()
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
} else if (!beforeData) {
|
||||
//song create
|
||||
} else {
|
||||
if (!beforeData?.songUrl && afterData?.songUrl) {
|
||||
await getSunoTimestamps(projectId);
|
||||
await refList.projects.doc(projectId).update({ hasSong: true });
|
||||
await getSunoTimestamps(projectId)
|
||||
await refList.projects.doc(projectId).update({ hasSong: true })
|
||||
}
|
||||
|
||||
if (!beforeData?.playbackUrl && afterData?.playbackUrl) {
|
||||
await refList.projects.doc(projectId).update({ hasPlayback: true });
|
||||
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;
|
||||
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 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);
|
||||
.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 statsSnapshot = await transaction.get(statsDocRef)
|
||||
const totalsSnapshot = await transaction.get(totalsDocRef)
|
||||
const updatePayload = {
|
||||
projectId,
|
||||
userId,
|
||||
@@ -78,15 +70,14 @@ exports.onProjectWritten = onDocumentWritten(
|
||||
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 hasFirstStream = statsSnapshot.exists && statsSnapshot.data()?.firstStreamAt
|
||||
if (!hasFirstStream) {
|
||||
updatePayload.firstStreamAt = now
|
||||
}
|
||||
|
||||
transaction.set(statsDocRef, updatePayload, { merge: true })
|
||||
|
||||
const totalsUpdatePayload = {
|
||||
monthKey,
|
||||
@@ -94,22 +85,20 @@ exports.onProjectWritten = onDocumentWritten(
|
||||
lastStreamAt: now,
|
||||
lastDelta: delta,
|
||||
totalStreams: FieldValue.increment(delta),
|
||||
};
|
||||
const totalsHasFirstStream =
|
||||
totalsSnapshot.exists && totalsSnapshot.data()?.firstStreamAt;
|
||||
if (!totalsHasFirstStream) {
|
||||
totalsUpdatePayload.firstStreamAt = now;
|
||||
}
|
||||
transaction.set(totalsDocRef, totalsUpdatePayload, { merge: true });
|
||||
});
|
||||
const totalsHasFirstStream = totalsSnapshot.exists && totalsSnapshot.data()?.firstStreamAt
|
||||
if (!totalsHasFirstStream) {
|
||||
totalsUpdatePayload.firstStreamAt = now
|
||||
}
|
||||
transaction.set(totalsDocRef, totalsUpdatePayload, { merge: true })
|
||||
})
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
} catch (error) {
|
||||
console.log("onProjectViewsIncrement error", {
|
||||
message: error?.message || String(error || ""),
|
||||
});
|
||||
return null;
|
||||
console.log('onProjectViewsIncrement error', {
|
||||
message: error?.message || String(error || ''),
|
||||
})
|
||||
return null
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
+30
-37
@@ -1,22 +1,22 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
||||
const { refList } = require("../index");
|
||||
const firestore = refList.projects.firestore;
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const { onSchedule } = require('firebase-functions/v2/scheduler')
|
||||
const { refList } = require('../index')
|
||||
const firestore = refList.projects.firestore
|
||||
|
||||
function buildMonthContext(referenceDate) {
|
||||
const current = referenceDate ? new Date(referenceDate) : new Date();
|
||||
current.setHours(0, 0, 0, 0);
|
||||
current.setDate(1);
|
||||
const current = referenceDate ? new Date(referenceDate) : new Date()
|
||||
current.setHours(0, 0, 0, 0)
|
||||
current.setDate(1)
|
||||
|
||||
const target = new Date(current);
|
||||
target.setMonth(target.getMonth() - 1);
|
||||
const target = new Date(current)
|
||||
target.setMonth(target.getMonth() - 1)
|
||||
|
||||
const month = target.getMonth();
|
||||
const year = target.getFullYear();
|
||||
const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`;
|
||||
const rangeStart = new Date(year, month, 1, 0, 0, 0, 0);
|
||||
const rangeEnd = new Date(year, month + 1, 0, 23, 59, 59, 999);
|
||||
const month = target.getMonth()
|
||||
const year = target.getFullYear()
|
||||
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`
|
||||
const rangeStart = new Date(year, month, 1, 0, 0, 0, 0)
|
||||
const rangeEnd = new Date(year, month + 1, 0, 23, 59, 59, 999)
|
||||
|
||||
return {
|
||||
year,
|
||||
@@ -24,27 +24,22 @@ function buildMonthContext(referenceDate) {
|
||||
monthKey,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
exports.snapshotMonthlyTopSongs = onSchedule(
|
||||
{
|
||||
schedule: "5 0 1 * *",
|
||||
timeZone: "Europe/Paris",
|
||||
schedule: '5 0 1 * *',
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
async (event) => {
|
||||
const { scheduleTime } = event;
|
||||
const context = buildMonthContext(
|
||||
scheduleTime ? new Date(scheduleTime) : new Date()
|
||||
);
|
||||
const { scheduleTime } = event
|
||||
const context = buildMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
|
||||
|
||||
const topProjectsSnap = await refList.projects
|
||||
.orderBy("views", "desc")
|
||||
.limit(3)
|
||||
.get();
|
||||
const topProjectsSnap = await refList.projects.orderBy('views', 'desc').limit(3).get()
|
||||
|
||||
const topProjects = topProjectsSnap.docs.map((doc, index) => {
|
||||
const data = doc.data() || {};
|
||||
const data = doc.data() || {}
|
||||
return {
|
||||
rank: index + 1,
|
||||
projectId: doc.id,
|
||||
@@ -54,14 +49,12 @@ exports.snapshotMonthlyTopSongs = onSchedule(
|
||||
coverUrl: data.coverUrl || null,
|
||||
songUrl: data.songUrl || null,
|
||||
views: data.views || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
const docRef = firestore
|
||||
.collection("monthlyTopSongs")
|
||||
.doc(context.monthKey);
|
||||
const docRef = firestore.collection('monthlyTopSongs').doc(context.monthKey)
|
||||
|
||||
const existingSnapshot = await docRef.get();
|
||||
const existingSnapshot = await docRef.get()
|
||||
const payload = {
|
||||
monthKey: context.monthKey,
|
||||
month: context.month,
|
||||
@@ -73,12 +66,12 @@ exports.snapshotMonthlyTopSongs = onSchedule(
|
||||
topProjects,
|
||||
totalProjects: topProjects.length,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!existingSnapshot.exists) {
|
||||
payload.createdAt = FieldValue.serverTimestamp();
|
||||
payload.createdAt = FieldValue.serverTimestamp()
|
||||
}
|
||||
|
||||
await docRef.set(payload, { merge: true });
|
||||
await docRef.set(payload, { merge: true })
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
+183
-260
@@ -1,15 +1,15 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { HttpsError, onCall } = require("firebase-functions/https");
|
||||
const admin = require('firebase-admin')
|
||||
const { HttpsError, onCall } = require('firebase-functions/https')
|
||||
|
||||
let FieldValue = null;
|
||||
let FieldValue = null
|
||||
try {
|
||||
({ FieldValue } = require("firebase-admin/firestore"));
|
||||
;({ FieldValue } = require('firebase-admin/firestore'))
|
||||
} catch (error) {
|
||||
console.warn("[stripe] FieldValue import failed", error?.message);
|
||||
console.warn('[stripe] FieldValue import failed', error?.message)
|
||||
}
|
||||
|
||||
const { REGION, refsList } = require("../index");
|
||||
const STRIPE_MODE = "test";
|
||||
const { REGION, refsList } = require('../index')
|
||||
const STRIPE_MODE = 'test'
|
||||
const {
|
||||
getStripeClient,
|
||||
getReturnUrls,
|
||||
@@ -19,23 +19,20 @@ const {
|
||||
formatCheckoutSessionResponse,
|
||||
getPortalConfigurationId,
|
||||
mapStripeErrorToHttps,
|
||||
} = require("../helpers/stripe");
|
||||
} = require('../helpers/stripe')
|
||||
|
||||
const paymentsCollection = admin.firestore().collection("payments");
|
||||
const paymentsCollection = admin.firestore().collection('payments')
|
||||
|
||||
const getServerTimestamp = () => {
|
||||
if (FieldValue?.serverTimestamp) {
|
||||
return FieldValue.serverTimestamp();
|
||||
return FieldValue.serverTimestamp()
|
||||
}
|
||||
const fallback = admin.firestore?.FieldValue;
|
||||
const fallback = admin.firestore?.FieldValue
|
||||
if (fallback?.serverTimestamp) {
|
||||
return fallback.serverTimestamp();
|
||||
return fallback.serverTimestamp()
|
||||
}
|
||||
throw new HttpsError('failed-precondition', 'Firestore FieldValue.serverTimestamp indisponible.')
|
||||
}
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"Firestore FieldValue.serverTimestamp indisponible.",
|
||||
);
|
||||
};
|
||||
|
||||
const normalizePaymentIntent = (paymentIntent) => ({
|
||||
id: paymentIntent.id,
|
||||
@@ -48,53 +45,44 @@ const normalizePaymentIntent = (paymentIntent) => ({
|
||||
created: paymentIntent.created,
|
||||
latest_charge: paymentIntent.latest_charge,
|
||||
metadata: paymentIntent.metadata,
|
||||
});
|
||||
})
|
||||
|
||||
const createCheckoutSession = onCall({ region: REGION }, async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
const uid = request?.auth?.uid
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour créer une session Stripe.",
|
||||
);
|
||||
throw new HttpsError('unauthenticated', 'Connecte-toi pour créer une session Stripe.')
|
||||
}
|
||||
|
||||
const requestedUserId =
|
||||
typeof request?.data?.userID === "string"
|
||||
? request.data.userID.trim()
|
||||
: null;
|
||||
typeof request?.data?.userID === 'string' ? request.data.userID.trim() : null
|
||||
|
||||
if (requestedUserId && requestedUserId !== uid) {
|
||||
throw new HttpsError(
|
||||
"permission-denied",
|
||||
"Tu ne peux créer une session que pour ton propre compte.",
|
||||
);
|
||||
'permission-denied',
|
||||
'Tu ne peux créer une session que pour ton propre compte.'
|
||||
)
|
||||
}
|
||||
|
||||
const productList = Array.isArray(request?.data?.productList)
|
||||
? request.data.productList
|
||||
: [];
|
||||
const productList = Array.isArray(request?.data?.productList) ? request.data.productList : []
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const { lineItems, summary, hasSubscription } =
|
||||
await buildCheckoutLineItems(productList, { stripe });
|
||||
const stripe = getStripeClient()
|
||||
const { lineItems, summary, hasSubscription } = await buildCheckoutLineItems(productList, {
|
||||
stripe,
|
||||
})
|
||||
|
||||
const mode = hasSubscription ? "subscription" : "payment";
|
||||
const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls);
|
||||
const mode = hasSubscription ? 'subscription' : 'payment'
|
||||
const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls)
|
||||
|
||||
const { customerId } = await ensureStripeCustomer({
|
||||
uid,
|
||||
stripe,
|
||||
refsList,
|
||||
createIfMissing: true,
|
||||
});
|
||||
})
|
||||
|
||||
if (!customerId) {
|
||||
throw new HttpsError(
|
||||
"internal",
|
||||
"Impossible de retrouver le client Stripe associé.",
|
||||
);
|
||||
throw new HttpsError('internal', 'Impossible de retrouver le client Stripe associé.')
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
@@ -107,12 +95,12 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => {
|
||||
metadata: {
|
||||
firebaseUID: uid,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
await paymentsCollection.doc(session.id).set({
|
||||
userId: uid,
|
||||
customerId,
|
||||
status: session.status || "created",
|
||||
status: session.status || 'created',
|
||||
mode,
|
||||
createdAt: getServerTimestamp(),
|
||||
updatedAt: getServerTimestamp(),
|
||||
@@ -123,59 +111,53 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => {
|
||||
amountTotal: session.amount_total,
|
||||
currency: session.currency,
|
||||
paymentStatus: session.payment_status,
|
||||
});
|
||||
})
|
||||
|
||||
return formatCheckoutSessionResponse(session);
|
||||
return formatCheckoutSessionResponse(session)
|
||||
} catch (error) {
|
||||
console.error("[createCheckoutSession] error", error);
|
||||
console.error('[createCheckoutSession] error', error)
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Création de la session Stripe impossible.",
|
||||
);
|
||||
throw mapStripeErrorToHttps(error, 'Création de la session Stripe impossible.')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const getPremiumStatus = onCall({ region: REGION }, async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
const uid = request?.auth?.uid
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour consulter ton statut Stripe.",
|
||||
);
|
||||
throw new HttpsError('unauthenticated', 'Connecte-toi pour consulter ton statut Stripe.')
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const stripe = getStripeClient()
|
||||
const { customerId } = await ensureStripeCustomer({
|
||||
uid,
|
||||
stripe,
|
||||
refsList,
|
||||
createIfMissing: false,
|
||||
});
|
||||
})
|
||||
|
||||
if (!customerId) {
|
||||
return {
|
||||
customerId: null,
|
||||
subscriptions: [],
|
||||
invoices: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const [subscriptions, invoices] = await Promise.all([
|
||||
stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: "all",
|
||||
expand: ["data.items.data.price"],
|
||||
status: 'all',
|
||||
expand: ['data.items.data.price'],
|
||||
limit: 20,
|
||||
}),
|
||||
stripe.invoices.list({
|
||||
customer: customerId,
|
||||
limit: 20,
|
||||
}),
|
||||
]);
|
||||
])
|
||||
|
||||
const formattedSubscriptions = subscriptions.data.map((subscription) => ({
|
||||
id: subscription.id,
|
||||
@@ -193,7 +175,7 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => {
|
||||
currency: item.price?.currency,
|
||||
recurring: item.price?.recurring,
|
||||
})),
|
||||
}));
|
||||
}))
|
||||
|
||||
const formattedInvoices = invoices.data.map((invoice) => ({
|
||||
id: invoice.id,
|
||||
@@ -205,179 +187,153 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => {
|
||||
hosted_invoice_url: invoice.hosted_invoice_url,
|
||||
invoice_pdf: invoice.invoice_pdf,
|
||||
created: invoice.created,
|
||||
}));
|
||||
}))
|
||||
|
||||
return {
|
||||
customerId,
|
||||
subscriptions: formattedSubscriptions,
|
||||
invoices: formattedInvoices,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[getPremiumStatus] error", error);
|
||||
console.error('[getPremiumStatus] error', error)
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Impossible de récupérer le statut Stripe.",
|
||||
);
|
||||
throw mapStripeErrorToHttps(error, 'Impossible de récupérer le statut Stripe.')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const createStripeCustomerPortalSession = onCall(
|
||||
{ region: REGION },
|
||||
async (request) => {
|
||||
const createStripeCustomerPortalSession = onCall({ region: REGION }, async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
const uid = request?.auth?.uid
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour ouvrir le portail client Stripe.",
|
||||
);
|
||||
throw new HttpsError('unauthenticated', 'Connecte-toi pour ouvrir le portail client Stripe.')
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
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.",
|
||||
);
|
||||
throw new HttpsError('failed-precondition', 'Aucun client Stripe associé à cet utilisateur.')
|
||||
}
|
||||
|
||||
const portalConfigurationId = await getPortalConfigurationId(stripe);
|
||||
const portalConfigurationId = await getPortalConfigurationId(stripe)
|
||||
if (!portalConfigurationId) {
|
||||
const modeLabel = STRIPE_MODE === "prod" ? "production" : "test";
|
||||
const envKeySuffix = STRIPE_MODE === "prod" ? "PROD" : "TEST";
|
||||
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}.`,
|
||||
);
|
||||
'failed-precondition',
|
||||
`Configure le portail client Stripe en mode ${modeLabel} ou renseigne STRIPE_PORTAL_CONFIGURATION_${envKeySuffix}.`
|
||||
)
|
||||
}
|
||||
|
||||
const { successUrl } = getReturnUrls();
|
||||
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);
|
||||
console.error('[createStripeCustomerPortalSession] error', error)
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Ouverture du portail Stripe impossible.",
|
||||
);
|
||||
throw mapStripeErrorToHttps(error, 'Ouverture du portail Stripe impossible.')
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
const resolveStripeConnectAccountId = (userData) => {
|
||||
if (!userData || typeof userData !== "object") {
|
||||
return null;
|
||||
if (!userData || typeof userData !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidatePaths = [
|
||||
["stripeConnectAccountId"],
|
||||
["stripeConnectId"],
|
||||
["stripeAccountId"],
|
||||
["stripeConnectAccount"],
|
||||
["stripeAccount"],
|
||||
["stripe", "connectAccountId"],
|
||||
["stripe", "accountId"],
|
||||
["providers", "stripeConnect", "accountId"],
|
||||
];
|
||||
['stripeConnectAccountId'],
|
||||
['stripeConnectId'],
|
||||
['stripeAccountId'],
|
||||
['stripeConnectAccount'],
|
||||
['stripeAccount'],
|
||||
['stripe', 'connectAccountId'],
|
||||
['stripe', 'accountId'],
|
||||
['providers', 'stripeConnect', 'accountId'],
|
||||
]
|
||||
|
||||
for (const path of candidatePaths) {
|
||||
let current = userData;
|
||||
let current = userData
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object") {
|
||||
current = null;
|
||||
break;
|
||||
if (!current || typeof current !== 'object') {
|
||||
current = null
|
||||
break
|
||||
}
|
||||
current = current[key];
|
||||
current = current[key]
|
||||
}
|
||||
|
||||
if (typeof current === "string" && current.trim()) {
|
||||
return current.trim();
|
||||
if (typeof current === 'string' && current.trim()) {
|
||||
return current.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
const ensureStripeConnectAccount = async ({
|
||||
uid,
|
||||
stripe,
|
||||
userRef,
|
||||
userData,
|
||||
}) => {
|
||||
const ensureStripeConnectAccount = async ({ uid, stripe, userRef, userData }) => {
|
||||
if (!uid || !stripe) {
|
||||
return { connectAccountId: null, userData, createdAccount: false };
|
||||
return { connectAccountId: null, userData, createdAccount: false }
|
||||
}
|
||||
|
||||
let connectAccountId = resolveStripeConnectAccountId(userData);
|
||||
let connectAccountId = resolveStripeConnectAccountId(userData)
|
||||
if (connectAccountId) {
|
||||
return { connectAccountId, userData, createdAccount: false };
|
||||
return { connectAccountId, userData, createdAccount: false }
|
||||
}
|
||||
|
||||
let authRecord = null;
|
||||
let authRecord = null
|
||||
try {
|
||||
authRecord = await admin.auth().getUser(uid);
|
||||
authRecord = await admin.auth().getUser(uid)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[ensureStripeConnectAccount] Impossible de récupérer auth user",
|
||||
error,
|
||||
);
|
||||
console.warn('[ensureStripeConnectAccount] Impossible de récupérer auth user', error)
|
||||
}
|
||||
|
||||
const email = userData?.email || authRecord?.email || undefined;
|
||||
const email = userData?.email || authRecord?.email || undefined
|
||||
|
||||
const accountParams = {
|
||||
type: "express",
|
||||
type: 'express',
|
||||
capabilities: {
|
||||
card_payments: { requested: true },
|
||||
transfers: { requested: true },
|
||||
},
|
||||
metadata: {
|
||||
firebaseUID: uid,
|
||||
appMode: STRIPE_MODE || "test",
|
||||
appMode: STRIPE_MODE || 'test',
|
||||
},
|
||||
};
|
||||
|
||||
if (email) {
|
||||
accountParams.email = email;
|
||||
}
|
||||
|
||||
const account = await stripe.accounts.create(accountParams);
|
||||
connectAccountId = account?.id;
|
||||
if (email) {
|
||||
accountParams.email = email
|
||||
}
|
||||
|
||||
const account = await stripe.accounts.create(accountParams)
|
||||
connectAccountId = account?.id
|
||||
|
||||
if (!connectAccountId) {
|
||||
throw new HttpsError(
|
||||
"internal",
|
||||
"Stripe n’a pas renvoyé d’identifiant de compte Connect.",
|
||||
);
|
||||
throw new HttpsError('internal', 'Stripe n’a pas renvoyé d’identifiant de compte Connect.')
|
||||
}
|
||||
|
||||
const providersConnect = {
|
||||
...(userData?.providers?.stripeConnect || {}),
|
||||
accountId: connectAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
if (userRef) {
|
||||
await userRef.set(
|
||||
@@ -391,8 +347,8 @@ const ensureStripeConnectAccount = async ({
|
||||
},
|
||||
},
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
{ merge: true }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -406,44 +362,39 @@ const ensureStripeConnectAccount = async ({
|
||||
},
|
||||
},
|
||||
createdAccount: true,
|
||||
};
|
||||
};
|
||||
|
||||
const createStripeConnectLoginLink = onCall(
|
||||
{ region: REGION },
|
||||
async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour ouvrir Stripe Connect.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userRef = refsList?.users?.doc(uid);
|
||||
const snapshot = userRef ? await userRef.get() : null;
|
||||
let userData = snapshot?.exists ? snapshot.data() : null;
|
||||
const createStripeConnectLoginLink = onCall({ region: REGION }, async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid
|
||||
if (!uid) {
|
||||
throw new HttpsError('unauthenticated', 'Connecte-toi pour ouvrir Stripe Connect.')
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
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;
|
||||
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.",
|
||||
);
|
||||
'failed-precondition',
|
||||
'Impossible de créer un compte Stripe Connect pour cet utilisateur.'
|
||||
)
|
||||
}
|
||||
|
||||
const redirectUrl = getReturnBaseUrl();
|
||||
const redirectUrl = getReturnBaseUrl()
|
||||
|
||||
const loginLink = await stripe.accounts.createLoginLink(
|
||||
connectAccountId,
|
||||
@@ -451,14 +402,11 @@ const createStripeConnectLoginLink = onCall(
|
||||
? {
|
||||
redirect_url: redirectUrl,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
: undefined
|
||||
)
|
||||
|
||||
if (!loginLink?.url) {
|
||||
throw new HttpsError(
|
||||
"internal",
|
||||
"Stripe Connect n’a pas renvoyé de lien de connexion.",
|
||||
);
|
||||
throw new HttpsError('internal', 'Stripe Connect n’a pas renvoyé de lien de connexion.')
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -467,109 +415,87 @@ const createStripeConnectLoginLink = onCall(
|
||||
created: loginLink.created,
|
||||
connectAccountId,
|
||||
createdAccount: ensureResult.createdAccount,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[createStripeConnectLoginLink] error", error);
|
||||
console.error('[createStripeConnectLoginLink] error', error)
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Ouverture de Stripe Connect impossible.",
|
||||
);
|
||||
throw mapStripeErrorToHttps(error, 'Ouverture de Stripe Connect impossible.')
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
const verifyStripePayment = onCall({ region: REGION }, async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
const uid = request?.auth?.uid
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour vérifier un paiement Stripe.",
|
||||
);
|
||||
throw new HttpsError('unauthenticated', 'Connecte-toi pour vérifier un paiement Stripe.')
|
||||
}
|
||||
|
||||
const rawPaymentId =
|
||||
typeof request?.data?.paymentId === "string"
|
||||
? request.data.paymentId.trim()
|
||||
: "";
|
||||
typeof request?.data?.paymentId === 'string' ? request.data.paymentId.trim() : ''
|
||||
|
||||
if (!rawPaymentId) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
"Fournis un identifiant de paiement Stripe.",
|
||||
);
|
||||
throw new HttpsError('invalid-argument', 'Fournis un identifiant de paiement Stripe.')
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const stripe = getStripeClient()
|
||||
|
||||
const fetchPaymentIntent = async (paymentIntentId) =>
|
||||
stripe.paymentIntents.retrieve(paymentIntentId, {
|
||||
expand: ["latest_charge"],
|
||||
});
|
||||
expand: ['latest_charge'],
|
||||
})
|
||||
|
||||
const fetchCheckoutSession = async (sessionId) =>
|
||||
stripe.checkout.sessions.retrieve(sessionId, {
|
||||
expand: ["payment_intent"],
|
||||
});
|
||||
expand: ['payment_intent'],
|
||||
})
|
||||
|
||||
let paymentIntent = null;
|
||||
let checkoutSession = null;
|
||||
let paymentType = null;
|
||||
let paymentIntent = null
|
||||
let checkoutSession = null
|
||||
let paymentType = null
|
||||
|
||||
if (rawPaymentId.startsWith("pi_")) {
|
||||
paymentIntent = await fetchPaymentIntent(rawPaymentId);
|
||||
paymentType = "payment_intent";
|
||||
} else if (rawPaymentId.startsWith("cs_")) {
|
||||
checkoutSession = await fetchCheckoutSession(rawPaymentId);
|
||||
if (rawPaymentId.startsWith('pi_')) {
|
||||
paymentIntent = await fetchPaymentIntent(rawPaymentId)
|
||||
paymentType = 'payment_intent'
|
||||
} else if (rawPaymentId.startsWith('cs_')) {
|
||||
checkoutSession = await fetchCheckoutSession(rawPaymentId)
|
||||
paymentIntent =
|
||||
checkoutSession?.payment_intent &&
|
||||
typeof checkoutSession.payment_intent === "object"
|
||||
checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object'
|
||||
? checkoutSession.payment_intent
|
||||
: checkoutSession?.payment_intent
|
||||
? await fetchPaymentIntent(checkoutSession.payment_intent)
|
||||
: null;
|
||||
paymentType = "checkout_session";
|
||||
: null
|
||||
paymentType = 'checkout_session'
|
||||
} else {
|
||||
try {
|
||||
paymentIntent = await fetchPaymentIntent(rawPaymentId);
|
||||
paymentType = "payment_intent";
|
||||
paymentIntent = await fetchPaymentIntent(rawPaymentId)
|
||||
paymentType = 'payment_intent'
|
||||
} catch (intentError) {
|
||||
try {
|
||||
checkoutSession = await fetchCheckoutSession(rawPaymentId);
|
||||
paymentType = "checkout_session";
|
||||
checkoutSession = await fetchCheckoutSession(rawPaymentId)
|
||||
paymentType = 'checkout_session'
|
||||
paymentIntent =
|
||||
checkoutSession?.payment_intent &&
|
||||
typeof checkoutSession.payment_intent === "object"
|
||||
checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object'
|
||||
? checkoutSession.payment_intent
|
||||
: checkoutSession?.payment_intent
|
||||
? await fetchPaymentIntent(checkoutSession.payment_intent)
|
||||
: null;
|
||||
: null
|
||||
} catch (sessionError) {
|
||||
console.error("[verifyStripePayment] lookup failure", {
|
||||
console.error('[verifyStripePayment] lookup failure', {
|
||||
intentError,
|
||||
sessionError,
|
||||
});
|
||||
throw new HttpsError(
|
||||
"not-found",
|
||||
"Aucun paiement Stripe trouvé avec cet identifiant.",
|
||||
);
|
||||
})
|
||||
throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!paymentIntent && !checkoutSession) {
|
||||
throw new HttpsError(
|
||||
"not-found",
|
||||
"Aucun paiement Stripe trouvé avec cet identifiant.",
|
||||
);
|
||||
throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.')
|
||||
}
|
||||
|
||||
const normalizedIntent = paymentIntent
|
||||
? normalizePaymentIntent(paymentIntent)
|
||||
: null;
|
||||
const normalizedIntent = paymentIntent ? normalizePaymentIntent(paymentIntent) : null
|
||||
|
||||
const response = {
|
||||
type: paymentType,
|
||||
@@ -594,41 +520,38 @@ const verifyStripePayment = onCall({ region: REGION }, async (request) => {
|
||||
checkoutSession?.status ??
|
||||
null,
|
||||
currency: normalizedIntent?.currency ?? checkoutSession?.currency ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const paymentDocId =
|
||||
paymentType === "checkout_session"
|
||||
? checkoutSession?.id
|
||||
: normalizedIntent?.id;
|
||||
paymentType === 'checkout_session' ? checkoutSession?.id : normalizedIntent?.id
|
||||
|
||||
if (paymentDocId) {
|
||||
const paymentDocRef = paymentsCollection.doc(paymentDocId);
|
||||
const existing = await paymentDocRef.get();
|
||||
const paymentDocRef = paymentsCollection.doc(paymentDocId)
|
||||
const existing = await paymentDocRef.get()
|
||||
if (existing.exists) {
|
||||
await paymentDocRef.set(
|
||||
{
|
||||
status: response.status,
|
||||
paymentStatus: checkoutSession?.payment_status,
|
||||
amountTotal:
|
||||
checkoutSession?.amount_total ?? normalizedIntent?.amount,
|
||||
amountTotal: checkoutSession?.amount_total ?? normalizedIntent?.amount,
|
||||
amountReceived: normalizedIntent?.amount_received,
|
||||
currency: response.currency,
|
||||
updatedAt: getServerTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
{ merge: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[verifyStripePayment] error", error);
|
||||
console.error('[verifyStripePayment] error', error)
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
throw mapStripeErrorToHttps(error, "Vérification du paiement impossible.");
|
||||
throw mapStripeErrorToHttps(error, 'Vérification du paiement impossible.')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
createCheckoutSession,
|
||||
@@ -636,4 +559,4 @@ module.exports = {
|
||||
createStripeCustomerPortalSession,
|
||||
createStripeConnectLoginLink,
|
||||
verifyStripePayment,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
const { HttpsError, onCall } = require("firebase-functions/https");
|
||||
const { HttpsError, onCall } = require('firebase-functions/https')
|
||||
|
||||
const { getStripeClient, mapStripeErrorToHttps } = require("../../helpers/stripe");
|
||||
const { REGION } = require("./config");
|
||||
const { getStripeClient, mapStripeErrorToHttps } = require('../../helpers/stripe')
|
||||
const { REGION } = require('./config')
|
||||
const {
|
||||
SUBSCRIPTION_PRICE_IDS,
|
||||
SUBSCRIPTION_PRICE_METADATA,
|
||||
COIN_PACK_PRODUCTS,
|
||||
} = require("./constants");
|
||||
const { parseCoinsPerMonth, formatCoinPack } = require("./shared");
|
||||
} = require('./constants')
|
||||
const { parseCoinsPerMonth, formatCoinPack } = require('./shared')
|
||||
|
||||
const formatPlan = (price, priceId) => {
|
||||
if (!price || typeof price !== "object") {
|
||||
return null;
|
||||
if (!price || typeof price !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const product =
|
||||
typeof price.product === "object" && price.product !== null
|
||||
? price.product
|
||||
: {};
|
||||
const product = typeof price.product === 'object' && price.product !== null ? price.product : {}
|
||||
|
||||
return {
|
||||
id: price.id || priceId,
|
||||
priceId: price.id || priceId,
|
||||
active: price.active !== false,
|
||||
currency: price.currency || "eur",
|
||||
currency: price.currency || 'eur',
|
||||
unitAmount: price.unit_amount,
|
||||
unitAmountDecimal: price.unit_amount_decimal,
|
||||
transformQuantity: price.transform_quantity || null,
|
||||
@@ -36,16 +33,16 @@ const formatPlan = (price, priceId) => {
|
||||
metadata: price.metadata || {},
|
||||
product: {
|
||||
id: product.id || null,
|
||||
name: product.name || "",
|
||||
description: product.description || "",
|
||||
name: product.name || '',
|
||||
description: product.description || '',
|
||||
metadata: product.metadata || {},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const listSubscriptionPlans = onCall({ region: REGION }, async () => {
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const stripe = getStripeClient()
|
||||
|
||||
const entries = await Promise.all(
|
||||
Object.entries(SUBSCRIPTION_PRICE_IDS).map(async ([period, priceIds]) => {
|
||||
@@ -53,88 +50,79 @@ const listSubscriptionPlans = onCall({ region: REGION }, async () => {
|
||||
priceIds.map(async (priceId) => {
|
||||
try {
|
||||
const price = await stripe.prices.retrieve(priceId, {
|
||||
expand: ["product"],
|
||||
});
|
||||
return formatPlan(price, priceId);
|
||||
expand: ['product'],
|
||||
})
|
||||
return formatPlan(price, priceId)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[subscription-listSubscriptionPlans] Impossible de récupérer ${priceId}`,
|
||||
error?.message || error,
|
||||
);
|
||||
return null;
|
||||
error?.message || error
|
||||
)
|
||||
return null
|
||||
}
|
||||
}),
|
||||
);
|
||||
})
|
||||
)
|
||||
|
||||
return [period, periodPlans.filter(Boolean)];
|
||||
}),
|
||||
);
|
||||
return [period, periodPlans.filter(Boolean)]
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
plans: Object.fromEntries(entries),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[subscription-listSubscriptionPlans] error", error);
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Impossible de récupérer les abonnements Stripe.",
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[subscription-listSubscriptionPlans] error', error)
|
||||
throw mapStripeErrorToHttps(error, 'Impossible de récupérer les abonnements Stripe.')
|
||||
}
|
||||
})
|
||||
|
||||
const listCoinPacks = onCall({ region: REGION }, async () => {
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const stripe = getStripeClient()
|
||||
|
||||
const packs = await Promise.all(
|
||||
COIN_PACK_PRODUCTS.map(async (pack) => {
|
||||
try {
|
||||
const product = await stripe.products.retrieve(pack.productId, {
|
||||
expand: ["default_price"],
|
||||
});
|
||||
expand: ['default_price'],
|
||||
})
|
||||
|
||||
let resolvedPrice = null;
|
||||
if (typeof product?.default_price === "string") {
|
||||
resolvedPrice = await stripe.prices.retrieve(product.default_price);
|
||||
} else if (
|
||||
product?.default_price &&
|
||||
typeof product.default_price === "object"
|
||||
) {
|
||||
resolvedPrice = product.default_price;
|
||||
let resolvedPrice = null
|
||||
if (typeof product?.default_price === 'string') {
|
||||
resolvedPrice = await stripe.prices.retrieve(product.default_price)
|
||||
} else if (product?.default_price && typeof product.default_price === 'object') {
|
||||
resolvedPrice = product.default_price
|
||||
}
|
||||
|
||||
const formatted = formatCoinPack({
|
||||
product,
|
||||
price: resolvedPrice,
|
||||
});
|
||||
})
|
||||
return {
|
||||
...formatted,
|
||||
coinPackKey: pack.key || null,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[subscription-listCoinPacks] Unable to retrieve product",
|
||||
'[subscription-listCoinPacks] Unable to retrieve product',
|
||||
pack.productId,
|
||||
error?.message || error,
|
||||
);
|
||||
return null;
|
||||
error?.message || error
|
||||
)
|
||||
return null
|
||||
}
|
||||
}),
|
||||
);
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
packs: packs.filter(Boolean),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[subscription-listCoinPacks] error", error);
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Impossible de récupérer les packs de pièces.",
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[subscription-listCoinPacks] error', error)
|
||||
throw mapStripeErrorToHttps(error, 'Impossible de récupérer les packs de pièces.')
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
listSubscriptionPlans,
|
||||
listCoinPacks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { HttpsError, onCall } = require("firebase-functions/https");
|
||||
const { HttpsError, onCall } = require('firebase-functions/https')
|
||||
|
||||
const {
|
||||
getStripeClient,
|
||||
@@ -7,101 +7,86 @@ const {
|
||||
getReturnUrls,
|
||||
formatCheckoutSessionResponse,
|
||||
mapStripeErrorToHttps,
|
||||
} = require("../../helpers/stripe");
|
||||
const { REGION } = require("./config");
|
||||
} = require('../../helpers/stripe')
|
||||
const { REGION } = require('./config')
|
||||
const {
|
||||
ALL_SUBSCRIPTION_PRICE_IDS,
|
||||
COIN_PACK_PRODUCT_IDS,
|
||||
COIN_PACK_PRODUCT_MAP,
|
||||
} = require("./constants");
|
||||
const {
|
||||
refsList,
|
||||
formatCoinPack,
|
||||
getSubscriptionMetaFromPrice,
|
||||
} = require("./shared");
|
||||
} = require('./constants')
|
||||
const { refsList, formatCoinPack, getSubscriptionMetaFromPrice } = require('./shared')
|
||||
|
||||
const CHECKOUT_UI_MODES = new Set(["hosted", "embedded"]);
|
||||
const CHECKOUT_UI_MODES = new Set(['hosted', 'embedded'])
|
||||
|
||||
const sanitizePriceId = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
if (typeof value !== 'string') {
|
||||
return ''
|
||||
}
|
||||
return value.trim()
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const resolveCheckoutUiMode = (request) => {
|
||||
if (!request || !request.data || typeof request.data.uiMode === "undefined") {
|
||||
return "hosted";
|
||||
if (!request || !request.data || typeof request.data.uiMode === 'undefined') {
|
||||
return 'hosted'
|
||||
}
|
||||
|
||||
if (typeof request.data.uiMode !== "string") {
|
||||
if (typeof request.data.uiMode !== 'string') {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").',
|
||||
);
|
||||
'invalid-argument',
|
||||
'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").'
|
||||
)
|
||||
}
|
||||
|
||||
const normalizedUiMode = request.data.uiMode.trim().toLowerCase();
|
||||
const normalizedUiMode = request.data.uiMode.trim().toLowerCase()
|
||||
if (!CHECKOUT_UI_MODES.has(normalizedUiMode)) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
`uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`,
|
||||
);
|
||||
'invalid-argument',
|
||||
`uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`
|
||||
)
|
||||
}
|
||||
|
||||
return normalizedUiMode;
|
||||
};
|
||||
return normalizedUiMode
|
||||
}
|
||||
|
||||
const withCheckoutNavigationParams = (
|
||||
baseParams,
|
||||
{ uiMode, successUrl, cancelUrl },
|
||||
) => {
|
||||
if (uiMode === "embedded") {
|
||||
const withCheckoutNavigationParams = (baseParams, { uiMode, successUrl, cancelUrl }) => {
|
||||
if (uiMode === 'embedded') {
|
||||
return {
|
||||
...baseParams,
|
||||
ui_mode: "embedded",
|
||||
ui_mode: 'embedded',
|
||||
return_url: undefined,
|
||||
redirect_on_completion: "never",
|
||||
};
|
||||
redirect_on_completion: 'never',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...baseParams,
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
};
|
||||
};
|
||||
|
||||
const createSubscriptionCheckoutSession = onCall(
|
||||
{ region: REGION },
|
||||
async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour souscrire un abonnement.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rawPriceId = request?.data?.priceId;
|
||||
const priceId = sanitizePriceId(rawPriceId);
|
||||
const createSubscriptionCheckoutSession = onCall({ region: REGION }, async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid
|
||||
if (!uid) {
|
||||
throw new HttpsError('unauthenticated', 'Connecte-toi pour souscrire un abonnement.')
|
||||
}
|
||||
|
||||
const rawPriceId = request?.data?.priceId
|
||||
const priceId = sanitizePriceId(rawPriceId)
|
||||
if (!priceId) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
"Un identifiant de prix Stripe est requis.",
|
||||
);
|
||||
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.`,
|
||||
);
|
||||
'invalid-argument',
|
||||
`L'identifiant de prix ${priceId} n'est pas pris en charge.`
|
||||
)
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
|
||||
const stripe = getStripeClient()
|
||||
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
|
||||
|
||||
const { lineItems, summary } = await buildCheckoutLineItems(
|
||||
[
|
||||
@@ -111,40 +96,40 @@ const createSubscriptionCheckoutSession = onCall(
|
||||
isRenewable: true,
|
||||
},
|
||||
],
|
||||
{ stripe },
|
||||
);
|
||||
{ stripe }
|
||||
)
|
||||
|
||||
const uiMode = resolveCheckoutUiMode(request);
|
||||
const shouldProvideReturnUrls = uiMode !== "embedded";
|
||||
const uiMode = resolveCheckoutUiMode(request)
|
||||
const shouldProvideReturnUrls = uiMode !== 'embedded'
|
||||
const { successUrl, cancelUrl } = shouldProvideReturnUrls
|
||||
? getReturnUrls(request?.data?.returnUrls)
|
||||
: { successUrl: null, cancelUrl: null };
|
||||
: { 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é.",
|
||||
);
|
||||
'failed-precondition',
|
||||
'Impossible de retrouver le client Stripe associé.'
|
||||
)
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create(
|
||||
withCheckoutNavigationParams(
|
||||
{
|
||||
mode: "subscription",
|
||||
mode: 'subscription',
|
||||
customer: customerId,
|
||||
line_items: lineItems,
|
||||
allow_promotion_codes: true,
|
||||
metadata: {
|
||||
firebaseUID: uid,
|
||||
priceId,
|
||||
purchaseType: "SUBSCRIPTION",
|
||||
purchaseType: 'SUBSCRIPTION',
|
||||
subscriptionLevel: priceMeta.level || null,
|
||||
subscriptionBillingPeriod: priceMeta.billingPeriod || null,
|
||||
},
|
||||
@@ -153,122 +138,103 @@ const createSubscriptionCheckoutSession = onCall(
|
||||
uiMode,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return formatCheckoutSessionResponse(session);
|
||||
return formatCheckoutSessionResponse(session)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[subscription-createSubscriptionCheckoutSession] error",
|
||||
error,
|
||||
);
|
||||
console.error('[subscription-createSubscriptionCheckoutSession] error', error)
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Impossible de créer la session d'abonnement Stripe.",
|
||||
);
|
||||
throw mapStripeErrorToHttps(error, "Impossible de créer la session d'abonnement Stripe.")
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
const createCoinPackCheckoutSession = onCall(
|
||||
{ region: REGION },
|
||||
async (request) => {
|
||||
const createCoinPackCheckoutSession = onCall({ region: REGION }, async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
const uid = request?.auth?.uid
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour acheter un pack de pièces.",
|
||||
);
|
||||
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() : "";
|
||||
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.",
|
||||
);
|
||||
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é`,
|
||||
);
|
||||
'invalid-argument',
|
||||
`Le produit ${productId} n'est pas un pack de pièces autorisé`
|
||||
)
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const stripe = getStripeClient()
|
||||
|
||||
const product = await stripe.products.retrieve(productId, {
|
||||
expand: ["default_price"],
|
||||
});
|
||||
expand: ['default_price'],
|
||||
})
|
||||
|
||||
let resolvedPrice = null;
|
||||
if (typeof product?.default_price === "string") {
|
||||
resolvedPrice = await stripe.prices.retrieve(product.default_price);
|
||||
} else if (
|
||||
product?.default_price &&
|
||||
typeof product.default_price === "object"
|
||||
) {
|
||||
resolvedPrice = product.default_price;
|
||||
let resolvedPrice = null
|
||||
if (typeof product?.default_price === 'string') {
|
||||
resolvedPrice = await stripe.prices.retrieve(product.default_price)
|
||||
} else if (product?.default_price && typeof product.default_price === 'object') {
|
||||
resolvedPrice = product.default_price
|
||||
}
|
||||
|
||||
let coinPack = null;
|
||||
let coinPack = null
|
||||
try {
|
||||
coinPack = formatCoinPack({
|
||||
product,
|
||||
price: resolvedPrice,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[subscription-createCoinPackCheckoutSession] invalid coin pack metadata",
|
||||
'[subscription-createCoinPackCheckoutSession] invalid coin pack metadata',
|
||||
productId,
|
||||
error?.message || error,
|
||||
);
|
||||
error?.message || error
|
||||
)
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"Le pack Stripe est mal configuré (metadata.coins manquant).",
|
||||
);
|
||||
'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.",
|
||||
);
|
||||
'failed-precondition',
|
||||
'Impossible de déterminer le prix Stripe pour ce pack.'
|
||||
)
|
||||
}
|
||||
|
||||
const uiMode = resolveCheckoutUiMode(request);
|
||||
const shouldProvideReturnUrls = uiMode !== "embedded";
|
||||
const uiMode = resolveCheckoutUiMode(request)
|
||||
const shouldProvideReturnUrls = uiMode !== 'embedded'
|
||||
const { successUrl, cancelUrl } = shouldProvideReturnUrls
|
||||
? getReturnUrls(request?.data?.returnUrls)
|
||||
: { successUrl: null, cancelUrl: null };
|
||||
: { 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é.",
|
||||
);
|
||||
'failed-precondition',
|
||||
'Impossible de retrouver le client Stripe associé.'
|
||||
)
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create(
|
||||
withCheckoutNavigationParams(
|
||||
{
|
||||
mode: "payment",
|
||||
mode: 'payment',
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
@@ -279,7 +245,7 @@ const createCoinPackCheckoutSession = onCall(
|
||||
allow_promotion_codes: false,
|
||||
metadata: {
|
||||
firebaseUID: uid,
|
||||
purchaseType: "COIN_PACK",
|
||||
purchaseType: 'COIN_PACK',
|
||||
coinPackProductId: coinPack.productId,
|
||||
coinPackPriceId: coinPack.priceId,
|
||||
coinAmount: coinPack.coinAmount,
|
||||
@@ -290,29 +256,22 @@ const createCoinPackCheckoutSession = onCall(
|
||||
uiMode,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return formatCheckoutSessionResponse(session);
|
||||
return formatCheckoutSessionResponse(session)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[subscription-createCoinPackCheckoutSession] error",
|
||||
error,
|
||||
);
|
||||
console.error('[subscription-createCoinPackCheckoutSession] error', error)
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
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 = {
|
||||
createSubscriptionCheckoutSession,
|
||||
createCoinPackCheckoutSession,
|
||||
resolveCheckoutUiMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const REGION = process.env.FIREBASE_REGION || "europe-west1";
|
||||
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
|
||||
|
||||
module.exports = {
|
||||
REGION,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,86 +1,76 @@
|
||||
const SUBSCRIPTION_PRICE_IDS = {
|
||||
monthly: [
|
||||
"price_1SPgitCzf2o5bDRdbnhLFx6f",
|
||||
"price_1SPgjCCzf2o5bDRdr08Xzp8u",
|
||||
"price_1SPgjaCzf2o5bDRdd9Xo2u26",
|
||||
'price_1SPgitCzf2o5bDRdbnhLFx6f',
|
||||
'price_1SPgjCCzf2o5bDRdr08Xzp8u',
|
||||
'price_1SPgjaCzf2o5bDRdd9Xo2u26',
|
||||
],
|
||||
annual: [
|
||||
"price_1SPgkDCzf2o5bDRdNGLVNeQ3",
|
||||
"price_1SPgkXCzf2o5bDRdejBVxEBY",
|
||||
"price_1SPgkqCzf2o5bDRdIcUwTDrm",
|
||||
'price_1SPgkDCzf2o5bDRdNGLVNeQ3',
|
||||
'price_1SPgkXCzf2o5bDRdejBVxEBY',
|
||||
'price_1SPgkqCzf2o5bDRdIcUwTDrm',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat();
|
||||
const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat()
|
||||
|
||||
const SUBSCRIPTION_LEVEL_ALLOWANCES = {
|
||||
starter: 10,
|
||||
pro: 40,
|
||||
premium: 60,
|
||||
};
|
||||
}
|
||||
|
||||
const SUBSCRIPTION_PRICE_METADATA = {
|
||||
price_1SPgitCzf2o5bDRdbnhLFx6f: {
|
||||
level: "starter",
|
||||
billingPeriod: "monthly",
|
||||
level: 'starter',
|
||||
billingPeriod: 'monthly',
|
||||
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
|
||||
},
|
||||
price_1SPgjCCzf2o5bDRdr08Xzp8u: {
|
||||
level: "pro",
|
||||
billingPeriod: "monthly",
|
||||
level: 'pro',
|
||||
billingPeriod: 'monthly',
|
||||
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
|
||||
},
|
||||
price_1SPgjaCzf2o5bDRdd9Xo2u26: {
|
||||
level: "premium",
|
||||
billingPeriod: "monthly",
|
||||
level: 'premium',
|
||||
billingPeriod: 'monthly',
|
||||
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
|
||||
},
|
||||
price_1SPgkDCzf2o5bDRdNGLVNeQ3: {
|
||||
level: "starter",
|
||||
billingPeriod: "annual",
|
||||
level: 'starter',
|
||||
billingPeriod: 'annual',
|
||||
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
|
||||
},
|
||||
price_1SPgkXCzf2o5bDRdejBVxEBY: {
|
||||
level: "pro",
|
||||
billingPeriod: "annual",
|
||||
level: 'pro',
|
||||
billingPeriod: 'annual',
|
||||
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
|
||||
},
|
||||
price_1SPgkqCzf2o5bDRdIcUwTDrm: {
|
||||
level: "premium",
|
||||
billingPeriod: "annual",
|
||||
level: 'premium',
|
||||
billingPeriod: 'annual',
|
||||
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const COIN_PACK_PRODUCTS = [
|
||||
{ productId: "prod_TMPPEXZ1wGk2cS", key: "starter" },
|
||||
{ productId: "prod_TMPQ5SNdS47gY6", key: "pro" },
|
||||
{ productId: "prod_TMPRpA0qQSHXj5", key: "premium" },
|
||||
];
|
||||
{ productId: 'prod_TMPPEXZ1wGk2cS', key: 'starter' },
|
||||
{ productId: 'prod_TMPQ5SNdS47gY6', key: 'pro' },
|
||||
{ productId: 'prod_TMPRpA0qQSHXj5', key: 'premium' },
|
||||
]
|
||||
|
||||
const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId);
|
||||
const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId)
|
||||
|
||||
const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce(
|
||||
(acc, pack) => ({
|
||||
...acc,
|
||||
[pack.productId]: pack,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
{}
|
||||
)
|
||||
|
||||
const PREMIUM_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]);
|
||||
const CANCELABLE_SUBSCRIPTION_STATUSES = new Set([
|
||||
"trialing",
|
||||
"active",
|
||||
"past_due",
|
||||
"unpaid",
|
||||
]);
|
||||
const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
|
||||
"trialing",
|
||||
"active",
|
||||
"past_due",
|
||||
"unpaid",
|
||||
]);
|
||||
const PREMIUM_SUBSCRIPTION_STATUSES = new Set(['active', 'trialing'])
|
||||
const CANCELABLE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid'])
|
||||
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid'])
|
||||
|
||||
module.exports = {
|
||||
SUBSCRIPTION_PRICE_IDS,
|
||||
@@ -93,4 +83,4 @@ module.exports = {
|
||||
PREMIUM_SUBSCRIPTION_STATUSES,
|
||||
CANCELABLE_SUBSCRIPTION_STATUSES,
|
||||
ACTIVE_SUBSCRIPTION_STATUSES,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
const { listSubscriptionPlans, listCoinPacks } = require("./catalog");
|
||||
const {
|
||||
createSubscriptionCheckoutSession,
|
||||
createCoinPackCheckoutSession,
|
||||
} = require("./checkout");
|
||||
const {
|
||||
cancelActiveSubscription,
|
||||
getActiveSubscription,
|
||||
} = require("./management");
|
||||
const { handleStripeWebhook } = require("./webhooks");
|
||||
const { processAnnualSubscriptionAllowances } = require("./schedule");
|
||||
const { listSubscriptionPlans, listCoinPacks } = require('./catalog')
|
||||
const { createSubscriptionCheckoutSession, createCoinPackCheckoutSession } = require('./checkout')
|
||||
const { cancelActiveSubscription, getActiveSubscription } = require('./management')
|
||||
const { handleStripeWebhook } = require('./webhooks')
|
||||
const { processAnnualSubscriptionAllowances } = require('./schedule')
|
||||
|
||||
module.exports = {
|
||||
listSubscriptionPlans,
|
||||
@@ -19,4 +13,4 @@ module.exports = {
|
||||
createCoinPackCheckoutSession,
|
||||
handleStripeWebhook,
|
||||
processAnnualSubscriptionAllowances,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,91 +1,74 @@
|
||||
const { HttpsError, onCall } = require("firebase-functions/https");
|
||||
const { HttpsError, onCall } = require('firebase-functions/https')
|
||||
|
||||
const { getStripeClient, mapStripeErrorToHttps } = require("../../helpers/stripe");
|
||||
const { REGION } = require("./config");
|
||||
const {
|
||||
CANCELABLE_SUBSCRIPTION_STATUSES,
|
||||
ACTIVE_SUBSCRIPTION_STATUSES,
|
||||
} = require("./constants");
|
||||
const {
|
||||
refsList,
|
||||
formatSubscriptionForClient,
|
||||
resolveUserContext,
|
||||
} = require("./shared");
|
||||
const { getStripeClient, mapStripeErrorToHttps } = require('../../helpers/stripe')
|
||||
const { REGION } = require('./config')
|
||||
const { CANCELABLE_SUBSCRIPTION_STATUSES, ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants')
|
||||
const { refsList, formatSubscriptionForClient, resolveUserContext } = require('./shared')
|
||||
|
||||
const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
const uid = request?.auth?.uid
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour gérer ton abonnement.",
|
||||
);
|
||||
throw new HttpsError('unauthenticated', 'Connecte-toi pour gérer ton abonnement.')
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const stripe = getStripeClient()
|
||||
|
||||
const userRef = refsList?.users?.doc(uid) || null;
|
||||
const snapshot = userRef ? await userRef.get() : null;
|
||||
const userData = snapshot?.exists ? snapshot.data() || {} : {};
|
||||
const userRef = refsList?.users?.doc(uid) || null
|
||||
const snapshot = userRef ? await userRef.get() : null
|
||||
const userData = snapshot?.exists ? snapshot.data() || {} : {}
|
||||
|
||||
const inputSubscriptionId =
|
||||
typeof request?.data?.subscriptionId === "string"
|
||||
? request.data.subscriptionId.trim()
|
||||
: "";
|
||||
typeof request?.data?.subscriptionId === 'string' ? request.data.subscriptionId.trim() : ''
|
||||
|
||||
let subscriptionId =
|
||||
inputSubscriptionId ||
|
||||
userData?.stripeSubscription?.id ||
|
||||
userData?.stripeSubscription?.subscriptionId ||
|
||||
null;
|
||||
null
|
||||
|
||||
const customerId = userData?.stripeCustomerId || null;
|
||||
const customerId = userData?.stripeCustomerId || null
|
||||
|
||||
if (!subscriptionId && customerId) {
|
||||
try {
|
||||
const response = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: "all",
|
||||
status: 'all',
|
||||
limit: 5,
|
||||
});
|
||||
const { data: subscriptionList = [] } = response || {};
|
||||
})
|
||||
const { data: subscriptionList = [] } = response || {}
|
||||
const activeSubscription = subscriptionList.find(
|
||||
(candidate) =>
|
||||
candidate?.status &&
|
||||
CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status),
|
||||
);
|
||||
(candidate) => candidate?.status && CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status)
|
||||
)
|
||||
if (activeSubscription?.id) {
|
||||
subscriptionId = activeSubscription.id;
|
||||
subscriptionId = activeSubscription.id
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[subscription-cancelActiveSubscription] Unable to list subscriptions",
|
||||
'[subscription-cancelActiveSubscription] Unable to list subscriptions',
|
||||
customerId,
|
||||
error?.message || error,
|
||||
);
|
||||
error?.message || error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!subscriptionId) {
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"Aucun abonnement actif à annuler.",
|
||||
);
|
||||
throw new HttpsError('failed-precondition', 'Aucun abonnement actif à annuler.')
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
|
||||
if (!subscription) {
|
||||
throw new HttpsError("not-found", "Abonnement introuvable côté Stripe.");
|
||||
throw new HttpsError('not-found', 'Abonnement introuvable côté Stripe.')
|
||||
}
|
||||
|
||||
if (subscription.status === "canceled") {
|
||||
if (subscription.status === 'canceled') {
|
||||
return {
|
||||
subscriptionId: subscription.id,
|
||||
status: subscription.status,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
||||
currentPeriodEnd: subscription.current_period_end || null,
|
||||
alreadyCanceled: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (subscription.cancel_at_period_end === true) {
|
||||
@@ -95,15 +78,12 @@ const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
|
||||
cancelAtPeriodEnd: true,
|
||||
currentPeriodEnd: subscription.current_period_end || null,
|
||||
alreadyCanceled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSubscription = await stripe.subscriptions.update(
|
||||
subscriptionId,
|
||||
{
|
||||
const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
return {
|
||||
subscriptionId: updatedSubscription.id,
|
||||
@@ -111,30 +91,24 @@ const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
|
||||
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end === true,
|
||||
currentPeriodEnd: updatedSubscription.current_period_end || null,
|
||||
alreadyCanceled: false,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[subscription-cancelActiveSubscription] error", error);
|
||||
console.error('[subscription-cancelActiveSubscription] error', error)
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Impossible d'annuler l'abonnement Stripe.",
|
||||
);
|
||||
throw mapStripeErrorToHttps(error, "Impossible d'annuler l'abonnement Stripe.")
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const getActiveSubscription = onCall({ region: REGION }, async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
const uid = request?.auth?.uid
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour récupérer ton abonnement.",
|
||||
);
|
||||
throw new HttpsError('unauthenticated', 'Connecte-toi pour récupérer ton abonnement.')
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const stripe = getStripeClient()
|
||||
|
||||
const {
|
||||
uid: resolvedUid,
|
||||
@@ -143,71 +117,66 @@ const getActiveSubscription = onCall({ region: REGION }, async (request) => {
|
||||
} = await resolveUserContext({
|
||||
metadata: request?.data?.metadata || {},
|
||||
customerId: null,
|
||||
});
|
||||
})
|
||||
|
||||
const lookupUid = resolvedUid || uid;
|
||||
const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null;
|
||||
const snapshot = lookupRef ? await lookupRef.get() : null;
|
||||
const data = snapshot?.exists ? snapshot.data() || {} : userData || {};
|
||||
const lookupUid = resolvedUid || uid
|
||||
const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null
|
||||
const snapshot = lookupRef ? await lookupRef.get() : null
|
||||
const data = snapshot?.exists ? snapshot.data() || {} : userData || {}
|
||||
|
||||
const subscriptionId =
|
||||
data?.stripeSubscription?.id ||
|
||||
data?.stripeSubscription?.subscriptionId ||
|
||||
null;
|
||||
const customerId = data?.stripeCustomerId || null;
|
||||
data?.stripeSubscription?.id || data?.stripeSubscription?.subscriptionId || null
|
||||
const customerId = data?.stripeCustomerId || null
|
||||
|
||||
if (!subscriptionId && !customerId) {
|
||||
return {
|
||||
subscription: null,
|
||||
customerId: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (subscriptionId) {
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
|
||||
expand: ["items.data.price.product"],
|
||||
});
|
||||
expand: ['items.data.price.product'],
|
||||
})
|
||||
if (subscription) {
|
||||
return {
|
||||
subscription: formatSubscriptionForClient(subscription),
|
||||
customerId: subscription.customer || customerId || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (customerId) {
|
||||
const response = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: "all",
|
||||
status: 'all',
|
||||
limit: 5,
|
||||
expand: ["data.items.data.price.product"],
|
||||
});
|
||||
const [subscription] = response?.data || [];
|
||||
expand: ['data.items.data.price.product'],
|
||||
})
|
||||
const [subscription] = response?.data || []
|
||||
if (subscription && ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
|
||||
return {
|
||||
subscription: formatSubscriptionForClient(subscription),
|
||||
customerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscription: null,
|
||||
customerId,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[subscription-getActiveSubscription] error", error);
|
||||
console.error('[subscription-getActiveSubscription] error', error)
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Impossible de récupérer l'abonnement Stripe.",
|
||||
);
|
||||
throw mapStripeErrorToHttps(error, "Impossible de récupérer l'abonnement Stripe.")
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
cancelActiveSubscription,
|
||||
getActiveSubscription,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,106 +1,97 @@
|
||||
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
||||
const { onSchedule } = require('firebase-functions/v2/scheduler')
|
||||
|
||||
const { ORDER_TYPES, createOrderDocument } = require("../helpers/orders");
|
||||
const { batchFirestore } = require("../../helpers/firebase");
|
||||
const { BATCH_TYPE } = require("../../config/types");
|
||||
const {
|
||||
admin,
|
||||
refsList,
|
||||
computeNextGrantTimestamp,
|
||||
getServerTimestamp,
|
||||
} = require("./shared");
|
||||
const { ACTIVE_SUBSCRIPTION_STATUSES } = require("./constants");
|
||||
const { ORDER_TYPES, createOrderDocument } = require('../helpers/orders')
|
||||
const { batchFirestore } = require('../../helpers/firebase')
|
||||
const { BATCH_TYPE } = require('../../config/types')
|
||||
const { admin, refsList, computeNextGrantTimestamp, getServerTimestamp } = require('./shared')
|
||||
const { ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants')
|
||||
|
||||
// Toggle to stop monthly grants for annual subscriptions while keeping logic handy.
|
||||
const ENABLE_ANNUAL_GRANT_SCHEDULER = true;
|
||||
const ENABLE_ANNUAL_GRANT_SCHEDULER = true
|
||||
|
||||
const processAnnualSubscriptionAllowances = onSchedule(
|
||||
{
|
||||
schedule: "30 3 * * *",
|
||||
timeZone: "Europe/Paris",
|
||||
schedule: '30 3 * * *',
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
async () => {
|
||||
if (!ENABLE_ANNUAL_GRANT_SCHEDULER) {
|
||||
console.log(
|
||||
"[subscription-processAnnualSubscriptionAllowances] skipped (disabled)",
|
||||
);
|
||||
return;
|
||||
console.log('[subscription-processAnnualSubscriptionAllowances] skipped (disabled)')
|
||||
return
|
||||
}
|
||||
|
||||
const nowTimestamp = admin.firestore.Timestamp.now();
|
||||
const pageSize = 200;
|
||||
let lastDoc = null;
|
||||
let processedUsers = 0;
|
||||
let grantsCreated = 0;
|
||||
let docsToUpdate = [];
|
||||
const nowTimestamp = admin.firestore.Timestamp.now()
|
||||
const pageSize = 200
|
||||
let lastDoc = null
|
||||
let processedUsers = 0
|
||||
let grantsCreated = 0
|
||||
let docsToUpdate = []
|
||||
|
||||
const flushUpdates = async () => {
|
||||
if (!docsToUpdate.length) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
await batchFirestore({
|
||||
docs: docsToUpdate,
|
||||
type: BATCH_TYPE.UPDATE,
|
||||
});
|
||||
docsToUpdate = [];
|
||||
};
|
||||
})
|
||||
docsToUpdate = []
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
let query = refsList.users
|
||||
.where("premiumBillingPeriod", "==", "annual")
|
||||
.where("subscriptionGrantInterval", "==", "monthly")
|
||||
.where("subscriptionNextGrantAt", "<=", nowTimestamp)
|
||||
.orderBy("subscriptionNextGrantAt")
|
||||
.limit(pageSize);
|
||||
.where('premiumBillingPeriod', '==', 'annual')
|
||||
.where('subscriptionGrantInterval', '==', 'monthly')
|
||||
.where('subscriptionNextGrantAt', '<=', nowTimestamp)
|
||||
.orderBy('subscriptionNextGrantAt')
|
||||
.limit(pageSize)
|
||||
|
||||
if (lastDoc) {
|
||||
query = query.startAfter(lastDoc);
|
||||
query = query.startAfter(lastDoc)
|
||||
}
|
||||
|
||||
const snapshot = await query.get();
|
||||
const snapshot = await query.get()
|
||||
if (snapshot.empty) {
|
||||
break;
|
||||
break
|
||||
}
|
||||
|
||||
for (const doc of snapshot.docs) {
|
||||
processedUsers += 1;
|
||||
const data = doc.data() || {};
|
||||
processedUsers += 1
|
||||
const data = doc.data() || {}
|
||||
|
||||
const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0);
|
||||
const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0)
|
||||
if (!Number.isFinite(coinsPerMonth) || coinsPerMonth <= 0) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
|
||||
const status =
|
||||
typeof data.stripeSubscriptionStatus === "string"
|
||||
typeof data.stripeSubscriptionStatus === 'string'
|
||||
? data.stripeSubscriptionStatus.toLowerCase()
|
||||
: null;
|
||||
: null
|
||||
if (status && !ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
|
||||
const nextGrantAt = data.subscriptionNextGrantAt;
|
||||
if (!nextGrantAt || typeof nextGrantAt.toDate !== "function") {
|
||||
continue;
|
||||
const nextGrantAt = data.subscriptionNextGrantAt
|
||||
if (!nextGrantAt || typeof nextGrantAt.toDate !== 'function') {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextGrantDate = nextGrantAt.toDate();
|
||||
const nextGrantDate = nextGrantAt.toDate()
|
||||
if (!nextGrantDate || nextGrantDate > new Date()) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
|
||||
const subscriptionInfo =
|
||||
data.stripeSubscription &&
|
||||
typeof data.stripeSubscription === "object"
|
||||
data.stripeSubscription && typeof data.stripeSubscription === 'object'
|
||||
? data.stripeSubscription
|
||||
: {};
|
||||
const subscriptionId =
|
||||
subscriptionInfo.id || data.stripeSubscriptionId || null;
|
||||
: {}
|
||||
const subscriptionId = subscriptionInfo.id || data.stripeSubscriptionId || null
|
||||
|
||||
const orderId = subscriptionId
|
||||
? `subscription_${subscriptionId}_sched_${nextGrantAt.seconds}`
|
||||
: `subscription_${doc.id}_sched_${nextGrantAt.seconds}`;
|
||||
: `subscription_${doc.id}_sched_${nextGrantAt.seconds}`
|
||||
|
||||
try {
|
||||
const { orderId: processedOrderId } = await createOrderDocument({
|
||||
@@ -108,25 +99,25 @@ const processAnnualSubscriptionAllowances = onSchedule(
|
||||
type: ORDER_TYPES.SUBSCRIPTION,
|
||||
amount: coinsPerMonth,
|
||||
metadata: {
|
||||
source: "STRIPE_SUBSCRIPTION",
|
||||
schedule: "annual_scheduler",
|
||||
source: 'STRIPE_SUBSCRIPTION',
|
||||
schedule: 'annual_scheduler',
|
||||
subscriptionId,
|
||||
scheduledGrantAt: nextGrantDate.toISOString(),
|
||||
},
|
||||
orderId,
|
||||
});
|
||||
})
|
||||
|
||||
let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1);
|
||||
const currentPeriodEnd = subscriptionInfo.currentPeriodEnd;
|
||||
let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1)
|
||||
const currentPeriodEnd = subscriptionInfo.currentPeriodEnd
|
||||
if (
|
||||
nextGrantTimestamp &&
|
||||
currentPeriodEnd &&
|
||||
typeof currentPeriodEnd.toDate === "function"
|
||||
typeof currentPeriodEnd.toDate === 'function'
|
||||
) {
|
||||
const periodEndDate = currentPeriodEnd.toDate();
|
||||
const nextGrantFutureDate = nextGrantTimestamp.toDate();
|
||||
const periodEndDate = currentPeriodEnd.toDate()
|
||||
const nextGrantFutureDate = nextGrantTimestamp.toDate()
|
||||
if (periodEndDate && nextGrantFutureDate > periodEndDate) {
|
||||
nextGrantTimestamp = null;
|
||||
nextGrantTimestamp = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,53 +127,47 @@ const processAnnualSubscriptionAllowances = onSchedule(
|
||||
subscriptionLastGrantAt: getServerTimestamp(),
|
||||
subscriptionLastGrantAmount: coinsPerMonth,
|
||||
subscriptionLastGrantOrderId: processedOrderId,
|
||||
subscriptionLastGrantSource: "annual_scheduler",
|
||||
subscriptionLastGrantSource: 'annual_scheduler',
|
||||
subscriptionNextGrantAt: nextGrantTimestamp || null,
|
||||
subscriptionGrantInterval: nextGrantTimestamp ? "monthly" : null,
|
||||
subscriptionGrantInterval: nextGrantTimestamp ? 'monthly' : null,
|
||||
},
|
||||
});
|
||||
grantsCreated += 1;
|
||||
})
|
||||
grantsCreated += 1
|
||||
|
||||
if (docsToUpdate.length >= 450) {
|
||||
await flushUpdates();
|
||||
await flushUpdates()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[subscription-processAnnualSubscriptionAllowances] Unable to create order",
|
||||
'[subscription-processAnnualSubscriptionAllowances] Unable to create order',
|
||||
{
|
||||
userId: doc.id,
|
||||
subscriptionId,
|
||||
error: error?.message || error,
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lastDoc = snapshot.docs[snapshot.docs.length - 1];
|
||||
lastDoc = snapshot.docs[snapshot.docs.length - 1]
|
||||
if (snapshot.size < pageSize) {
|
||||
break;
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await flushUpdates();
|
||||
await flushUpdates()
|
||||
|
||||
console.log(
|
||||
"[subscription-processAnnualSubscriptionAllowances] completed",
|
||||
{
|
||||
console.log('[subscription-processAnnualSubscriptionAllowances] completed', {
|
||||
processedUsers,
|
||||
grantsCreated,
|
||||
},
|
||||
);
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[subscription-processAnnualSubscriptionAllowances] error",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
console.error('[subscription-processAnnualSubscriptionAllowances] error', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
module.exports = {
|
||||
processAnnualSubscriptionAllowances,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,242 +1,226 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
|
||||
const { refList } = require("../../index");
|
||||
const { STRIPE_WEBHOOK_SECRET } = require("../../config/keys");
|
||||
const { refList } = require('../../index')
|
||||
const { STRIPE_WEBHOOK_SECRET } = require('../../config/keys')
|
||||
const {
|
||||
SUBSCRIPTION_LEVEL_ALLOWANCES,
|
||||
SUBSCRIPTION_PRICE_METADATA,
|
||||
COIN_PACK_PRODUCT_MAP,
|
||||
} = require("./constants");
|
||||
} = require('./constants')
|
||||
|
||||
const refsList = refList;
|
||||
const paymentsCollection = admin.firestore().collection("payments");
|
||||
let cachedStripeWebhookSecret = null;
|
||||
const refsList = refList
|
||||
const paymentsCollection = admin.firestore().collection('payments')
|
||||
let cachedStripeWebhookSecret = null
|
||||
|
||||
const toFiniteNumber = (value) => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().replace(",", ".");
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().replace(',', '.')
|
||||
if (!normalized) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
const parsed = Number(normalized)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseCoinsPerMonth = (metadata) => {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return null
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, "coinsPerMonth")) {
|
||||
return null;
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, 'coinsPerMonth')) {
|
||||
return null
|
||||
}
|
||||
const candidateValue = toFiniteNumber(metadata.coinsPerMonth);
|
||||
const candidateValue = toFiniteNumber(metadata.coinsPerMonth)
|
||||
if (candidateValue === null || candidateValue <= 0) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
return Math.round(candidateValue)
|
||||
}
|
||||
return Math.round(candidateValue);
|
||||
};
|
||||
|
||||
const parseCoinAmount = (metadata) => {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return null
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, "coins")) {
|
||||
return null;
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, 'coins')) {
|
||||
return null
|
||||
}
|
||||
const candidateValue = toFiniteNumber(metadata.coins);
|
||||
const candidateValue = toFiniteNumber(metadata.coins)
|
||||
if (candidateValue === null || candidateValue <= 0) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
return Math.round(candidateValue)
|
||||
}
|
||||
return Math.round(candidateValue);
|
||||
};
|
||||
|
||||
const toDateSafe = (value) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
return value
|
||||
}
|
||||
if (typeof value?.toDate === "function") {
|
||||
if (typeof value?.toDate === 'function') {
|
||||
try {
|
||||
return value.toDate();
|
||||
return value.toDate()
|
||||
} catch (_error) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
if (value > 1e12) {
|
||||
return new Date(value);
|
||||
return new Date(value)
|
||||
}
|
||||
return new Date(value * 1000);
|
||||
return new Date(value * 1000)
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const addMonths = (date, months = 1) => {
|
||||
if (!(date instanceof Date) || !Number.isFinite(months)) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
const result = new Date(date.getTime());
|
||||
const initialDay = result.getDate();
|
||||
result.setMonth(result.getMonth() + months);
|
||||
const result = new Date(date.getTime())
|
||||
const initialDay = result.getDate()
|
||||
result.setMonth(result.getMonth() + months)
|
||||
if (result.getDate() !== initialDay) {
|
||||
result.setDate(0);
|
||||
result.setDate(0)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const computeNextGrantTimestamp = (base, months = 1) => {
|
||||
const baseDate = toDateSafe(base);
|
||||
const baseDate = toDateSafe(base)
|
||||
if (!baseDate) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
const nextDate = addMonths(baseDate, months);
|
||||
const nextDate = addMonths(baseDate, months)
|
||||
if (!nextDate) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
return admin.firestore.Timestamp.fromDate(nextDate)
|
||||
}
|
||||
return admin.firestore.Timestamp.fromDate(nextDate);
|
||||
};
|
||||
|
||||
const getServerTimestamp = () => {
|
||||
if (typeof FieldValue?.serverTimestamp === "function") {
|
||||
return FieldValue.serverTimestamp();
|
||||
if (typeof FieldValue?.serverTimestamp === 'function') {
|
||||
return FieldValue.serverTimestamp()
|
||||
}
|
||||
const fallback = admin.firestore?.FieldValue;
|
||||
if (typeof fallback?.serverTimestamp === "function") {
|
||||
return fallback.serverTimestamp();
|
||||
const fallback = admin.firestore?.FieldValue
|
||||
if (typeof fallback?.serverTimestamp === 'function') {
|
||||
return fallback.serverTimestamp()
|
||||
}
|
||||
throw new Error('Firestore FieldValue.serverTimestamp indisponible.')
|
||||
}
|
||||
throw new Error("Firestore FieldValue.serverTimestamp indisponible.");
|
||||
};
|
||||
|
||||
const resolveStripeWebhookSecret = () => {
|
||||
if (cachedStripeWebhookSecret) {
|
||||
return cachedStripeWebhookSecret;
|
||||
return cachedStripeWebhookSecret
|
||||
}
|
||||
|
||||
const envSecret =
|
||||
typeof process?.env?.STRIPE_WEBHOOK_SECRET === "string"
|
||||
typeof process?.env?.STRIPE_WEBHOOK_SECRET === 'string'
|
||||
? process.env.STRIPE_WEBHOOK_SECRET.trim()
|
||||
: "";
|
||||
const inlineSecret =
|
||||
typeof STRIPE_WEBHOOK_SECRET === "string"
|
||||
? STRIPE_WEBHOOK_SECRET.trim()
|
||||
: "";
|
||||
: ''
|
||||
const inlineSecret = typeof STRIPE_WEBHOOK_SECRET === 'string' ? STRIPE_WEBHOOK_SECRET.trim() : ''
|
||||
|
||||
const secret = envSecret || inlineSecret;
|
||||
const secret = envSecret || inlineSecret
|
||||
if (!secret) {
|
||||
throw new Error("STRIPE_WEBHOOK_SECRET not configured");
|
||||
throw new Error('STRIPE_WEBHOOK_SECRET not configured')
|
||||
}
|
||||
|
||||
cachedStripeWebhookSecret = secret;
|
||||
return cachedStripeWebhookSecret;
|
||||
};
|
||||
cachedStripeWebhookSecret = secret
|
||||
return cachedStripeWebhookSecret
|
||||
}
|
||||
|
||||
const toFirestoreTimestamp = (unixSeconds) => {
|
||||
if (typeof unixSeconds !== "number" || !Number.isFinite(unixSeconds)) {
|
||||
return null;
|
||||
if (typeof unixSeconds !== 'number' || !Number.isFinite(unixSeconds)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000);
|
||||
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000)
|
||||
} catch (error) {
|
||||
console.error("[subscription-toFirestoreTimestamp] Conversion error", unixSeconds, error);
|
||||
return null;
|
||||
console.error('[subscription-toFirestoreTimestamp] Conversion error', unixSeconds, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const extractFirebaseUid = (metadata) => {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
metadata.firebaseUID,
|
||||
metadata.firebaseUid,
|
||||
metadata.uid,
|
||||
metadata.userId,
|
||||
];
|
||||
const candidates = [metadata.firebaseUID, metadata.firebaseUid, metadata.uid, metadata.userId]
|
||||
|
||||
for (let index = 0; index < candidates.length; index += 1) {
|
||||
const candidate = candidates[index];
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
const candidate = candidates[index]
|
||||
if (typeof candidate === 'string' && candidate.trim()) {
|
||||
return candidate.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
const formatCoinPack = ({ product, price }) => {
|
||||
if (!product || typeof product !== "object") {
|
||||
return null;
|
||||
if (!product || typeof product !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolvedPrice =
|
||||
price ||
|
||||
(typeof product.default_price === "object" && product.default_price) ||
|
||||
null;
|
||||
price || (typeof product.default_price === 'object' && product.default_price) || null
|
||||
|
||||
const priceId =
|
||||
(resolvedPrice && resolvedPrice.id) ||
|
||||
(typeof product.default_price === "string" ? product.default_price : null);
|
||||
(typeof product.default_price === 'string' ? product.default_price : null)
|
||||
|
||||
const coinAmount = parseCoinAmount(product?.metadata || {});
|
||||
const coinAmount = parseCoinAmount(product?.metadata || {})
|
||||
if (coinAmount === null) {
|
||||
throw new Error(
|
||||
`[formatCoinPack] Missing metadata.coins on product ${product.id}`,
|
||||
);
|
||||
throw new Error(`[formatCoinPack] Missing metadata.coins on product ${product.id}`)
|
||||
}
|
||||
|
||||
return {
|
||||
productId: product.id,
|
||||
priceId,
|
||||
name: product.name || "",
|
||||
description: product.description || "",
|
||||
name: product.name || '',
|
||||
description: product.description || '',
|
||||
coinAmount,
|
||||
currency:
|
||||
resolvedPrice?.currency ||
|
||||
(typeof resolvedPrice?.currency === "string"
|
||||
? resolvedPrice.currency.toLowerCase()
|
||||
: "eur"),
|
||||
(typeof resolvedPrice?.currency === 'string' ? resolvedPrice.currency.toLowerCase() : 'eur'),
|
||||
unitAmount: resolvedPrice?.unit_amount ?? null,
|
||||
metadata: product.metadata || {},
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const getSubscriptionMetaFromPrice = (priceId) => {
|
||||
if (typeof priceId !== "string") {
|
||||
return null;
|
||||
if (typeof priceId !== 'string') {
|
||||
return null
|
||||
}
|
||||
return SUBSCRIPTION_PRICE_METADATA[priceId] || null
|
||||
}
|
||||
return SUBSCRIPTION_PRICE_METADATA[priceId] || null;
|
||||
};
|
||||
|
||||
const buildEventSnapshot = (eventType, entityId) => {
|
||||
const now = admin.firestore.Timestamp.now();
|
||||
const now = admin.firestore.Timestamp.now()
|
||||
return {
|
||||
eventType: eventType || null,
|
||||
entityId: entityId || null,
|
||||
syncedAt: now,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const formatSubscriptionForClient = (subscription) => {
|
||||
if (!subscription || typeof subscription !== "object") {
|
||||
return null;
|
||||
if (!subscription || typeof subscription !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
status: subscription.status,
|
||||
customer: subscription.customer,
|
||||
currentPeriodStart: toFirestoreTimestamp(
|
||||
subscription.current_period_start,
|
||||
),
|
||||
currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
|
||||
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
||||
created: toFirestoreTimestamp(subscription.created),
|
||||
@@ -248,12 +232,12 @@ const formatSubscriptionForClient = (subscription) => {
|
||||
}))
|
||||
: [],
|
||||
metadata: subscription.metadata || {},
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const buildSubscriptionPayload = (subscription) => {
|
||||
if (!subscription || typeof subscription !== "object") {
|
||||
return null;
|
||||
if (!subscription || typeof subscription !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const items = Array.isArray(subscription.items?.data)
|
||||
@@ -264,20 +248,19 @@ const buildSubscriptionPayload = (subscription) => {
|
||||
quantity: item.quantity || 0,
|
||||
price: item.price || null,
|
||||
}))
|
||||
: [];
|
||||
: []
|
||||
|
||||
const primaryItem = items[0] || null;
|
||||
const productId = primaryItem?.productId || null;
|
||||
const priceId = primaryItem?.priceId || null;
|
||||
const primaryItem = items[0] || null
|
||||
const productId = primaryItem?.productId || null
|
||||
const priceId = primaryItem?.priceId || null
|
||||
|
||||
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
|
||||
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
|
||||
const resolvedLevel = metadataLevel || priceMeta?.level || null;
|
||||
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null;
|
||||
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
|
||||
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
|
||||
const metadataLevel = subscription.metadata?.subscriptionLevel || null
|
||||
const resolvedLevel = metadataLevel || priceMeta?.level || null
|
||||
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null
|
||||
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null
|
||||
|
||||
const customerId =
|
||||
typeof subscription.customer === "string" ? subscription.customer : null;
|
||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : null
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
@@ -289,34 +272,32 @@ const buildSubscriptionPayload = (subscription) => {
|
||||
billingPeriod: resolvedPeriod,
|
||||
status: subscription.status || null,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
||||
currentPeriodStart: toFirestoreTimestamp(
|
||||
subscription.current_period_start,
|
||||
),
|
||||
currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
|
||||
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const resolveUserContext = async ({ metadata, customerId }) => {
|
||||
const firebaseUid = extractFirebaseUid(metadata);
|
||||
const firebaseUid = extractFirebaseUid(metadata)
|
||||
|
||||
if (firebaseUid) {
|
||||
const userRef = refsList?.users?.doc(firebaseUid) || null;
|
||||
const userRef = refsList?.users?.doc(firebaseUid) || null
|
||||
if (userRef) {
|
||||
try {
|
||||
const snapshot = await userRef.get();
|
||||
const snapshot = await userRef.get()
|
||||
if (snapshot.exists) {
|
||||
return {
|
||||
uid: firebaseUid,
|
||||
userRef,
|
||||
userData: snapshot.data() || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[subscription-resolveUserContext] Unable to read user",
|
||||
'[subscription-resolveUserContext] Unable to read user',
|
||||
firebaseUid,
|
||||
error?.message || error,
|
||||
);
|
||||
error?.message || error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,23 +305,23 @@ const resolveUserContext = async ({ metadata, customerId }) => {
|
||||
if (customerId) {
|
||||
try {
|
||||
const snapshot = await refsList.users
|
||||
.where("stripeCustomerId", "==", customerId)
|
||||
.where('stripeCustomerId', '==', customerId)
|
||||
.limit(1)
|
||||
.get();
|
||||
.get()
|
||||
if (!snapshot.empty) {
|
||||
const doc = snapshot.docs[0];
|
||||
const doc = snapshot.docs[0]
|
||||
return {
|
||||
uid: doc.id,
|
||||
userRef: doc.ref,
|
||||
userData: doc.data() || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[subscription-resolveUserContext] Unable to query by customer",
|
||||
'[subscription-resolveUserContext] Unable to query by customer',
|
||||
customerId,
|
||||
error?.message || error,
|
||||
);
|
||||
error?.message || error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,15 +329,15 @@ const resolveUserContext = async ({ metadata, customerId }) => {
|
||||
uid: firebaseUid || null,
|
||||
userRef: null,
|
||||
userData: null,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const upsertPaymentDocument = async (docId, data = {}) => {
|
||||
if (!docId) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const docRef = paymentsCollection.doc(docId);
|
||||
const docRef = paymentsCollection.doc(docId)
|
||||
try {
|
||||
await docRef.set(
|
||||
{
|
||||
@@ -364,17 +345,13 @@ const upsertPaymentDocument = async (docId, data = {}) => {
|
||||
updatedAt: getServerTimestamp(),
|
||||
createdAt: getServerTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
{ merge: true }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[subscription-upsertPaymentDocument] Failed to persist payment",
|
||||
docId,
|
||||
error,
|
||||
);
|
||||
console.error('[subscription-upsertPaymentDocument] Failed to persist payment', docId, error)
|
||||
}
|
||||
return docRef
|
||||
}
|
||||
return docRef;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
admin,
|
||||
@@ -398,4 +375,4 @@ module.exports = {
|
||||
resolveUserContext,
|
||||
upsertPaymentDocument,
|
||||
COIN_PACK_PRODUCT_MAP,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const { onRequest } = require("firebase-functions/v2/https");
|
||||
const { HttpsError } = require("firebase-functions/https");
|
||||
const { onRequest } = require('firebase-functions/v2/https')
|
||||
const { HttpsError } = require('firebase-functions/https')
|
||||
|
||||
const { ORDER_TYPES, createOrderDocument } = require("../helpers/orders");
|
||||
const { getStripeClient } = require("../../helpers/stripe");
|
||||
const { REGION } = require("./config");
|
||||
const { ORDER_TYPES, createOrderDocument } = require('../helpers/orders')
|
||||
const { getStripeClient } = require('../../helpers/stripe')
|
||||
const { REGION } = require('./config')
|
||||
const {
|
||||
paymentsCollection,
|
||||
resolveStripeWebhookSecret,
|
||||
@@ -17,25 +17,21 @@ const {
|
||||
buildSubscriptionPayload,
|
||||
resolveUserContext,
|
||||
upsertPaymentDocument,
|
||||
} = require("./shared");
|
||||
const { PREMIUM_SUBSCRIPTION_STATUSES } = require("./constants");
|
||||
} = require('./shared')
|
||||
const { PREMIUM_SUBSCRIPTION_STATUSES } = require('./constants')
|
||||
|
||||
const handleCheckoutSessionCompleted = async (
|
||||
session,
|
||||
event,
|
||||
{ stripe } = {},
|
||||
) => {
|
||||
if (!session || typeof session !== "object") {
|
||||
return;
|
||||
const handleCheckoutSessionCompleted = async (session, event, { stripe } = {}) => {
|
||||
if (!session || typeof session !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const firebaseUid = extractFirebaseUid(session.metadata);
|
||||
const firebaseUid = extractFirebaseUid(session.metadata)
|
||||
const paymentDocRef = await upsertPaymentDocument(session.id, {
|
||||
userId: firebaseUid || null,
|
||||
customerId: session.customer || null,
|
||||
subscriptionId: session.subscription || null,
|
||||
invoiceId: session.invoice || null,
|
||||
status: session.status || "completed",
|
||||
status: session.status || 'completed',
|
||||
paymentStatus: session.payment_status || null,
|
||||
mode: session.mode || null,
|
||||
amountSubtotal: session.amount_subtotal ?? null,
|
||||
@@ -44,109 +40,103 @@ const handleCheckoutSessionCompleted = async (
|
||||
metadata: session.metadata || {},
|
||||
completedAt: toFirestoreTimestamp(session.created),
|
||||
expiresAt: toFirestoreTimestamp(session.expires_at),
|
||||
paymentIntentId:
|
||||
typeof session.payment_intent === "string"
|
||||
? session.payment_intent
|
||||
: null,
|
||||
paymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : null,
|
||||
lastEventType: event?.type || null,
|
||||
lastEventId: event?.id || null,
|
||||
lastEventAt: getServerTimestamp(),
|
||||
});
|
||||
})
|
||||
|
||||
const { uid, userRef } = await resolveUserContext({
|
||||
metadata: session.metadata,
|
||||
customerId: session.customer,
|
||||
});
|
||||
})
|
||||
|
||||
if (userRef) {
|
||||
const lastEvent = buildEventSnapshot(event?.type, session.id);
|
||||
const lastEvent = buildEventSnapshot(event?.type, session.id)
|
||||
if (firebaseUid) {
|
||||
lastEvent.uid = firebaseUid;
|
||||
lastEvent.uid = firebaseUid
|
||||
}
|
||||
|
||||
const userUpdate = {
|
||||
lastStripeWebhookEvent: lastEvent,
|
||||
};
|
||||
}
|
||||
|
||||
if (session.customer) {
|
||||
userUpdate.stripeCustomerId = session.customer;
|
||||
userUpdate.stripeCustomerId = session.customer
|
||||
}
|
||||
|
||||
if (session.metadata?.subscriptionLevel) {
|
||||
userUpdate.premiumLevel = session.metadata.subscriptionLevel;
|
||||
userUpdate.premiumLevel = session.metadata.subscriptionLevel
|
||||
}
|
||||
|
||||
if (session.metadata?.subscriptionBillingPeriod) {
|
||||
userUpdate.premiumBillingPeriod =
|
||||
session.metadata.subscriptionBillingPeriod;
|
||||
userUpdate.premiumBillingPeriod = session.metadata.subscriptionBillingPeriod
|
||||
}
|
||||
|
||||
await userRef.set(userUpdate, { merge: true });
|
||||
await userRef.set(userUpdate, { merge: true })
|
||||
}
|
||||
|
||||
if (
|
||||
stripe &&
|
||||
session.mode === "subscription" &&
|
||||
typeof session.subscription === "string" &&
|
||||
session.mode === 'subscription' &&
|
||||
typeof session.subscription === 'string' &&
|
||||
session.subscription
|
||||
) {
|
||||
try {
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
session.subscription,
|
||||
{ expand: ["items.data.price.product"] },
|
||||
);
|
||||
const subscription = await stripe.subscriptions.retrieve(session.subscription, {
|
||||
expand: ['items.data.price.product'],
|
||||
})
|
||||
if (subscription) {
|
||||
await handleCustomerSubscriptionEvent(subscription, event, { stripe });
|
||||
await handleCustomerSubscriptionEvent(subscription, event, { stripe })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[subscription-handleCheckoutSessionCompleted] Unable to sync subscription",
|
||||
'[subscription-handleCheckoutSessionCompleted] Unable to sync subscription',
|
||||
session.subscription,
|
||||
error,
|
||||
);
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
session.mode === "payment" &&
|
||||
(session.payment_status === "paid" ||
|
||||
session.payment_status === "no_payment_required") &&
|
||||
session.metadata?.purchaseType === "COIN_PACK" &&
|
||||
session.mode === 'payment' &&
|
||||
(session.payment_status === 'paid' || session.payment_status === 'no_payment_required') &&
|
||||
session.metadata?.purchaseType === 'COIN_PACK' &&
|
||||
userRef
|
||||
) {
|
||||
const coinAmountRaw = Number(session.metadata?.coinAmount || 0);
|
||||
const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0;
|
||||
const coinAmountRaw = Number(session.metadata?.coinAmount || 0)
|
||||
const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0
|
||||
|
||||
if (coinAmount > 0 && paymentDocRef) {
|
||||
let paymentSnapshot = null;
|
||||
let paymentSnapshot = null
|
||||
try {
|
||||
paymentSnapshot = await paymentDocRef.get();
|
||||
paymentSnapshot = await paymentDocRef.get()
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[subscription-handleCheckoutSessionCompleted] Unable to read payment doc",
|
||||
'[subscription-handleCheckoutSessionCompleted] Unable to read payment doc',
|
||||
session.id,
|
||||
error?.message || error,
|
||||
);
|
||||
error?.message || error
|
||||
)
|
||||
}
|
||||
|
||||
const alreadyGranted = Boolean(
|
||||
paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt,
|
||||
);
|
||||
paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt
|
||||
)
|
||||
|
||||
if (!alreadyGranted) {
|
||||
const targetUserId = uid || firebaseUid || userRef.id;
|
||||
const targetUserId = uid || firebaseUid || userRef.id
|
||||
|
||||
await createOrderDocument({
|
||||
userId: targetUserId,
|
||||
type: ORDER_TYPES.COINS,
|
||||
amount: coinAmount,
|
||||
metadata: {
|
||||
source: "STRIPE_CHECKOUT",
|
||||
source: 'STRIPE_CHECKOUT',
|
||||
paymentId: session.id || null,
|
||||
coinPackKey: session.metadata?.coinPackKey || null,
|
||||
},
|
||||
orderId: `stripe_${session.id}`,
|
||||
});
|
||||
})
|
||||
|
||||
await paymentDocRef.set(
|
||||
{
|
||||
@@ -154,25 +144,21 @@ const handleCheckoutSessionCompleted = async (
|
||||
coinPackGrantedAmount: coinAmount,
|
||||
coinPackGrantedKey: session.metadata?.coinPackKey || null,
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
{ merge: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomerSubscriptionEvent = async (
|
||||
subscription,
|
||||
event,
|
||||
{ stripe } = {},
|
||||
) => {
|
||||
if (!subscription || typeof subscription !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionPayload = buildSubscriptionPayload(subscription);
|
||||
const handleCustomerSubscriptionEvent = async (subscription, event, { stripe } = {}) => {
|
||||
if (!subscription || typeof subscription !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const subscriptionPayload = buildSubscriptionPayload(subscription)
|
||||
if (!subscriptionPayload) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -182,36 +168,36 @@ const handleCustomerSubscriptionEvent = async (
|
||||
} = await resolveUserContext({
|
||||
metadata: subscription.metadata,
|
||||
customerId: subscription.customer,
|
||||
});
|
||||
})
|
||||
|
||||
let userData = resolvedUserData || null;
|
||||
let userData = resolvedUserData || null
|
||||
if (!userData && userRef) {
|
||||
try {
|
||||
const snapshot = await userRef.get();
|
||||
userData = snapshot.exists ? snapshot.data() || null : null;
|
||||
const snapshot = await userRef.get()
|
||||
userData = snapshot.exists ? snapshot.data() || null : null
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[subscription-handleCustomerSubscriptionEvent] Unable to read user",
|
||||
'[subscription-handleCustomerSubscriptionEvent] Unable to read user',
|
||||
subscription.customer,
|
||||
error?.message || error,
|
||||
);
|
||||
error?.message || error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedCustomerId = subscriptionPayload?.customerId || null;
|
||||
let resolvedCustomerId = subscriptionPayload?.customerId || null
|
||||
if (!resolvedCustomerId && subscription.customer) {
|
||||
resolvedCustomerId = subscription.customer;
|
||||
resolvedCustomerId = subscription.customer
|
||||
}
|
||||
|
||||
const fallbackUid = extractFirebaseUid(subscription.metadata);
|
||||
const resolvedUid = uid || fallbackUid || null;
|
||||
const fallbackUid = extractFirebaseUid(subscription.metadata)
|
||||
const resolvedUid = uid || fallbackUid || null
|
||||
|
||||
await upsertPaymentDocument(subscription.id, {
|
||||
userId: resolvedUid,
|
||||
customerId: resolvedCustomerId,
|
||||
subscriptionId: subscription.id || null,
|
||||
status: subscription.status || null,
|
||||
mode: "subscription",
|
||||
mode: 'subscription',
|
||||
priceId: subscriptionPayload?.priceId || null,
|
||||
productId: subscriptionPayload?.productId || null,
|
||||
cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null,
|
||||
@@ -224,76 +210,66 @@ const handleCustomerSubscriptionEvent = async (
|
||||
lastEventType: event?.type || null,
|
||||
lastEventId: event?.id || null,
|
||||
lastEventAt: getServerTimestamp(),
|
||||
});
|
||||
})
|
||||
|
||||
if (!userRef) {
|
||||
console.warn(
|
||||
"[subscription-handleCustomerSubscriptionEvent] User not resolved",
|
||||
{
|
||||
console.warn('[subscription-handleCustomerSubscriptionEvent] User not resolved', {
|
||||
subscriptionId: subscription?.id || null,
|
||||
customerId: subscription?.customer || null,
|
||||
metadataKeys: Object.keys(subscription?.metadata || {}),
|
||||
eventType: event?.type || null,
|
||||
},
|
||||
);
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const lastEvent = buildEventSnapshot(event?.type, subscription.id);
|
||||
const lastEvent = buildEventSnapshot(event?.type, subscription.id)
|
||||
if (resolvedUid) {
|
||||
lastEvent.uid = resolvedUid;
|
||||
lastEvent.uid = resolvedUid
|
||||
}
|
||||
|
||||
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId);
|
||||
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
|
||||
const metadataPeriod =
|
||||
subscription.metadata?.subscriptionBillingPeriod || null;
|
||||
const resolvedLevel = metadataLevel || priceMeta?.level || null;
|
||||
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
|
||||
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId)
|
||||
const metadataLevel = subscription.metadata?.subscriptionLevel || null
|
||||
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null
|
||||
const resolvedLevel = metadataLevel || priceMeta?.level || null
|
||||
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null
|
||||
|
||||
const isPremium = subscription.status
|
||||
? PREMIUM_SUBSCRIPTION_STATUSES.has(subscription.status)
|
||||
: false;
|
||||
: false
|
||||
|
||||
const primaryItem = Array.isArray(subscriptionPayload?.items)
|
||||
? subscriptionPayload.items[0]
|
||||
: null;
|
||||
: null
|
||||
|
||||
let coinsPerMonth = null;
|
||||
let coinsPerMonth = null
|
||||
const productMetadata =
|
||||
primaryItem?.price &&
|
||||
typeof primaryItem.price === "object" &&
|
||||
typeof primaryItem.price === 'object' &&
|
||||
primaryItem.price.product &&
|
||||
typeof primaryItem.price.product === "object"
|
||||
typeof primaryItem.price.product === 'object'
|
||||
? primaryItem.price.product.metadata
|
||||
: null;
|
||||
: null
|
||||
|
||||
coinsPerMonth = parseCoinsPerMonth(productMetadata || {});
|
||||
coinsPerMonth = parseCoinsPerMonth(productMetadata || {})
|
||||
|
||||
if (coinsPerMonth === null && stripe && primaryItem?.price?.id) {
|
||||
try {
|
||||
const priceWithProduct = await stripe.prices.retrieve(
|
||||
primaryItem.price.id,
|
||||
{ expand: ["product"] },
|
||||
);
|
||||
coinsPerMonth = parseCoinsPerMonth(
|
||||
priceWithProduct?.product?.metadata || {},
|
||||
);
|
||||
const priceWithProduct = await stripe.prices.retrieve(primaryItem.price.id, {
|
||||
expand: ['product'],
|
||||
})
|
||||
coinsPerMonth = parseCoinsPerMonth(priceWithProduct?.product?.metadata || {})
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata",
|
||||
'[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata',
|
||||
primaryItem.price.id,
|
||||
error?.message || error,
|
||||
);
|
||||
error?.message || error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let subscriptionNextGrantAt = null;
|
||||
let subscriptionNextGrantAt = null
|
||||
if (coinsPerMonth !== null && subscriptionPayload?.currentPeriodEnd) {
|
||||
subscriptionNextGrantAt = computeNextGrantTimestamp(
|
||||
subscriptionPayload.currentPeriodEnd,
|
||||
1,
|
||||
);
|
||||
subscriptionNextGrantAt = computeNextGrantTimestamp(subscriptionPayload.currentPeriodEnd, 1)
|
||||
}
|
||||
|
||||
const userUpdate = {
|
||||
@@ -313,53 +289,46 @@ const handleCustomerSubscriptionEvent = async (
|
||||
premiumLevel: isPremium ? resolvedLevel : null,
|
||||
premiumBillingPeriod: isPremium ? resolvedPeriod : null,
|
||||
subscriptionNextGrantAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPremium) {
|
||||
userUpdate.subscriptionNextGrantAt = null;
|
||||
userUpdate.subscriptionNextGrantAt = null
|
||||
}
|
||||
|
||||
await userRef.set(userUpdate, { merge: true });
|
||||
};
|
||||
await userRef.set(userUpdate, { merge: true })
|
||||
}
|
||||
|
||||
const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
|
||||
if (!invoice || typeof invoice !== "object") {
|
||||
return;
|
||||
if (!invoice || typeof invoice !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const firebaseUid = extractFirebaseUid(invoice.metadata);
|
||||
const eventType = event?.type || null;
|
||||
const paymentDocRef = paymentsCollection.doc(invoice.id);
|
||||
const firebaseUid = extractFirebaseUid(invoice.metadata)
|
||||
const eventType = event?.type || null
|
||||
const paymentDocRef = paymentsCollection.doc(invoice.id)
|
||||
|
||||
let resolvedSubscriptionId =
|
||||
typeof invoice.subscription === "string" && invoice.subscription
|
||||
? invoice.subscription
|
||||
: null;
|
||||
typeof invoice.subscription === 'string' && invoice.subscription ? invoice.subscription : null
|
||||
|
||||
if (!resolvedSubscriptionId) {
|
||||
const lineSubscriptionId = Array.isArray(invoice?.lines?.data)
|
||||
? invoice.lines.data
|
||||
.map((line) =>
|
||||
typeof line?.subscription === "string" && line.subscription
|
||||
? line.subscription
|
||||
: null,
|
||||
typeof line?.subscription === 'string' && line.subscription ? line.subscription : null
|
||||
)
|
||||
.find((value) => value)
|
||||
: null;
|
||||
: null
|
||||
|
||||
if (lineSubscriptionId) {
|
||||
resolvedSubscriptionId = lineSubscriptionId;
|
||||
console.log(
|
||||
"[subscription-handleInvoiceEvent] Subscription resolved from invoice line",
|
||||
{
|
||||
resolvedSubscriptionId = lineSubscriptionId
|
||||
console.log('[subscription-handleInvoiceEvent] Subscription resolved from invoice line', {
|
||||
invoiceId: invoice?.id || null,
|
||||
subscriptionId: resolvedSubscriptionId,
|
||||
},
|
||||
);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[subscription-handleInvoiceEvent] Received invoice webhook", {
|
||||
console.log('[subscription-handleInvoiceEvent] Received invoice webhook', {
|
||||
eventType,
|
||||
invoiceId: invoice?.id || null,
|
||||
subscriptionId: invoice?.subscription || null,
|
||||
@@ -368,17 +337,14 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
|
||||
status: invoice?.status || null,
|
||||
billingReason: invoice?.billing_reason || null,
|
||||
attemptCount: invoice?.attempt_count ?? null,
|
||||
});
|
||||
})
|
||||
|
||||
await upsertPaymentDocument(invoice.id, {
|
||||
userId: firebaseUid || null,
|
||||
customerId: invoice.customer || null,
|
||||
subscriptionId: resolvedSubscriptionId,
|
||||
status: invoice.status || null,
|
||||
paymentStatus:
|
||||
eventType === "invoice.payment_failed"
|
||||
? "failed"
|
||||
: invoice.status || null,
|
||||
paymentStatus: eventType === 'invoice.payment_failed' ? 'failed' : invoice.status || null,
|
||||
amountDue: invoice.amount_due ?? null,
|
||||
amountPaid: invoice.amount_paid ?? null,
|
||||
amountRemaining: invoice.amount_remaining ?? null,
|
||||
@@ -394,8 +360,8 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
|
||||
lastEventType: eventType,
|
||||
lastEventId: event?.id || null,
|
||||
lastEventAt: getServerTimestamp(),
|
||||
mode: "invoice",
|
||||
});
|
||||
mode: 'invoice',
|
||||
})
|
||||
|
||||
const {
|
||||
uid,
|
||||
@@ -404,124 +370,109 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
|
||||
} = await resolveUserContext({
|
||||
metadata: invoice.metadata,
|
||||
customerId: invoice.customer,
|
||||
});
|
||||
})
|
||||
|
||||
if (!userRef) {
|
||||
console.warn(
|
||||
"[subscription-handleInvoiceEvent] User context not resolved",
|
||||
{
|
||||
console.warn('[subscription-handleInvoiceEvent] User context not resolved', {
|
||||
invoiceId: invoice?.id || null,
|
||||
customerId: invoice?.customer || null,
|
||||
firebaseUid: firebaseUid || null,
|
||||
metadataKeys: Object.keys(invoice?.metadata || {}),
|
||||
},
|
||||
);
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let userData = resolvedUserData || null;
|
||||
let userData = resolvedUserData || null
|
||||
if (!userData) {
|
||||
try {
|
||||
const snapshot = await userRef.get();
|
||||
userData = snapshot.exists ? snapshot.data() || null : null;
|
||||
const snapshot = await userRef.get()
|
||||
userData = snapshot.exists ? snapshot.data() || null : null
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[subscription-handleInvoiceEvent] Unable to read user",
|
||||
'[subscription-handleInvoiceEvent] Unable to read user',
|
||||
invoice.customer,
|
||||
error?.message || error,
|
||||
);
|
||||
error?.message || error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const lastEvent = buildEventSnapshot(eventType, invoice.id);
|
||||
const lastEvent = buildEventSnapshot(eventType, invoice.id)
|
||||
if (firebaseUid) {
|
||||
lastEvent.uid = firebaseUid;
|
||||
lastEvent.uid = firebaseUid
|
||||
}
|
||||
|
||||
const userUpdate = {
|
||||
lastStripeWebhookEvent: lastEvent,
|
||||
};
|
||||
}
|
||||
|
||||
const billingReason = invoice.billing_reason || null;
|
||||
const isInvoicePaid = invoice.status === "paid";
|
||||
if (
|
||||
billingReason === "subscription_create" ||
|
||||
billingReason === "subscription_cycle"
|
||||
) {
|
||||
const billingReason = invoice.billing_reason || null
|
||||
const isInvoicePaid = invoice.status === 'paid'
|
||||
if (billingReason === 'subscription_create' || billingReason === 'subscription_cycle') {
|
||||
if (isInvoicePaid && invoice.subscription) {
|
||||
userUpdate.subscriptionLastInvoiceAt = getServerTimestamp();
|
||||
userUpdate.subscriptionLastInvoiceAt = getServerTimestamp()
|
||||
}
|
||||
}
|
||||
|
||||
await userRef.set(userUpdate, { merge: true });
|
||||
await userRef.set(userUpdate, { merge: true })
|
||||
|
||||
const subscriptionLine = Array.isArray(invoice?.lines?.data)
|
||||
? invoice.lines.data.find(
|
||||
(line) =>
|
||||
line &&
|
||||
typeof line === "object" &&
|
||||
(line.type === "subscription" || line.price),
|
||||
(line) => line && typeof line === 'object' && (line.type === 'subscription' || line.price)
|
||||
)
|
||||
: null;
|
||||
: null
|
||||
|
||||
const priceId =
|
||||
typeof subscriptionLine?.price?.id === "string"
|
||||
typeof subscriptionLine?.price?.id === 'string'
|
||||
? subscriptionLine.price.id
|
||||
: typeof subscriptionLine?.price === "string"
|
||||
: typeof subscriptionLine?.price === 'string'
|
||||
? subscriptionLine.price
|
||||
: null;
|
||||
: null
|
||||
|
||||
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
|
||||
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
|
||||
const billingPeriod =
|
||||
priceMeta.billingPeriod ||
|
||||
(subscriptionLine?.price?.recurring?.interval === "year"
|
||||
? "annual"
|
||||
: subscriptionLine?.price?.recurring?.interval === "month"
|
||||
? "monthly"
|
||||
: null);
|
||||
(subscriptionLine?.price?.recurring?.interval === 'year'
|
||||
? 'annual'
|
||||
: subscriptionLine?.price?.recurring?.interval === 'month'
|
||||
? 'monthly'
|
||||
: null)
|
||||
|
||||
const coinsPerMonth =
|
||||
priceMeta.coinsPerMonth ??
|
||||
parseCoinsPerMonth(subscriptionLine?.price?.product?.metadata || {}) ??
|
||||
null;
|
||||
null
|
||||
|
||||
const coinsToGrant =
|
||||
billingPeriod === "annual" && typeof coinsPerMonth === "number"
|
||||
? coinsPerMonth * 12
|
||||
: null;
|
||||
billingPeriod === 'annual' && typeof coinsPerMonth === 'number' ? coinsPerMonth * 12 : null
|
||||
|
||||
const targetSubscriptionId =
|
||||
resolvedSubscriptionId ||
|
||||
(typeof invoice.subscription === "string" ? invoice.subscription : null) ||
|
||||
(typeof subscriptionLine?.subscription === "string"
|
||||
? subscriptionLine.subscription
|
||||
: null);
|
||||
(typeof invoice.subscription === 'string' ? invoice.subscription : null) ||
|
||||
(typeof subscriptionLine?.subscription === 'string' ? subscriptionLine.subscription : null)
|
||||
|
||||
const shouldGrantUpfront =
|
||||
isInvoicePaid &&
|
||||
coinsToGrant &&
|
||||
(billingReason === "subscription_create" ||
|
||||
billingReason === "subscription_cycle");
|
||||
(billingReason === 'subscription_create' || billingReason === 'subscription_cycle')
|
||||
|
||||
if (shouldGrantUpfront && targetSubscriptionId) {
|
||||
let grantSnapshot = null;
|
||||
let grantSnapshot = null
|
||||
try {
|
||||
grantSnapshot = await paymentDocRef.get();
|
||||
grantSnapshot = await paymentDocRef.get()
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[subscription-handleInvoiceEvent] Unable to read payment doc before grant",
|
||||
'[subscription-handleInvoiceEvent] Unable to read payment doc before grant',
|
||||
invoice?.id || null,
|
||||
error?.message || error,
|
||||
);
|
||||
error?.message || error
|
||||
)
|
||||
}
|
||||
|
||||
const alreadyGranted =
|
||||
grantSnapshot?.exists &&
|
||||
Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt);
|
||||
grantSnapshot?.exists && Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt)
|
||||
|
||||
if (!alreadyGranted) {
|
||||
const targetUserId = uid || firebaseUid || userRef.id;
|
||||
const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}`;
|
||||
const targetUserId = uid || firebaseUid || userRef.id
|
||||
const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}`
|
||||
|
||||
try {
|
||||
const { orderId: processedOrderId } = await createOrderDocument({
|
||||
@@ -529,139 +480,129 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
|
||||
type: ORDER_TYPES.SUBSCRIPTION,
|
||||
amount: coinsToGrant,
|
||||
metadata: {
|
||||
source: "STRIPE_INVOICE",
|
||||
source: 'STRIPE_INVOICE',
|
||||
billingPeriod,
|
||||
coinsPerMonth,
|
||||
invoiceId: invoice.id || null,
|
||||
subscriptionId: targetSubscriptionId,
|
||||
grantStrategy: "upfront",
|
||||
grantStrategy: 'upfront',
|
||||
},
|
||||
orderId,
|
||||
});
|
||||
})
|
||||
|
||||
await paymentDocRef.set(
|
||||
{
|
||||
subscriptionCoinsGrantedAt: getServerTimestamp(),
|
||||
subscriptionCoinsGrantAmount: coinsToGrant,
|
||||
subscriptionCoinsGrantOrderId: processedOrderId,
|
||||
subscriptionCoinsGrantSource: "invoice_upfront",
|
||||
subscriptionCoinsGrantSource: 'invoice_upfront',
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
await userRef.set(
|
||||
{
|
||||
subscriptionLastGrantAt: getServerTimestamp(),
|
||||
subscriptionLastGrantAmount: coinsToGrant,
|
||||
subscriptionLastGrantOrderId: processedOrderId,
|
||||
subscriptionLastGrantSource: "invoice_upfront",
|
||||
subscriptionLastGrantSource: 'invoice_upfront',
|
||||
subscriptionNextGrantAt: null,
|
||||
subscriptionGrantInterval: null,
|
||||
subscriptionGrantStrategy: "upfront",
|
||||
subscriptionGrantStrategy: 'upfront',
|
||||
subscriptionCoinsPerMonth: coinsPerMonth,
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
{ merge: true }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins",
|
||||
'[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins',
|
||||
{
|
||||
invoiceId: invoice?.id || null,
|
||||
subscriptionId: targetSubscriptionId,
|
||||
error: error?.message || error,
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStripeWebhookEvent = async ({ event, stripe }) => {
|
||||
if (!event || typeof event !== "object") {
|
||||
return;
|
||||
if (!event || typeof event !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const eventType = event.type;
|
||||
const eventType = event.type
|
||||
switch (eventType) {
|
||||
case "checkout.session.completed":
|
||||
case 'checkout.session.completed':
|
||||
await handleCheckoutSessionCompleted(event.data?.object, event, {
|
||||
stripe,
|
||||
});
|
||||
break;
|
||||
case "customer.subscription.created":
|
||||
case "customer.subscription.updated":
|
||||
case "customer.subscription.deleted":
|
||||
})
|
||||
break
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
case 'customer.subscription.deleted':
|
||||
await handleCustomerSubscriptionEvent(event.data?.object, event, {
|
||||
stripe,
|
||||
});
|
||||
break;
|
||||
case "invoice.payment_succeeded":
|
||||
case "invoice.payment_failed":
|
||||
case "invoice.finalized":
|
||||
await handleInvoiceEvent(event.data?.object, event, { stripe });
|
||||
break;
|
||||
})
|
||||
break
|
||||
case 'invoice.payment_succeeded':
|
||||
case 'invoice.payment_failed':
|
||||
case 'invoice.finalized':
|
||||
await handleInvoiceEvent(event.data?.object, event, { stripe })
|
||||
break
|
||||
default:
|
||||
console.log(
|
||||
"[subscription-handleStripeWebhookEvent] Unhandled event type",
|
||||
eventType,
|
||||
);
|
||||
console.log('[subscription-handleStripeWebhookEvent] Unhandled event type', eventType)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
res.status(405).send("Method Not Allowed");
|
||||
return;
|
||||
if (req.method !== 'POST') {
|
||||
res.status(405).send('Method Not Allowed')
|
||||
return
|
||||
}
|
||||
|
||||
const signature = req.headers["stripe-signature"];
|
||||
const signature = req.headers['stripe-signature']
|
||||
if (!signature) {
|
||||
res.status(400).send("Missing Stripe signature");
|
||||
return;
|
||||
res.status(400).send('Missing Stripe signature')
|
||||
return
|
||||
}
|
||||
|
||||
let rawBody = req.rawBody;
|
||||
let rawBody = req.rawBody
|
||||
if (!rawBody && req.body) {
|
||||
rawBody = Buffer.from(JSON.stringify(req.body));
|
||||
rawBody = Buffer.from(JSON.stringify(req.body))
|
||||
}
|
||||
|
||||
if (!rawBody) {
|
||||
res.status(400).send("Missing request body");
|
||||
return;
|
||||
res.status(400).send('Missing request body')
|
||||
return
|
||||
}
|
||||
|
||||
let stripe = null;
|
||||
let stripe = null
|
||||
try {
|
||||
stripe = getStripeClient();
|
||||
stripe = getStripeClient()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[subscription-handleStripeWebhook] Stripe client error",
|
||||
error,
|
||||
);
|
||||
res.status(500).send("Client Stripe indisponible");
|
||||
return;
|
||||
console.error('[subscription-handleStripeWebhook] Stripe client error', error)
|
||||
res.status(500).send('Client Stripe indisponible')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
signature,
|
||||
resolveStripeWebhookSecret(),
|
||||
);
|
||||
const event = stripe.webhooks.constructEvent(rawBody, signature, resolveStripeWebhookSecret())
|
||||
|
||||
await handleStripeWebhookEvent({ event, stripe });
|
||||
await handleStripeWebhookEvent({ event, stripe })
|
||||
|
||||
res.status(200).send({ received: true });
|
||||
res.status(200).send({ received: true })
|
||||
} catch (error) {
|
||||
console.error("[subscription-handleStripeWebhook] error", error);
|
||||
console.error('[subscription-handleStripeWebhook] error', error)
|
||||
if (error instanceof HttpsError) {
|
||||
res.status(400).send(error.message);
|
||||
return;
|
||||
res.status(400).send(error.message)
|
||||
return
|
||||
}
|
||||
res.status(500).send("Erreur lors du traitement du webhook");
|
||||
res.status(500).send('Erreur lors du traitement du webhook')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
handleStripeWebhook,
|
||||
};
|
||||
}
|
||||
|
||||
+72
-83
@@ -1,151 +1,140 @@
|
||||
const { onObjectFinalized } = require("firebase-functions/v2/storage");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const ffmpeg = require("fluent-ffmpeg");
|
||||
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const { refList } = require("../index");
|
||||
const { onObjectFinalized } = require('firebase-functions/v2/storage')
|
||||
const logger = require('firebase-functions/logger')
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const ffmpeg = require('fluent-ffmpeg')
|
||||
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg')
|
||||
const fs = require('node:fs/promises')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const crypto = require('node:crypto')
|
||||
const { refList } = require('../index')
|
||||
|
||||
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
||||
ffmpeg.setFfmpegPath(ffmpegInstaller.path)
|
||||
|
||||
exports.generateVideoThumbnail = onObjectFinalized(
|
||||
{
|
||||
region: "europe-west1",
|
||||
region: 'europe-west1',
|
||||
timeoutSeconds: 180,
|
||||
memory: "1GiB",
|
||||
memory: '1GiB',
|
||||
cpu: 1,
|
||||
},
|
||||
async (event) => {
|
||||
const file = event.data || {};
|
||||
const bucketName = file.bucket;
|
||||
const objectName = file.name || "";
|
||||
const contentType = file.contentType || "";
|
||||
const file = event.data || {}
|
||||
const bucketName = file.bucket
|
||||
const objectName = file.name || ''
|
||||
const contentType = file.contentType || ''
|
||||
|
||||
// Basic guards + helpful logs for debugging why events might be ignored
|
||||
if (!bucketName || !objectName) {
|
||||
logger.info("[Thumbnail] Ignored: missing bucket or object name", {
|
||||
logger.info('[Thumbnail] Ignored: missing bucket or object name', {
|
||||
bucketName,
|
||||
objectName,
|
||||
contentType,
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid processing our own generated thumbnails
|
||||
if (/_thumb9x16\.jpg$/i.test(objectName)) {
|
||||
logger.info("[Thumbnail] Ignored: already a thumbnail", { objectName });
|
||||
return;
|
||||
logger.info('[Thumbnail] Ignored: already a thumbnail', { objectName })
|
||||
return
|
||||
}
|
||||
|
||||
// Accept if contentType says it's a video OR fallback to extension-based check
|
||||
const isVideoContentType =
|
||||
typeof contentType === "string" && contentType.startsWith("video/");
|
||||
const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(
|
||||
objectName.toLowerCase()
|
||||
);
|
||||
const isVideoContentType = typeof contentType === 'string' && contentType.startsWith('video/')
|
||||
const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(objectName.toLowerCase())
|
||||
if (!isVideoContentType && !isVideoLikeName) {
|
||||
logger.info("[Thumbnail] Ignored: not a video", {
|
||||
logger.info('[Thumbnail] Ignored: not a video', {
|
||||
objectName,
|
||||
contentType,
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("[Thumbnail] Event accepted", {
|
||||
logger.info('[Thumbnail] Event accepted', {
|
||||
bucketName,
|
||||
objectName,
|
||||
contentType,
|
||||
});
|
||||
})
|
||||
|
||||
const bucket = admin.storage().bucket(bucketName);
|
||||
const playbackMatch = objectName.match(
|
||||
/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i
|
||||
);
|
||||
const projectId = playbackMatch ? playbackMatch[1] : null;
|
||||
const bucket = admin.storage().bucket(bucketName)
|
||||
const playbackMatch = objectName.match(/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i)
|
||||
const projectId = playbackMatch ? playbackMatch[1] : null
|
||||
|
||||
// Use unique folder under /tmp to avoid name collisions
|
||||
const tmpDir = path.join(
|
||||
os.tmpdir(),
|
||||
`thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`
|
||||
);
|
||||
const baseName = path.basename(objectName);
|
||||
const dirName = path.posix.dirname(objectName);
|
||||
const localVideoPath = path.join(tmpDir, baseName);
|
||||
const tmpDir = path.join(os.tmpdir(), `thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`)
|
||||
const baseName = path.basename(objectName)
|
||||
const dirName = path.posix.dirname(objectName)
|
||||
const localVideoPath = path.join(tmpDir, baseName)
|
||||
|
||||
const thumbBase = baseName.replace(/\.[^.]+$/, "") + "_thumb9x16.jpg";
|
||||
const localThumbPath = path.join(tmpDir, thumbBase);
|
||||
const thumbBase = baseName.replace(/\.[^.]+$/, '') + '_thumb9x16.jpg'
|
||||
const localThumbPath = path.join(tmpDir, thumbBase)
|
||||
const remoteThumbPath =
|
||||
dirName && dirName !== "."
|
||||
? path.posix.join(dirName, thumbBase)
|
||||
: thumbBase;
|
||||
dirName && dirName !== '.' ? path.posix.join(dirName, thumbBase) : thumbBase
|
||||
|
||||
try {
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
await fs.mkdir(tmpDir, { recursive: true })
|
||||
|
||||
// Télécharger la vidéo depuis le bucket
|
||||
await bucket.file(objectName).download({ destination: localVideoPath });
|
||||
await bucket.file(objectName).download({ destination: localVideoPath })
|
||||
|
||||
// Extraire 1 frame en 9:16 (1080x1920) de manière robuste
|
||||
const vfCoverCrop =
|
||||
"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920";
|
||||
const vfCoverCrop = 'scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920'
|
||||
const vfPad =
|
||||
"scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2";
|
||||
'scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2'
|
||||
|
||||
const extractFrame = (ssSeconds, vf) =>
|
||||
new Promise((resolve, reject) => {
|
||||
ffmpeg(localVideoPath)
|
||||
.inputOptions([`-ss ${ssSeconds}`])
|
||||
.frames(1)
|
||||
.outputOptions(["-vf", vf, "-q:v", "2"])
|
||||
.outputOptions(['-vf', vf, '-q:v', '2'])
|
||||
.output(localThumbPath)
|
||||
.on("end", resolve)
|
||||
.on("error", reject)
|
||||
.run();
|
||||
});
|
||||
.on('end', resolve)
|
||||
.on('error', reject)
|
||||
.run()
|
||||
})
|
||||
|
||||
try {
|
||||
// 1) Essai principal: 1s + cover/crop
|
||||
await extractFrame(1, vfCoverCrop);
|
||||
await extractFrame(1, vfCoverCrop)
|
||||
} catch (e1) {
|
||||
logger.warn("[Thumbnail] First attempt failed, retrying at 0s", {
|
||||
logger.warn('[Thumbnail] First attempt failed, retrying at 0s', {
|
||||
objectName,
|
||||
error: e1?.message || String(e1),
|
||||
});
|
||||
})
|
||||
try {
|
||||
// 2) Deuxième essai: 0s + cover/crop (si vidéo très courte)
|
||||
await extractFrame(0, vfCoverCrop);
|
||||
await extractFrame(0, vfCoverCrop)
|
||||
} catch (e2) {
|
||||
logger.warn("[Thumbnail] Second attempt failed, fallback to pad", {
|
||||
logger.warn('[Thumbnail] Second attempt failed, fallback to pad', {
|
||||
objectName,
|
||||
error: e2?.message || String(e2),
|
||||
});
|
||||
})
|
||||
// 3) Fallback: 0s + pad (aucun crop, bandes latérales si besoin)
|
||||
await extractFrame(0, vfPad);
|
||||
await extractFrame(0, vfPad)
|
||||
}
|
||||
}
|
||||
|
||||
// Upload du thumbnail avec un token de téléchargement public Firebase
|
||||
const downloadToken = crypto.randomUUID();
|
||||
const downloadToken = crypto.randomUUID()
|
||||
await bucket.upload(localThumbPath, {
|
||||
destination: remoteThumbPath,
|
||||
metadata: {
|
||||
contentType: "image/jpeg",
|
||||
cacheControl: "public, max-age=86400",
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: 'public, max-age=86400',
|
||||
metadata: {
|
||||
original: objectName,
|
||||
aspect: "9:16",
|
||||
t: "1s",
|
||||
aspect: '9:16',
|
||||
t: '1s',
|
||||
firebaseStorageDownloadTokens: downloadToken,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const encodedPath = encodeURIComponent(remoteThumbPath);
|
||||
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`;
|
||||
const encodedPath = encodeURIComponent(remoteThumbPath)
|
||||
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`
|
||||
|
||||
if (projectId) {
|
||||
await refList.projects.doc(projectId).set(
|
||||
@@ -154,23 +143,23 @@ exports.generateVideoThumbnail = onObjectFinalized(
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
logger.info("✅ [Thumbnail] Uploaded", {
|
||||
logger.info('✅ [Thumbnail] Uploaded', {
|
||||
objectName,
|
||||
remoteThumbPath,
|
||||
projectId,
|
||||
thumbnailUrl,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error("❌ [Thumbnail] Failed", {
|
||||
logger.error('❌ [Thumbnail] Failed', {
|
||||
objectName,
|
||||
error: error?.message || String(error),
|
||||
});
|
||||
throw error;
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
+115
-129
@@ -1,35 +1,35 @@
|
||||
// functions/mergeVideoAndAudio.js (ou dans index.js)
|
||||
|
||||
const { onCall, HttpsError } = require("firebase-functions/v2/https");
|
||||
const admin = require("firebase-admin");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const axios = require("axios");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const ffmpeg = require("fluent-ffmpeg");
|
||||
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
|
||||
const { Buffer } = require("node:buffer");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const { onCall, HttpsError } = require('firebase-functions/v2/https')
|
||||
const admin = require('firebase-admin')
|
||||
const logger = require('firebase-functions/logger')
|
||||
const axios = require('axios')
|
||||
const fs = require('node:fs/promises')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const crypto = require('node:crypto')
|
||||
const ffmpeg = require('fluent-ffmpeg')
|
||||
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg')
|
||||
const { Buffer } = require('node:buffer')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
|
||||
if (!admin.apps.length) admin.initializeApp();
|
||||
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
||||
if (!admin.apps.length) admin.initializeApp()
|
||||
ffmpeg.setFfmpegPath(ffmpegInstaller.path)
|
||||
|
||||
const db = admin.firestore();
|
||||
const PLAYBACK_CODEC_TAG = "h264-v1";
|
||||
const db = admin.firestore()
|
||||
const PLAYBACK_CODEC_TAG = 'h264-v1'
|
||||
|
||||
async function downloadToFile(url, destPath) {
|
||||
if (!/^https?:\/\//i.test(url || "")) {
|
||||
throw new HttpsError("invalid-argument", `URL non supportée: ${url}`);
|
||||
if (!/^https?:\/\//i.test(url || '')) {
|
||||
throw new HttpsError('invalid-argument', `URL non supportée: ${url}`)
|
||||
}
|
||||
const res = await axios.get(url, { responseType: "arraybuffer" });
|
||||
await fs.writeFile(destPath, Buffer.from(res.data));
|
||||
return res.headers?.["content-type"] || "";
|
||||
const res = await axios.get(url, { responseType: 'arraybuffer' })
|
||||
await fs.writeFile(destPath, Buffer.from(res.data))
|
||||
return res.headers?.['content-type'] || ''
|
||||
}
|
||||
|
||||
const SCALE_FILTER =
|
||||
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease";
|
||||
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease"
|
||||
|
||||
async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -37,92 +37,92 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
|
||||
.input(videoPath) // 0:v
|
||||
.input(audioPath) // 1:a
|
||||
.outputOptions([
|
||||
"-map",
|
||||
"0:v:0", // garder la 1re piste vidéo de l'entrée 0
|
||||
"-map",
|
||||
"1:a:0", // prendre la 1re piste audio de l'entrée 1
|
||||
'-map',
|
||||
'0:v:0', // garder la 1re piste vidéo de l'entrée 0
|
||||
'-map',
|
||||
'1:a:0', // prendre la 1re piste audio de l'entrée 1
|
||||
// Force une sortie H264 1080p max pour compatibilité totale iOS (les WebM VP8/9 posaient problème)
|
||||
"-vf",
|
||||
'-vf',
|
||||
SCALE_FILTER,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"22",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-profile:v",
|
||||
"high",
|
||||
"-level:v",
|
||||
"4.1",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-shortest", // couper à la plus courte des 2 sources
|
||||
"-tag:v",
|
||||
"avc1",
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'veryfast',
|
||||
'-crf',
|
||||
'22',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-profile:v',
|
||||
'high',
|
||||
'-level:v',
|
||||
'4.1',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
'192k',
|
||||
'-movflags',
|
||||
'+faststart',
|
||||
'-shortest', // couper à la plus courte des 2 sources
|
||||
'-tag:v',
|
||||
'avc1',
|
||||
])
|
||||
.on("error", reject)
|
||||
.on("end", resolve)
|
||||
.save(outPath);
|
||||
});
|
||||
.on('error', reject)
|
||||
.on('end', resolve)
|
||||
.save(outPath)
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "merge-"));
|
||||
const videoPath = path.join(tmpDir, "video.mp4");
|
||||
const audioPath = path.join(tmpDir, "audio.mp3");
|
||||
const outPath = path.join(tmpDir, "output.mp4");
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'merge-'))
|
||||
const videoPath = path.join(tmpDir, 'video.mp4')
|
||||
const audioPath = path.join(tmpDir, 'audio.mp3')
|
||||
const outPath = path.join(tmpDir, 'output.mp4')
|
||||
|
||||
try {
|
||||
logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl });
|
||||
logger.info('[merge] téléchargement des sources', { videoUrl, audioUrl })
|
||||
|
||||
await downloadToFile(videoUrl, videoPath);
|
||||
await downloadToFile(audioUrl, audioPath);
|
||||
await downloadToFile(videoUrl, videoPath)
|
||||
await downloadToFile(audioUrl, audioPath)
|
||||
|
||||
logger.info("[merge] transcodage/mux ffmpeg");
|
||||
await muxAudioIntoVideo({ videoPath, audioPath, outPath });
|
||||
logger.info('[merge] transcodage/mux ffmpeg')
|
||||
await muxAudioIntoVideo({ videoPath, audioPath, outPath })
|
||||
|
||||
const bucket = admin.storage().bucket();
|
||||
const downloadToken = crypto.randomUUID();
|
||||
const bucket = admin.storage().bucket()
|
||||
const downloadToken = crypto.randomUUID()
|
||||
|
||||
await bucket.upload(outPath, {
|
||||
destination: storagePath,
|
||||
metadata: {
|
||||
contentType: "video/mp4",
|
||||
cacheControl: "public,max-age=86400",
|
||||
contentType: 'video/mp4',
|
||||
cacheControl: 'public,max-age=86400',
|
||||
metadata: { firebaseStorageDownloadTokens: downloadToken },
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
||||
storagePath
|
||||
)}?alt=media&token=${downloadToken}`;
|
||||
)}?alt=media&token=${downloadToken}`
|
||||
|
||||
logger.info("[merge] upload terminé", { storagePath });
|
||||
logger.info('[merge] upload terminé', { storagePath })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
url: fileUrl,
|
||||
contentType: "video/mp4",
|
||||
contentType: 'video/mp4',
|
||||
storagePath,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("[merge] échec", { error: err?.message || String(err) });
|
||||
if (err instanceof HttpsError) throw err;
|
||||
throw new HttpsError("internal", err?.message || "Fusion échouée");
|
||||
logger.error('[merge] échec', { error: err?.message || String(err) })
|
||||
if (err instanceof HttpsError) throw err
|
||||
throw new HttpsError('internal', err?.message || 'Fusion échouée')
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
|
||||
if (!projectId) return;
|
||||
const docRef = db.collection("projects").doc(projectId);
|
||||
if (!projectId) return
|
||||
const docRef = db.collection('projects').doc(projectId)
|
||||
await docRef.set(
|
||||
{
|
||||
playbackCompatibility: {
|
||||
@@ -132,89 +132,75 @@ async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
|
||||
},
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
exports.mergeVideoAndAudio = onCall(
|
||||
{ timeoutSeconds: 540, memory: "1GiB" },
|
||||
{ timeoutSeconds: 540, memory: '1GiB' },
|
||||
async ({ data = {}, auth }) => {
|
||||
const uid = auth?.uid;
|
||||
if (!uid)
|
||||
throw new HttpsError("unauthenticated", "Authentification requise");
|
||||
const uid = auth?.uid
|
||||
if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise')
|
||||
|
||||
const { videoUrl, audioUrl, storagePath, projectId } = data || {};
|
||||
const { videoUrl, audioUrl, storagePath, projectId } = data || {}
|
||||
|
||||
if (!videoUrl || !audioUrl || !storagePath) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
"Requis: { videoUrl, audioUrl, storagePath }"
|
||||
);
|
||||
throw new HttpsError('invalid-argument', 'Requis: { videoUrl, audioUrl, storagePath }')
|
||||
}
|
||||
|
||||
const expectedPrefix = `users/${uid}/`;
|
||||
const expectedPrefix = `users/${uid}/`
|
||||
if (!storagePath.startsWith(expectedPrefix)) {
|
||||
throw new HttpsError(
|
||||
"permission-denied",
|
||||
`storagePath doit commencer par ${expectedPrefix}`
|
||||
);
|
||||
throw new HttpsError('permission-denied', `storagePath doit commencer par ${expectedPrefix}`)
|
||||
}
|
||||
|
||||
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
|
||||
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath })
|
||||
if (projectId) {
|
||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
|
||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
|
||||
}
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
exports.reencodePlayback = onCall(
|
||||
{ timeoutSeconds: 540, memory: "1GiB" },
|
||||
{ timeoutSeconds: 540, memory: '1GiB' },
|
||||
async ({ data = {}, auth }) => {
|
||||
const uid = auth?.uid;
|
||||
if (!uid)
|
||||
throw new HttpsError("unauthenticated", "Authentification requise");
|
||||
const uid = auth?.uid
|
||||
if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise')
|
||||
|
||||
const projectId = data?.projectId;
|
||||
const projectId = data?.projectId
|
||||
if (!projectId) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
"Requis: { projectId } pour relancer le transcodage"
|
||||
);
|
||||
throw new HttpsError('invalid-argument', 'Requis: { projectId } pour relancer le transcodage')
|
||||
}
|
||||
|
||||
logger.info("[reencodePlayback] request received", {
|
||||
logger.info('[reencodePlayback] request received', {
|
||||
projectId,
|
||||
initiator: uid,
|
||||
});
|
||||
const projectRef = db.collection("projects").doc(projectId);
|
||||
const projectSnap = await projectRef.get();
|
||||
})
|
||||
const projectRef = db.collection('projects').doc(projectId)
|
||||
const projectSnap = await projectRef.get()
|
||||
if (!projectSnap.exists) {
|
||||
logger.warn("[reencodePlayback] project not found", {
|
||||
logger.warn('[reencodePlayback] project not found', {
|
||||
projectId,
|
||||
initiator: uid,
|
||||
});
|
||||
throw new HttpsError("not-found", "Projet introuvable");
|
||||
})
|
||||
throw new HttpsError('not-found', 'Projet introuvable')
|
||||
}
|
||||
const project = projectSnap.data() || {};
|
||||
const videoUrl = project.playbackUrl;
|
||||
const audioUrl = project.songUrl;
|
||||
const ownerId = project.userId;
|
||||
const project = projectSnap.data() || {}
|
||||
const videoUrl = project.playbackUrl
|
||||
const audioUrl = project.songUrl
|
||||
const ownerId = project.userId
|
||||
|
||||
if (!videoUrl || !audioUrl || !ownerId) {
|
||||
logger.warn("[reencodePlayback] missing fields", {
|
||||
logger.warn('[reencodePlayback] missing fields', {
|
||||
projectId,
|
||||
hasPlaybackUrl: !!videoUrl,
|
||||
hasSongUrl: !!audioUrl,
|
||||
ownerId,
|
||||
});
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"playbackUrl, songUrl ou userId manquant"
|
||||
);
|
||||
})
|
||||
throw new HttpsError('failed-precondition', 'playbackUrl, songUrl ou userId manquant')
|
||||
}
|
||||
|
||||
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`;
|
||||
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
|
||||
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`
|
||||
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath })
|
||||
|
||||
await projectRef.set(
|
||||
{
|
||||
@@ -222,13 +208,13 @@ exports.reencodePlayback = onCall(
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
|
||||
logger.info("[reencodePlayback] success", {
|
||||
)
|
||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
|
||||
logger.info('[reencodePlayback] success', {
|
||||
projectId,
|
||||
initiator: uid,
|
||||
storagePath,
|
||||
});
|
||||
return result;
|
||||
})
|
||||
return result
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
+65
-79
@@ -1,137 +1,123 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const {
|
||||
onDocumentDeleted,
|
||||
onDocumentCreated,
|
||||
} = require("firebase-functions/firestore");
|
||||
const { refList } = require("../index");
|
||||
const { ORDER_TYPES, createOrderDocument } = require("./helpers/orders");
|
||||
const { deleteFolder } = require("../helpers/firebase");
|
||||
const { Resend } = require("resend");
|
||||
const { welcomeTemplate } = require("../helpers/email");
|
||||
const { RESEND_API_KEY } = require("../config/keys");
|
||||
const { onRequest } = require("firebase-functions/https");
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const { onDocumentDeleted, onDocumentCreated } = require('firebase-functions/firestore')
|
||||
const { refList } = require('../index')
|
||||
const { ORDER_TYPES, createOrderDocument } = require('./helpers/orders')
|
||||
const { deleteFolder } = require('../helpers/firebase')
|
||||
const { Resend } = require('resend')
|
||||
const { welcomeTemplate } = require('../helpers/email')
|
||||
const { RESEND_API_KEY } = require('../config/keys')
|
||||
const { onRequest } = require('firebase-functions/https')
|
||||
|
||||
const resendClient = new Resend(RESEND_API_KEY);
|
||||
const WELCOME_EMAIL_FROM =
|
||||
process.env.RESEND_FROM_EMAIL || "MusicLand <musicland@musicland.ai>";
|
||||
const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
|
||||
const resendClient = new Resend(RESEND_API_KEY)
|
||||
const WELCOME_EMAIL_FROM = process.env.RESEND_FROM_EMAIL || 'MusicLand <musicland@musicland.ai>'
|
||||
const WELCOME_EMAIL_SUBJECT = 'Bienvenue sur MusicLand'
|
||||
|
||||
exports.testWelcomMail = onRequest(async (req, res) => {
|
||||
if (req.method !== "GET") {
|
||||
res.set("Allow", "GET");
|
||||
return res
|
||||
.status(405)
|
||||
.json({ success: false, error: "Method not allowed" });
|
||||
if (req.method !== 'GET') {
|
||||
res.set('Allow', 'GET')
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' })
|
||||
}
|
||||
try {
|
||||
const targetEmail = req.query.email || "tdtomthomas@gmail.com";
|
||||
const firstName = req.query.firstName || "Toto";
|
||||
const lastName = req.query.lastName || "Test";
|
||||
const targetEmail = req.query.email || 'tdtomthomas@gmail.com'
|
||||
const firstName = req.query.firstName || 'Toto'
|
||||
const lastName = req.query.lastName || 'Test'
|
||||
const { data, error } = await resendClient.emails.send({
|
||||
from: WELCOME_EMAIL_FROM,
|
||||
to: [targetEmail],
|
||||
subject: WELCOME_EMAIL_SUBJECT,
|
||||
html: welcomeTemplate({ firstName, lastName }),
|
||||
});
|
||||
})
|
||||
if (error) {
|
||||
console.log("Failed to send welcome email:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, error: error.message || error.toString() });
|
||||
console.log('Failed to send welcome email:', error)
|
||||
return res.status(500).json({ success: false, error: error.message || error.toString() })
|
||||
}
|
||||
console.log("Welcome email sent:", data);
|
||||
return res.status(200).json({ success: true, data });
|
||||
console.log('Welcome email sent:', data)
|
||||
return res.status(200).json({ success: true, data })
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, error: e.message || e.toString() });
|
||||
console.log(e)
|
||||
return res.status(500).json({ success: false, error: e.message || e.toString() })
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
|
||||
exports.onUserCreated = onDocumentCreated('users/{userID}', async (event) => {
|
||||
try {
|
||||
const {
|
||||
email = "",
|
||||
firstName = "",
|
||||
lastName = "",
|
||||
} = event?.data?.data() || {};
|
||||
const { email = '', firstName = '', lastName = '' } = event?.data?.data() || {}
|
||||
|
||||
const userId = event?.params?.userID;
|
||||
const userId = event?.params?.userID
|
||||
|
||||
try {
|
||||
await createOrderDocument({
|
||||
userId,
|
||||
type: ORDER_TYPES.GIFT,
|
||||
amount: 10,
|
||||
metadata: { reason: "WELCOME_BONUS" },
|
||||
metadata: { reason: 'WELCOME_BONUS' },
|
||||
orderId: `welcome_${userId}`,
|
||||
});
|
||||
})
|
||||
} catch (coinError) {
|
||||
console.warn(
|
||||
"[users-onUserCreated] Unable to grant welcome coins",
|
||||
'[users-onUserCreated] Unable to grant welcome coins',
|
||||
event?.params?.userID,
|
||||
coinError?.message || coinError,
|
||||
);
|
||||
coinError?.message || coinError
|
||||
)
|
||||
}
|
||||
if (email) {
|
||||
if (!resendClient) {
|
||||
console.warn("Resend API key not configured; skipping welcome email.");
|
||||
return;
|
||||
console.warn('Resend API key not configured; skipping welcome email.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
console.log(`Sending welcome email to ${email}`);
|
||||
console.log(`Sending welcome email to ${email}`)
|
||||
const { data, error } = await resendClient.emails.send({
|
||||
from: WELCOME_EMAIL_FROM,
|
||||
to: [email],
|
||||
subject: WELCOME_EMAIL_SUBJECT,
|
||||
html: welcomeTemplate({ firstName, lastName }),
|
||||
});
|
||||
})
|
||||
if (error) {
|
||||
console.log("Failed to send welcome email:", error);
|
||||
return;
|
||||
console.log('Failed to send welcome email:', error)
|
||||
return
|
||||
}
|
||||
console.log("Welcome email sent:", data);
|
||||
console.log('Welcome email sent:', data)
|
||||
} catch (error) {
|
||||
console.log("Failed to send welcome email:", error);
|
||||
console.log('Failed to send welcome email:', error)
|
||||
}
|
||||
} else {
|
||||
throw new Error("User created with empty email");
|
||||
throw new Error('User created with empty email')
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
console.log(e)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
exports.onUserDelete = onDocumentDeleted("users/{userID}", async (event) => {
|
||||
exports.onUserDelete = onDocumentDeleted('users/{userID}', async (event) => {
|
||||
try {
|
||||
const userID = event?.params?.userID;
|
||||
await clearAllUserData(userID);
|
||||
const userID = event?.params?.userID
|
||||
await clearAllUserData(userID)
|
||||
|
||||
await deleteFolder(`users/${userID}/`);
|
||||
await deleteFolder(`users/${userID}/`)
|
||||
|
||||
await admin.auth().deleteUser(userID);
|
||||
console.log(`User ${userID} deleted successfully`);
|
||||
await admin.auth().deleteUser(userID)
|
||||
console.log(`User ${userID} deleted successfully`)
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
console.log(e)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
async function clearAllUserData(userID) {
|
||||
const deleteAll = async (ref, key, operator = "==") => {
|
||||
const snapshot = await ref.where(key, operator, userID).get();
|
||||
snapshot.forEach((item) => item.ref.delete());
|
||||
};
|
||||
const deleteAll = async (ref, key, operator = '==') => {
|
||||
const snapshot = await ref.where(key, operator, userID).get()
|
||||
snapshot.forEach((item) => item.ref.delete())
|
||||
}
|
||||
const removeFromArray = async (ref, arrayName) => {
|
||||
const snapshot = await ref.where(arrayName, "array-contains", userID).get();
|
||||
const snapshot = await ref.where(arrayName, 'array-contains', userID).get()
|
||||
snapshot.forEach((item) =>
|
||||
item.ref.update({
|
||||
[arrayName]: FieldValue.arrayRemove(userID),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
await deleteAll(refList.projects, "userId");
|
||||
await deleteAll(refList.playlists, "createdBy");
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
await deleteAll(refList.projects, 'userId')
|
||||
await deleteAll(refList.playlists, 'createdBy')
|
||||
}
|
||||
|
||||
+125
-151
@@ -1,34 +1,29 @@
|
||||
const { onCall, HttpsError } = require("firebase-functions/v2/https");
|
||||
const { defineSecret } = require("firebase-functions/params");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const functions = require("firebase-functions");
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const axios = require("axios");
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { google } = require("googleapis");
|
||||
const { onCall, HttpsError } = require('firebase-functions/v2/https')
|
||||
const { defineSecret } = require('firebase-functions/params')
|
||||
const logger = require('firebase-functions/logger')
|
||||
const functions = require('firebase-functions')
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const axios = require('axios')
|
||||
const fs = require('node:fs')
|
||||
const fsp = require('node:fs/promises')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { google } = require('googleapis')
|
||||
|
||||
// ---- Secrets déclarés (gen2 + Secret Manager)
|
||||
const S_YT_CLIENT_ID = defineSecret("YOUTUBE_CLIENT_ID");
|
||||
const S_YT_CLIENT_SECRET = defineSecret("YOUTUBE_CLIENT_SECRET");
|
||||
const S_YT_REFRESH_TOKEN = defineSecret("YOUTUBE_REFRESH_TOKEN");
|
||||
const S_YT_REDIRECT_URI = defineSecret("YOUTUBE_REDIRECT_URI");
|
||||
const S_YT_PRIVACY_STATUS = defineSecret("YOUTUBE_PRIVACY_STATUS");
|
||||
const S_YT_CATEGORY_ID = defineSecret("YOUTUBE_CATEGORY_ID");
|
||||
const S_YT_CLIENT_ID = defineSecret('YOUTUBE_CLIENT_ID')
|
||||
const S_YT_CLIENT_SECRET = defineSecret('YOUTUBE_CLIENT_SECRET')
|
||||
const S_YT_REFRESH_TOKEN = defineSecret('YOUTUBE_REFRESH_TOKEN')
|
||||
const S_YT_REDIRECT_URI = defineSecret('YOUTUBE_REDIRECT_URI')
|
||||
const S_YT_PRIVACY_STATUS = defineSecret('YOUTUBE_PRIVACY_STATUS')
|
||||
const S_YT_CATEGORY_ID = defineSecret('YOUTUBE_CATEGORY_ID')
|
||||
|
||||
// Firestore
|
||||
const firestore = admin.firestore();
|
||||
const projectsRef = firestore.collection("projects");
|
||||
const firestore = admin.firestore()
|
||||
const projectsRef = firestore.collection('projects')
|
||||
|
||||
const YOUTUBE_IN_PROGRESS_STATUSES = [
|
||||
"PUBLISHING",
|
||||
"UPLOADING",
|
||||
"PROCESSING",
|
||||
"QUEUED",
|
||||
];
|
||||
const YOUTUBE_IN_PROGRESS_STATUSES = ['PUBLISHING', 'UPLOADING', 'PROCESSING', 'QUEUED']
|
||||
|
||||
// Lecture des secrets (recommandé en v2)
|
||||
const getSecretsYoutubeConfig = () =>
|
||||
@@ -40,44 +35,44 @@ const getSecretsYoutubeConfig = () =>
|
||||
redirect_uri: S_YT_REDIRECT_URI.value(),
|
||||
privacy_status: S_YT_PRIVACY_STATUS.value(),
|
||||
category_id: S_YT_CATEGORY_ID.value(),
|
||||
}).filter(([, value]) => value !== undefined && value !== "")
|
||||
);
|
||||
}).filter(([, value]) => value !== undefined && value !== '')
|
||||
)
|
||||
|
||||
// Compat facultative v1 -> renverra {} en v2 (et on log un warn propre)
|
||||
const getLegacyYoutubeConfig = () => {
|
||||
if (typeof functions.config !== "function") {
|
||||
return {};
|
||||
if (typeof functions.config !== 'function') {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
return functions.config()?.youtube || {};
|
||||
return functions.config()?.youtube || {}
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error?.message === "string" &&
|
||||
error.message.includes("functions.config() is no longer available")
|
||||
typeof error?.message === 'string' &&
|
||||
error.message.includes('functions.config() is no longer available')
|
||||
) {
|
||||
logger.warn(
|
||||
"[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)"
|
||||
);
|
||||
return {};
|
||||
'[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)'
|
||||
)
|
||||
return {}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureYoutubeConfig = () => {
|
||||
// Fusionne (par prudence) l’ancienne config et les secrets actuels
|
||||
const firebaseConfig = getLegacyYoutubeConfig();
|
||||
const secretConfig = getSecretsYoutubeConfig();
|
||||
const cfg = { ...firebaseConfig, ...secretConfig };
|
||||
const firebaseConfig = getLegacyYoutubeConfig()
|
||||
const secretConfig = getSecretsYoutubeConfig()
|
||||
const cfg = { ...firebaseConfig, ...secretConfig }
|
||||
|
||||
const requiredKeys = ["client_id", "client_secret", "refresh_token"];
|
||||
const missing = requiredKeys.filter((key) => !cfg[key]);
|
||||
const requiredKeys = ['client_id', 'client_secret', 'refresh_token']
|
||||
const missing = requiredKeys.filter((key) => !cfg[key])
|
||||
if (missing.length) {
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
`Configuration YouTube manquante: ${missing.join(", ")}`
|
||||
);
|
||||
'failed-precondition',
|
||||
`Configuration YouTube manquante: ${missing.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -87,63 +82,54 @@ const ensureYoutubeConfig = () => {
|
||||
redirectUri: cfg.redirect_uri,
|
||||
defaultPrivacyStatus: cfg.privacy_status,
|
||||
defaultCategoryId: cfg.category_id,
|
||||
};
|
||||
};
|
||||
|
||||
const createYoutubeClient = ({
|
||||
clientId,
|
||||
clientSecret,
|
||||
refreshToken,
|
||||
redirectUri,
|
||||
}) => {
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri
|
||||
);
|
||||
oauth2Client.setCredentials({ refresh_token: refreshToken });
|
||||
const youtube = google.youtube({
|
||||
version: "v3",
|
||||
auth: oauth2Client,
|
||||
});
|
||||
return { youtube, oauth2Client };
|
||||
};
|
||||
|
||||
const downloadFile = async (url, destinationPath) => {
|
||||
if (!/^https?:\/\//i.test(url || "")) {
|
||||
throw new HttpsError("invalid-argument", `URL non valide: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
await fsp.mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
const createYoutubeClient = ({ clientId, clientSecret, refreshToken, redirectUri }) => {
|
||||
const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri)
|
||||
oauth2Client.setCredentials({ refresh_token: refreshToken })
|
||||
const youtube = google.youtube({
|
||||
version: 'v3',
|
||||
auth: oauth2Client,
|
||||
})
|
||||
return { youtube, oauth2Client }
|
||||
}
|
||||
|
||||
const response = await axios.get(url, { responseType: "stream" });
|
||||
const downloadFile = async (url, destinationPath) => {
|
||||
if (!/^https?:\/\//i.test(url || '')) {
|
||||
throw new HttpsError('invalid-argument', `URL non valide: ${url}`)
|
||||
}
|
||||
|
||||
await fsp.mkdir(path.dirname(destinationPath), { recursive: true })
|
||||
|
||||
const response = await axios.get(url, { responseType: 'stream' })
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const writer = fs.createWriteStream(destinationPath);
|
||||
response.data.pipe(writer);
|
||||
writer.on("finish", resolve);
|
||||
writer.on("error", reject);
|
||||
});
|
||||
const writer = fs.createWriteStream(destinationPath)
|
||||
response.data.pipe(writer)
|
||||
writer.on('finish', resolve)
|
||||
writer.on('error', reject)
|
||||
})
|
||||
|
||||
return destinationPath;
|
||||
};
|
||||
return destinationPath
|
||||
}
|
||||
|
||||
const buildVideoMetadata = (project, defaults) => {
|
||||
const baseTitle = project?.title || "Création MusicLand";
|
||||
const youtubeTitle = `${baseTitle} | MusicLand`;
|
||||
const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`;
|
||||
const baseTitle = project?.title || 'Création MusicLand'
|
||||
const youtubeTitle = `${baseTitle} | MusicLand`
|
||||
const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`
|
||||
const tags = Array.isArray(project?.youtubeTags)
|
||||
? project.youtubeTags.filter(Boolean).slice(0, 500)
|
||||
: undefined;
|
||||
: undefined
|
||||
|
||||
const snippet = {
|
||||
title: youtubeTitle,
|
||||
description,
|
||||
categoryId: defaults.defaultCategoryId,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags && tags.length) {
|
||||
snippet.tags = tags;
|
||||
snippet.tags = tags
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -153,17 +139,17 @@ const buildVideoMetadata = (project, defaults) => {
|
||||
embeddable: true,
|
||||
selfDeclaredMadeForKids: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
exports.publishPlaybackToYoutube = onCall(
|
||||
{
|
||||
timeoutSeconds: 540,
|
||||
memory: "1GiB",
|
||||
memory: '1GiB',
|
||||
cors: [
|
||||
"http://localhost:8081",
|
||||
"https://musicland-one.vercel.app/",
|
||||
"https://musicland-d33f9.firebaseapp.com",
|
||||
'http://localhost:8081',
|
||||
'https://musicland-one.vercel.app/',
|
||||
'https://musicland-d33f9.firebaseapp.com',
|
||||
],
|
||||
// Secrets requis pour l’exécution (v2)
|
||||
secrets: [
|
||||
@@ -176,98 +162,86 @@ exports.publishPlaybackToYoutube = onCall(
|
||||
],
|
||||
},
|
||||
async ({ data = {}, auth }) => {
|
||||
const uid = auth?.uid;
|
||||
const uid = auth?.uid
|
||||
if (!uid) {
|
||||
throw new HttpsError("unauthenticated", "Authentification requise");
|
||||
throw new HttpsError('unauthenticated', 'Authentification requise')
|
||||
}
|
||||
|
||||
const projectId = data?.projectId;
|
||||
if (!projectId || typeof projectId !== "string") {
|
||||
throw new HttpsError("invalid-argument", "Paramètre projectId requis");
|
||||
const projectId = data?.projectId
|
||||
if (!projectId || typeof projectId !== 'string') {
|
||||
throw new HttpsError('invalid-argument', 'Paramètre projectId requis')
|
||||
}
|
||||
|
||||
const projectSnap = await projectsRef.doc(projectId).get();
|
||||
const projectSnap = await projectsRef.doc(projectId).get()
|
||||
if (!projectSnap.exists) {
|
||||
throw new HttpsError("not-found", "Projet introuvable");
|
||||
throw new HttpsError('not-found', 'Projet introuvable')
|
||||
}
|
||||
|
||||
const project = projectSnap.data();
|
||||
const project = projectSnap.data()
|
||||
if (!project || project.userId !== uid) {
|
||||
throw new HttpsError(
|
||||
"permission-denied",
|
||||
"Vous n'avez pas les droits sur ce projet"
|
||||
);
|
||||
throw new HttpsError('permission-denied', "Vous n'avez pas les droits sur ce projet")
|
||||
}
|
||||
|
||||
if (!project.playbackUrl) {
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"Aucun playback disponible pour la publication"
|
||||
);
|
||||
throw new HttpsError('failed-precondition', 'Aucun playback disponible pour la publication')
|
||||
}
|
||||
|
||||
if (
|
||||
project.youtubeStatus &&
|
||||
YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)
|
||||
) {
|
||||
if (project.youtubeStatus && YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)) {
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"Une publication est déjà en cours pour ce projet"
|
||||
);
|
||||
'failed-precondition',
|
||||
'Une publication est déjà en cours pour ce projet'
|
||||
)
|
||||
}
|
||||
|
||||
let youtubeDefaults;
|
||||
let youtubeClient;
|
||||
let youtubeDefaults
|
||||
let youtubeClient
|
||||
try {
|
||||
youtubeDefaults = ensureYoutubeConfig();
|
||||
youtubeClient = createYoutubeClient(youtubeDefaults);
|
||||
await youtubeClient.oauth2Client.getAccessToken();
|
||||
youtubeDefaults = ensureYoutubeConfig()
|
||||
youtubeClient = createYoutubeClient(youtubeDefaults)
|
||||
await youtubeClient.oauth2Client.getAccessToken()
|
||||
} catch (error) {
|
||||
logger.error("[publishPlaybackToYoutube] configuration invalide", {
|
||||
logger.error('[publishPlaybackToYoutube] configuration invalide', {
|
||||
error: error?.message,
|
||||
});
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"Configuration YouTube invalide ou incomplète"
|
||||
);
|
||||
})
|
||||
throw new HttpsError('failed-precondition', 'Configuration YouTube invalide ou incomplète')
|
||||
}
|
||||
|
||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "yt-upload-"));
|
||||
const videoPath = path.join(tmpDir, "playback.mp4");
|
||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'yt-upload-'))
|
||||
const videoPath = path.join(tmpDir, 'playback.mp4')
|
||||
|
||||
try {
|
||||
await projectsRef.doc(projectId).set(
|
||||
{
|
||||
youtubeStatus: "PUBLISHING",
|
||||
youtubeStatus: 'PUBLISHING',
|
||||
youtubePublished: false,
|
||||
youtubeError: null,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
)
|
||||
|
||||
await downloadFile(project.playbackUrl, videoPath);
|
||||
await downloadFile(project.playbackUrl, videoPath)
|
||||
|
||||
const metadata = buildVideoMetadata(project, youtubeDefaults);
|
||||
const metadata = buildVideoMetadata(project, youtubeDefaults)
|
||||
|
||||
const uploadResponse = await youtubeClient.youtube.videos.insert({
|
||||
part: ["snippet", "status"].join(","),
|
||||
part: ['snippet', 'status'].join(','),
|
||||
requestBody: metadata,
|
||||
media: {
|
||||
body: fs.createReadStream(videoPath),
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const videoId = uploadResponse?.data?.id;
|
||||
const videoId = uploadResponse?.data?.id
|
||||
if (!videoId) {
|
||||
throw new Error("ID de vidéo introuvable dans la réponse YouTube");
|
||||
throw new Error('ID de vidéo introuvable dans la réponse YouTube')
|
||||
}
|
||||
|
||||
const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`
|
||||
|
||||
await projectsRef.doc(projectId).set(
|
||||
{
|
||||
youtubeStatus: "PUBLISHED",
|
||||
youtubeStatus: 'PUBLISHED',
|
||||
youtubePublished: true,
|
||||
youtubeUrl: youtubeLink,
|
||||
youtubeVideoId: videoId,
|
||||
@@ -276,45 +250,45 @@ exports.publishPlaybackToYoutube = onCall(
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
)
|
||||
|
||||
logger.info("[publishPlaybackToYoutube] publication réussie", {
|
||||
logger.info('[publishPlaybackToYoutube] publication réussie', {
|
||||
projectId,
|
||||
videoId,
|
||||
});
|
||||
})
|
||||
|
||||
return {
|
||||
videoId,
|
||||
youtubeUrl: youtubeLink,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[publishPlaybackToYoutube] échec de publication", {
|
||||
logger.error('[publishPlaybackToYoutube] échec de publication', {
|
||||
projectId,
|
||||
error: error?.message,
|
||||
});
|
||||
})
|
||||
|
||||
const errorMessage =
|
||||
error instanceof HttpsError
|
||||
? error.message
|
||||
: error?.message || "Publication YouTube échouée";
|
||||
: error?.message || 'Publication YouTube échouée'
|
||||
|
||||
await projectsRef.doc(projectId).set(
|
||||
{
|
||||
youtubeStatus: "FAILED",
|
||||
youtubeStatus: 'FAILED',
|
||||
youtubePublished: false,
|
||||
youtubeError: errorMessage,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
)
|
||||
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
|
||||
throw new HttpsError("internal", errorMessage);
|
||||
throw new HttpsError('internal', errorMessage)
|
||||
} finally {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { registerRootComponent } from "expo";
|
||||
import App from "./App";
|
||||
import { registerRootComponent } from 'expo'
|
||||
import App from './App'
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
@@ -8,10 +8,10 @@ import App from "./App";
|
||||
function HeadlessCheck({ isHeadless }) {
|
||||
if (isHeadless) {
|
||||
// App has been launched in the background by iOS, ignore
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return <App />;
|
||||
return <App />
|
||||
}
|
||||
|
||||
registerRootComponent(App, () => HeadlessCheck);
|
||||
registerRootComponent(App, () => HeadlessCheck)
|
||||
|
||||
+19
-19
@@ -1,37 +1,37 @@
|
||||
import "@expo/metro-runtime";
|
||||
import { registerRootComponent } from "expo";
|
||||
import '@expo/metro-runtime'
|
||||
import { registerRootComponent } from 'expo'
|
||||
|
||||
import App from "./App";
|
||||
import App from './App'
|
||||
|
||||
// Inject minimal global CSS and a small setNativeProps polyfill for web
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement?.setAttribute("translate", "no");
|
||||
document.body?.setAttribute("translate", "no");
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement?.setAttribute('translate', 'no')
|
||||
document.body?.setAttribute('translate', 'no')
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-inline-global", "true");
|
||||
const style = document.createElement('style')
|
||||
style.setAttribute('data-inline-global', 'true')
|
||||
style.innerHTML = `
|
||||
html, body, #root { height: 100%; }
|
||||
body { margin: 0; }
|
||||
*:focus { outline: none; }
|
||||
* { scrollbar-width: none; -ms-overflow-style: none; }
|
||||
*::-webkit-scrollbar { display: none; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
|
||||
const proto = window.HTMLElement && window.HTMLElement.prototype;
|
||||
if (proto && typeof proto.setNativeProps !== "function") {
|
||||
const proto = window.HTMLElement && window.HTMLElement.prototype
|
||||
if (proto && typeof proto.setNativeProps !== 'function') {
|
||||
proto.setNativeProps = function (nativeProps = {}) {
|
||||
try {
|
||||
const { style: s, pointerEvents, ...rest } = nativeProps || {};
|
||||
const { style: s, pointerEvents, ...rest } = nativeProps || {}
|
||||
if (pointerEvents != null) {
|
||||
this.style.pointerEvents = pointerEvents;
|
||||
this.style.pointerEvents = pointerEvents
|
||||
}
|
||||
if (s && typeof s === "object") {
|
||||
if (s && typeof s === 'object') {
|
||||
for (const k in s) {
|
||||
if (Object.prototype.hasOwnProperty.call(s, k)) {
|
||||
try {
|
||||
this.style[k] = s[k];
|
||||
this.style[k] = s[k]
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,13 @@ if (typeof document !== "undefined") {
|
||||
for (const k in rest) {
|
||||
if (Object.prototype.hasOwnProperty.call(rest, k)) {
|
||||
try {
|
||||
this.style[k] = rest[k];
|
||||
this.style[k] = rest[k]
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerRootComponent(App);
|
||||
registerRootComponent(App)
|
||||
|
||||
+8
-8
@@ -1,18 +1,18 @@
|
||||
const { getDefaultConfig } = require("@expo/metro-config");
|
||||
const { getDefaultConfig } = require('@expo/metro-config')
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
const config = getDefaultConfig(__dirname)
|
||||
|
||||
// Vérifier que .js est bien présent
|
||||
if (!config.resolver.sourceExts.includes("js")) {
|
||||
config.resolver.sourceExts.push("js");
|
||||
if (!config.resolver.sourceExts.includes('js')) {
|
||||
config.resolver.sourceExts.push('js')
|
||||
}
|
||||
|
||||
// Ajouter ce dont tu as besoin
|
||||
config.resolver.sourceExts.push("cjs", "mjs");
|
||||
config.resolver.sourceExts.push('cjs', 'mjs')
|
||||
|
||||
// Assurer que la plateforme web est bien prise en compte en priorité
|
||||
config.resolver.platforms = Array.from(
|
||||
new Set(["web", ...((config.resolver && config.resolver.platforms) || [])])
|
||||
);
|
||||
new Set(['web', ...((config.resolver && config.resolver.platforms) || [])])
|
||||
)
|
||||
|
||||
module.exports = config;
|
||||
module.exports = config
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@
|
||||
"start": "expo start --dev-client",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios -d",
|
||||
"format": "prettier --write \"src/**/*.js\"",
|
||||
"web": "expo start --web",
|
||||
"xcode": "open ios/musicland.xcworkspace",
|
||||
"clean": "npx expo prebuild --clean",
|
||||
@@ -111,7 +112,7 @@
|
||||
"imagemin-jpegtran": "~7.0.0",
|
||||
"imagemin-optipng": "~8.0.0",
|
||||
"imagemin-svgo": "~11.0.1",
|
||||
"prettier": "~3.2.5",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "~5.3.3"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import moment from 'moment';
|
||||
import moment from 'moment'
|
||||
|
||||
export const validateDate = (date = null) => {
|
||||
if (!date) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
if (moment(date).isValid()) {
|
||||
return date;
|
||||
return date
|
||||
}
|
||||
if (typeof date?.toDate === 'function') {
|
||||
return date?.toDate();
|
||||
return date?.toDate()
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function getAge(birthDate) {
|
||||
return moment().diff(birthDate.toDate(), 'years', false);
|
||||
return moment().diff(birthDate.toDate(), 'years', false)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
responsiveHeight as _responsiveHeight,
|
||||
responsiveWidth as _responsiveWidth,
|
||||
responsiveFontSize as _responsiveFontSize,
|
||||
} from "react-native-responsive-dimensions";
|
||||
} from 'react-native-responsive-dimensions'
|
||||
|
||||
import {
|
||||
isWeb,
|
||||
@@ -10,31 +10,31 @@ import {
|
||||
isLargeDesktop,
|
||||
isSmallDesktop,
|
||||
isSuperSmallDesktop,
|
||||
} from "../hooks/useLayoutType";
|
||||
} from '../hooks/useLayoutType'
|
||||
|
||||
export const reductionCoeff = (bypass) =>
|
||||
bypass ? 1 : isLargeDesktop ? 3 : isSmallDesktop ? 2.2 : isDesktop ? 2.8 : 1;
|
||||
bypass ? 1 : isLargeDesktop ? 3 : isSmallDesktop ? 2.2 : isDesktop ? 2.8 : 1
|
||||
|
||||
export const responsiveHeight = (height, bypass = false) => {
|
||||
return _responsiveHeight(height) / reductionCoeff(bypass);
|
||||
};
|
||||
return _responsiveHeight(height) / reductionCoeff(bypass)
|
||||
}
|
||||
|
||||
export const responsiveWidth = (width, bypass = false) => {
|
||||
return _responsiveWidth(width) / reductionCoeff(bypass);
|
||||
};
|
||||
return _responsiveWidth(width) / reductionCoeff(bypass)
|
||||
}
|
||||
|
||||
export const responsiveFontSize = (fontSize, bypass = false) => {
|
||||
if (isWeb) {
|
||||
if (isSuperSmallDesktop) {
|
||||
return fontSize * 6;
|
||||
return fontSize * 6
|
||||
} else if (isSmallDesktop) {
|
||||
return fontSize * 7;
|
||||
return fontSize * 7
|
||||
} else {
|
||||
return fontSize * 7.5;
|
||||
return fontSize * 7.5
|
||||
}
|
||||
} else {
|
||||
return _responsiveFontSize(fontSize) / reductionCoeff(bypass);
|
||||
return _responsiveFontSize(fontSize) / reductionCoeff(bypass)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// test
|
||||
|
||||
@@ -1,86 +1,82 @@
|
||||
export function checkIfEmailIsValid({ email }) {
|
||||
let regex = new RegExp(
|
||||
"([!#-'*+/-9=?A-Z^-~-]+(.[!#-'*+/-9=?A-Z^-~-]+)*|\"([]!#-[^-~ \t]|(\\[\t -~]))+\")@([!#-'*+/-9=?A-Z^-~-]+(.[!#-'*+/-9=?A-Z^-~-]+)*|[[\t -Z^-~]*])"
|
||||
);
|
||||
return regex.test(email);
|
||||
)
|
||||
return regex.test(email)
|
||||
}
|
||||
|
||||
export function checkIfPasswordIsStrongEnough({ password }) {
|
||||
const reg =
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{6,})/;
|
||||
return reg.test(password);
|
||||
const reg = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{6,})/
|
||||
return reg.test(password)
|
||||
}
|
||||
|
||||
export const formatPhoneNumber = ({ phoneNumber = null }) => {
|
||||
if (!phoneNumber) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
let newPhoneNumber = phoneNumber;
|
||||
let newPhoneNumber = phoneNumber
|
||||
|
||||
newPhoneNumber = newPhoneNumber
|
||||
.trim()
|
||||
.replace(/\s+/g, "") // remove spaces
|
||||
.replace(/\D/g, ""); // remove non digits
|
||||
.replace(/\s+/g, '') // remove spaces
|
||||
.replace(/\D/g, '') // remove non digits
|
||||
|
||||
if (newPhoneNumber.startsWith("330")) {
|
||||
newPhoneNumber = newPhoneNumber.substring(2);
|
||||
if (newPhoneNumber.startsWith('330')) {
|
||||
newPhoneNumber = newPhoneNumber.substring(2)
|
||||
}
|
||||
if (newPhoneNumber.startsWith("06") || newPhoneNumber.startsWith("07")) {
|
||||
newPhoneNumber = `+33${newPhoneNumber.slice(1)}`;
|
||||
} else if (
|
||||
newPhoneNumber.startsWith("336") ||
|
||||
newPhoneNumber.startsWith("337")
|
||||
) {
|
||||
newPhoneNumber = `+${newPhoneNumber}`;
|
||||
if (newPhoneNumber.startsWith('06') || newPhoneNumber.startsWith('07')) {
|
||||
newPhoneNumber = `+33${newPhoneNumber.slice(1)}`
|
||||
} else if (newPhoneNumber.startsWith('336') || newPhoneNumber.startsWith('337')) {
|
||||
newPhoneNumber = `+${newPhoneNumber}`
|
||||
} else {
|
||||
newPhoneNumber = null;
|
||||
newPhoneNumber = null
|
||||
}
|
||||
return newPhoneNumber
|
||||
}
|
||||
return newPhoneNumber;
|
||||
};
|
||||
|
||||
export function handleFirebaseError(code = "") {
|
||||
export function handleFirebaseError(code = '') {
|
||||
switch (code) {
|
||||
case "auth/user-not-found":
|
||||
return "Ce compte n'existe pas.";
|
||||
case "auth/user-disabled":
|
||||
return "Ce compte est désactivé. Contacte le support si besoin.";
|
||||
case "auth/invalid-verification-code":
|
||||
return "Ton code de validation est incorrect.";
|
||||
case "auth/provider-already-linked":
|
||||
return "Ce compte est déjà lié à un utilisateur.";
|
||||
case "auth/invalid-credential":
|
||||
case "auth/invalid-login-credential":
|
||||
case "auth/invalid-login-credentials":
|
||||
return "Identifiants incorrects.";
|
||||
case "auth/credential-already-in-use":
|
||||
return "Ce compte existe déjà ou est déjà lié.";
|
||||
case "auth/operation-not-allowed":
|
||||
return "Le fournisseur d'identité n'est pas disponible.";
|
||||
case "auth/invalid-email":
|
||||
return "Adresse e-mail invalide.";
|
||||
case "auth/wrong-password":
|
||||
return "Mot de passe incorrect.";
|
||||
case "auth/invalid-verification-id":
|
||||
return "Impossible de t'authentifier, réessaie dans quelques secondes.";
|
||||
case "auth/invalid-phone-number":
|
||||
return "Numéro de téléphone incorrect.";
|
||||
case "auth/too-many-requests":
|
||||
return "Trop de tentatives. Réessaie dans quelques minutes.";
|
||||
case "auth/email-already-in-use":
|
||||
return "Un compte avec cette adresse mail existe déjà.";
|
||||
case "auth/missing-password":
|
||||
return "Renseigne ton mot de passe.";
|
||||
case "auth/weak-password":
|
||||
return "Ton mot de passe est trop faible.";
|
||||
case "auth/network-request-failed":
|
||||
return "Problème de connexion réseau. Vérifie ta connexion et réessaie.";
|
||||
case "auth/invalid-action-code":
|
||||
case "auth/expired-action-code":
|
||||
case "auth/missing-oob-code":
|
||||
return "Ce lien de réinitialisation n'est plus valide. Demande un nouveau mot de passe.";
|
||||
case "auth/missing-email":
|
||||
return "Renseigne ton adresse e-mail.";
|
||||
case 'auth/user-not-found':
|
||||
return "Ce compte n'existe pas."
|
||||
case 'auth/user-disabled':
|
||||
return 'Ce compte est désactivé. Contacte le support si besoin.'
|
||||
case 'auth/invalid-verification-code':
|
||||
return 'Ton code de validation est incorrect.'
|
||||
case 'auth/provider-already-linked':
|
||||
return 'Ce compte est déjà lié à un utilisateur.'
|
||||
case 'auth/invalid-credential':
|
||||
case 'auth/invalid-login-credential':
|
||||
case 'auth/invalid-login-credentials':
|
||||
return 'Identifiants incorrects.'
|
||||
case 'auth/credential-already-in-use':
|
||||
return 'Ce compte existe déjà ou est déjà lié.'
|
||||
case 'auth/operation-not-allowed':
|
||||
return "Le fournisseur d'identité n'est pas disponible."
|
||||
case 'auth/invalid-email':
|
||||
return 'Adresse e-mail invalide.'
|
||||
case 'auth/wrong-password':
|
||||
return 'Mot de passe incorrect.'
|
||||
case 'auth/invalid-verification-id':
|
||||
return "Impossible de t'authentifier, réessaie dans quelques secondes."
|
||||
case 'auth/invalid-phone-number':
|
||||
return 'Numéro de téléphone incorrect.'
|
||||
case 'auth/too-many-requests':
|
||||
return 'Trop de tentatives. Réessaie dans quelques minutes.'
|
||||
case 'auth/email-already-in-use':
|
||||
return 'Un compte avec cette adresse mail existe déjà.'
|
||||
case 'auth/missing-password':
|
||||
return 'Renseigne ton mot de passe.'
|
||||
case 'auth/weak-password':
|
||||
return 'Ton mot de passe est trop faible.'
|
||||
case 'auth/network-request-failed':
|
||||
return 'Problème de connexion réseau. Vérifie ta connexion et réessaie.'
|
||||
case 'auth/invalid-action-code':
|
||||
case 'auth/expired-action-code':
|
||||
case 'auth/missing-oob-code':
|
||||
return "Ce lien de réinitialisation n'est plus valide. Demande un nouveau mot de passe."
|
||||
case 'auth/missing-email':
|
||||
return 'Renseigne ton adresse e-mail.'
|
||||
default:
|
||||
return "Une erreur est survenue. Réessaie dans quelques instants.";
|
||||
return 'Une erreur est survenue. Réessaie dans quelques instants.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import Svg, { Circle, Path } from "react-native-svg";
|
||||
import * as React from 'react'
|
||||
import Svg, { Circle, Path } from 'react-native-svg'
|
||||
|
||||
function EyeSVG(props) {
|
||||
return (
|
||||
@@ -19,7 +19,7 @@ function EyeSVG(props) {
|
||||
<Path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||
<Circle cx={12} cy={12} r={3} />
|
||||
</Svg>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default EyeSVG;
|
||||
export default EyeSVG
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import * as React from 'react'
|
||||
import Svg, { Path } from 'react-native-svg'
|
||||
|
||||
function EyeSlashSVG(props) {
|
||||
return (
|
||||
@@ -21,7 +21,7 @@ function EyeSlashSVG(props) {
|
||||
<Path d="M12 9a3 3 0 013 3" />
|
||||
<Path d="M9.88 9.88A3 3 0 0012 15a3 3 0 002.12-.88" />
|
||||
</Svg>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default EyeSlashSVG;
|
||||
export default EyeSlashSVG
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { View } from "react-native";
|
||||
import Svg, { ClipPath, Defs, G, Path, Rect } from "react-native-svg";
|
||||
import * as React from 'react'
|
||||
import { View } from 'react-native'
|
||||
import Svg, { ClipPath, Defs, G, Path, Rect } from 'react-native-svg'
|
||||
|
||||
const RestartSpinnerIcon = ({
|
||||
size = 30,
|
||||
color = "#F94697",
|
||||
background = "transparent",
|
||||
color = '#F94697',
|
||||
background = 'transparent',
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
@@ -15,8 +15,8 @@ const RestartSpinnerIcon = ({
|
||||
width: size + 8,
|
||||
height: size + 8,
|
||||
borderRadius: size,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: background,
|
||||
},
|
||||
style,
|
||||
@@ -55,7 +55,7 @@ const RestartSpinnerIcon = ({
|
||||
</Defs>
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default RestartSpinnerIcon;
|
||||
export default RestartSpinnerIcon
|
||||
|
||||
+125
-127
@@ -1,110 +1,110 @@
|
||||
import addTab from "./UI/tabs/add.png";
|
||||
import albums from "./UI/tabs/albums.png";
|
||||
import chat from "./UI/tabs/chat.png";
|
||||
import design from "./UI/tabs/design.png";
|
||||
import home from "./UI/tabs/home.png";
|
||||
import mic from "./UI/tabs/mic.png";
|
||||
import person from "./UI/tabs/person.png";
|
||||
import quotes from "./UI/tabs/quotes.png";
|
||||
import ribbon from "./UI/tabs/ribbon.png";
|
||||
import settings from "./UI/tabs/settings.png";
|
||||
import tasks from "./UI/tabs/tasks.png";
|
||||
import addTab from './UI/tabs/add.png'
|
||||
import albums from './UI/tabs/albums.png'
|
||||
import chat from './UI/tabs/chat.png'
|
||||
import design from './UI/tabs/design.png'
|
||||
import home from './UI/tabs/home.png'
|
||||
import mic from './UI/tabs/mic.png'
|
||||
import person from './UI/tabs/person.png'
|
||||
import quotes from './UI/tabs/quotes.png'
|
||||
import ribbon from './UI/tabs/ribbon.png'
|
||||
import settings from './UI/tabs/settings.png'
|
||||
import tasks from './UI/tabs/tasks.png'
|
||||
|
||||
import bell from "./UI/bell.png";
|
||||
import sort from "./UI/sort.png";
|
||||
import bell from './UI/bell.png'
|
||||
import sort from './UI/sort.png'
|
||||
|
||||
import arrowRight from "./UI/arrowRight.png";
|
||||
import chevronDown from "./UI/chevronDown.png";
|
||||
import threeDots from "./UI/threeDots.png";
|
||||
import arrowRight from './UI/arrowRight.png'
|
||||
import chevronDown from './UI/chevronDown.png'
|
||||
import threeDots from './UI/threeDots.png'
|
||||
|
||||
import thumbDown from "./UI/thumbDown.png";
|
||||
import thumbUp from "./UI/thumbUp.png";
|
||||
import thumbDown from './UI/thumbDown.png'
|
||||
import thumbUp from './UI/thumbUp.png'
|
||||
|
||||
import add from "./UI/add.png";
|
||||
import addFile from "./UI/addFile.png";
|
||||
import check from "./UI/check.png";
|
||||
import checkCircle from "./UI/checkCircle.png";
|
||||
import deliveryTime from "./UI/deliveryTime.png";
|
||||
import edit from "./UI/edit.png";
|
||||
import eye from "./UI/eye.png";
|
||||
import lock from "./UI/lock.png";
|
||||
import message from "./UI/message.png";
|
||||
import search from "./UI/search.png";
|
||||
import send from "./UI/send.png";
|
||||
import shoppingBag from "./UI/shoppingBag.png";
|
||||
import tools from "./UI/tools.png";
|
||||
import trash from "./UI/trash.png";
|
||||
import undo from "./UI/undo.png";
|
||||
import add from './UI/add.png'
|
||||
import addFile from './UI/addFile.png'
|
||||
import check from './UI/check.png'
|
||||
import checkCircle from './UI/checkCircle.png'
|
||||
import deliveryTime from './UI/deliveryTime.png'
|
||||
import edit from './UI/edit.png'
|
||||
import eye from './UI/eye.png'
|
||||
import lock from './UI/lock.png'
|
||||
import message from './UI/message.png'
|
||||
import search from './UI/search.png'
|
||||
import send from './UI/send.png'
|
||||
import shoppingBag from './UI/shoppingBag.png'
|
||||
import tools from './UI/tools.png'
|
||||
import trash from './UI/trash.png'
|
||||
import undo from './UI/undo.png'
|
||||
|
||||
import focus from "./UI/focus.png";
|
||||
import picture from "./UI/picture.png";
|
||||
import screenshot from "./UI/screenshot.png";
|
||||
import focus from './UI/focus.png'
|
||||
import picture from './UI/picture.png'
|
||||
import screenshot from './UI/screenshot.png'
|
||||
|
||||
import android from "./UI/android.png";
|
||||
import ios from "./UI/ios.png";
|
||||
import web from "./UI/web.png";
|
||||
import android from './UI/android.png'
|
||||
import ios from './UI/ios.png'
|
||||
import web from './UI/web.png'
|
||||
|
||||
import law from "./UI/law.png";
|
||||
import support from "./UI/support.png";
|
||||
import user from "./UI/user.png";
|
||||
import law from './UI/law.png'
|
||||
import support from './UI/support.png'
|
||||
import user from './UI/user.png'
|
||||
|
||||
import attachment from "./UI/attachment.png";
|
||||
import attachment from './UI/attachment.png'
|
||||
|
||||
import cloud from "./icons/cloud.png";
|
||||
import dashboard from "./icons/dashboard.png";
|
||||
import file from "./icons/file.png";
|
||||
import cloud from './icons/cloud.png'
|
||||
import dashboard from './icons/dashboard.png'
|
||||
import file from './icons/file.png'
|
||||
|
||||
import algolia from "./icons/algolia.png";
|
||||
import calendar from "./icons/calendar.png";
|
||||
import chatBubble from "./icons/chatBubble.png";
|
||||
import close from "./icons/close.png";
|
||||
import coin from "./icons/coin.png";
|
||||
import disk from "./icons/disk.png";
|
||||
import figma from "./icons/figma.png";
|
||||
import forward from "./icons/forward.png";
|
||||
import gitlab from "./icons/gitlab.png";
|
||||
import heart from "./icons/heart.png";
|
||||
import heartOutline from "./icons/heartOutline.png";
|
||||
import hitParadeLogo from "./icons/hitParadeLogo.png";
|
||||
import more from "./icons/more.png";
|
||||
import musicLandAccueil from "./icons/musicLandAccueil.png";
|
||||
import musicLandLogo from "./icons/musicLandLogo.png";
|
||||
import musicLandProduction from "./icons/musicLandProduction.png";
|
||||
import musicLandStudio from "./icons/musicLandStudio.png";
|
||||
import musicLandVideo from "./icons/musicLandVideo.png";
|
||||
import musicLandWriting from "./icons/musicLandWriting.png";
|
||||
import pause from "./icons/pause.png";
|
||||
import play from "./icons/play.png";
|
||||
import share from "./icons/share.png";
|
||||
import stars from "./icons/stars.png";
|
||||
import algolia from './icons/algolia.png'
|
||||
import calendar from './icons/calendar.png'
|
||||
import chatBubble from './icons/chatBubble.png'
|
||||
import close from './icons/close.png'
|
||||
import coin from './icons/coin.png'
|
||||
import disk from './icons/disk.png'
|
||||
import figma from './icons/figma.png'
|
||||
import forward from './icons/forward.png'
|
||||
import gitlab from './icons/gitlab.png'
|
||||
import heart from './icons/heart.png'
|
||||
import heartOutline from './icons/heartOutline.png'
|
||||
import hitParadeLogo from './icons/hitParadeLogo.png'
|
||||
import more from './icons/more.png'
|
||||
import musicLandAccueil from './icons/musicLandAccueil.png'
|
||||
import musicLandLogo from './icons/musicLandLogo.png'
|
||||
import musicLandProduction from './icons/musicLandProduction.png'
|
||||
import musicLandStudio from './icons/musicLandStudio.png'
|
||||
import musicLandVideo from './icons/musicLandVideo.png'
|
||||
import musicLandWriting from './icons/musicLandWriting.png'
|
||||
import pause from './icons/pause.png'
|
||||
import play from './icons/play.png'
|
||||
import share from './icons/share.png'
|
||||
import stars from './icons/stars.png'
|
||||
|
||||
import hitParadeBG from "./UI/hitParadeBG.png";
|
||||
import homeBG from "./UI/homeBG.png";
|
||||
import libraryBG from "./UI/libraryBG.png";
|
||||
import libraryBG2 from "./UI/libraryBG2.png";
|
||||
import playbackBG from "./UI/playbackBG.png";
|
||||
import playbackBG2 from "./UI/playbackBG2.png";
|
||||
import productionBG from "./UI/productionBG.png";
|
||||
import productionBG2 from "./UI/productionBG2.png";
|
||||
import profileBG from "./UI/profileBG.png";
|
||||
import studioBG from "./UI/studioBG.png";
|
||||
import studioBG2 from "./UI/studioBG2.png";
|
||||
import writingBG from "./UI/writingBG.png";
|
||||
import hitParadeBG from './UI/hitParadeBG.png'
|
||||
import homeBG from './UI/homeBG.png'
|
||||
import libraryBG from './UI/libraryBG.png'
|
||||
import libraryBG2 from './UI/libraryBG2.png'
|
||||
import playbackBG from './UI/playbackBG.png'
|
||||
import playbackBG2 from './UI/playbackBG2.png'
|
||||
import productionBG from './UI/productionBG.png'
|
||||
import productionBG2 from './UI/productionBG2.png'
|
||||
import profileBG from './UI/profileBG.png'
|
||||
import studioBG from './UI/studioBG.png'
|
||||
import studioBG2 from './UI/studioBG2.png'
|
||||
import writingBG from './UI/writingBG.png'
|
||||
|
||||
import bena from "./UI/bena.png";
|
||||
import john from "./UI/john.png";
|
||||
import malik from "./UI/malik.png";
|
||||
import nathalie from "./UI/nathalie.png";
|
||||
import theo from "./UI/theo.png";
|
||||
import bena from './UI/bena.png'
|
||||
import john from './UI/john.png'
|
||||
import malik from './UI/malik.png'
|
||||
import nathalie from './UI/nathalie.png'
|
||||
import theo from './UI/theo.png'
|
||||
|
||||
import { Platform } from "react-native";
|
||||
import goodVibe from "./UI/goodVibe.png";
|
||||
import placeholder from "./UI/placeholder.jpg";
|
||||
import placeholder2 from "./UI/placeholder2.jpg";
|
||||
import placeholder3 from "./UI/placeholder3.png";
|
||||
import placeholder4 from "./UI/placeholder4.jpg";
|
||||
import profile from "./UI/profile.jpg";
|
||||
import musiclandClub from "./UI/musiclandClub.png";
|
||||
import { Platform } from 'react-native'
|
||||
import goodVibe from './UI/goodVibe.png'
|
||||
import placeholder from './UI/placeholder.jpg'
|
||||
import placeholder2 from './UI/placeholder2.jpg'
|
||||
import placeholder3 from './UI/placeholder3.png'
|
||||
import placeholder4 from './UI/placeholder4.jpg'
|
||||
import profile from './UI/profile.jpg'
|
||||
import musiclandClub from './UI/musiclandClub.png'
|
||||
export const tabs = {
|
||||
home,
|
||||
tasks,
|
||||
@@ -117,12 +117,12 @@ export const tabs = {
|
||||
ribbon,
|
||||
mic,
|
||||
person,
|
||||
};
|
||||
}
|
||||
|
||||
export const icons = {
|
||||
bell,
|
||||
sort,
|
||||
dragDots: require("./icons/dragDots.png"),
|
||||
dragDots: require('./icons/dragDots.png'),
|
||||
|
||||
chevronDown,
|
||||
arrowRight,
|
||||
@@ -186,13 +186,13 @@ export const icons = {
|
||||
hitParadeLogo,
|
||||
calendar,
|
||||
coin,
|
||||
club: require("./icons/club.png"),
|
||||
clubIcon: require("./icons/clubIcon.png"),
|
||||
};
|
||||
club: require('./icons/club.png'),
|
||||
clubIcon: require('./icons/clubIcon.png'),
|
||||
}
|
||||
|
||||
export const background = {
|
||||
writingBG,
|
||||
writingBgWeb: require("./UI/writingBgWeb.png"),
|
||||
writingBgWeb: require('./UI/writingBgWeb.png'),
|
||||
studioBG,
|
||||
studioBG2,
|
||||
productionBG,
|
||||
@@ -200,21 +200,21 @@ export const background = {
|
||||
playbackBG,
|
||||
playbackBG2,
|
||||
libraryBG,
|
||||
libraryBgWeb: require("./UI/libraryBgWeb.png"),
|
||||
libraryBgWeb: require('./UI/libraryBgWeb.png'),
|
||||
libraryBG2,
|
||||
libraryBG2Web: require("./UI/libraryBG2Web.png"),
|
||||
libraryBG2Web: require('./UI/libraryBG2Web.png'),
|
||||
profileBG,
|
||||
profileBgWeb: require("./UI/profileBgWeb.png"),
|
||||
profileBgWeb: require('./UI/profileBgWeb.png'),
|
||||
hitParadeBG,
|
||||
hitParadeBG2: require("./UI/hitparadeBG2.jpg"),
|
||||
playbackWeb: require("./UI/playbackWeb.png"),
|
||||
playbackMobile: require("./UI/playbackMobile.png"),
|
||||
hitParadeBG2: require('./UI/hitparadeBG2.jpg'),
|
||||
playbackWeb: require('./UI/playbackWeb.png'),
|
||||
playbackMobile: require('./UI/playbackMobile.png'),
|
||||
homeBG,
|
||||
homeBGWeb: require("./UI/homeBGWeb.png"),
|
||||
loginBgWeb: require("./UI/loginBgWeb.png"),
|
||||
profileWebBG: require("./UI/profileWebBG.jpg"),
|
||||
bgTrans: require("./UI/bgTrans.png"),
|
||||
};
|
||||
homeBGWeb: require('./UI/homeBGWeb.png'),
|
||||
loginBgWeb: require('./UI/loginBgWeb.png'),
|
||||
profileWebBG: require('./UI/profileWebBG.jpg'),
|
||||
bgTrans: require('./UI/bgTrans.png'),
|
||||
}
|
||||
|
||||
export const ai = {
|
||||
nathalie,
|
||||
@@ -222,15 +222,13 @@ export const ai = {
|
||||
malik,
|
||||
bena,
|
||||
john,
|
||||
};
|
||||
}
|
||||
|
||||
export const videos = {
|
||||
test:
|
||||
Platform.OS === "web"
|
||||
? require("./video/testVideoWeb.mp4")
|
||||
: require("./video/testVideo.mp4"),
|
||||
club: require("./video/club.mp4"),
|
||||
};
|
||||
Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'),
|
||||
club: require('./video/club.mp4'),
|
||||
}
|
||||
|
||||
export const img = {
|
||||
placeholder,
|
||||
@@ -240,17 +238,17 @@ export const img = {
|
||||
profile,
|
||||
goodVibe,
|
||||
musiclandClub,
|
||||
};
|
||||
}
|
||||
|
||||
export const cardsImg = {
|
||||
writing: require("./icons/writing.png"),
|
||||
studio: require("./icons/studio.png"),
|
||||
video: require("./icons/video.png"),
|
||||
production: require("./icons/production.png"),
|
||||
};
|
||||
writing: require('./icons/writing.png'),
|
||||
studio: require('./icons/studio.png'),
|
||||
video: require('./icons/video.png'),
|
||||
production: require('./icons/production.png'),
|
||||
}
|
||||
|
||||
export const subBadges = {
|
||||
starter: require("./icons/starterBadge.png"),
|
||||
pro: require("./icons/proBadge.png"),
|
||||
premium: require("./icons/premiumBadge.png"),
|
||||
};
|
||||
starter: require('./icons/starterBadge.png'),
|
||||
pro: require('./icons/proBadge.png'),
|
||||
premium: require('./icons/premiumBadge.png'),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {Dimensions} from 'react-native';
|
||||
import {resWidth} from '../styles';
|
||||
import { Dimensions } from 'react-native'
|
||||
import { resWidth } from '../styles'
|
||||
|
||||
const {width: DIMENSION_WIDTH, height: DIMENSION_HEIGHT} =
|
||||
Dimensions.get('screen');
|
||||
const { width: DIMENSION_WIDTH, height: DIMENSION_HEIGHT } = Dimensions.get('screen')
|
||||
|
||||
const BOTTOM_BAR_HEIGHT = resWidth(80);
|
||||
const BOTTOM_BAR_HEIGHT = resWidth(80)
|
||||
|
||||
export {DIMENSION_WIDTH, DIMENSION_HEIGHT, BOTTOM_BAR_HEIGHT};
|
||||
export { DIMENSION_WIDTH, DIMENSION_HEIGHT, BOTTOM_BAR_HEIGHT }
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export * from './constants';
|
||||
export * from './constants'
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { View } from "react-native";
|
||||
import { View } from 'react-native'
|
||||
|
||||
import { LoaderIndicator } from "../providers/LoadingProvider";
|
||||
import { LoaderIndicator } from '../providers/LoadingProvider'
|
||||
|
||||
export default ({
|
||||
message,
|
||||
defaultMessage = "Chargement...",
|
||||
defaultMessage = 'Chargement...',
|
||||
containerStyle = {},
|
||||
indicatorProps = {},
|
||||
messageStyle = {},
|
||||
@@ -13,8 +13,8 @@ export default ({
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
containerStyle,
|
||||
]}
|
||||
@@ -26,5 +26,5 @@ export default ({
|
||||
indicatorProps={indicatorProps}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
+63
-76
@@ -1,56 +1,48 @@
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import React, { useState } from "react";
|
||||
import { Alert, Platform, StyleSheet, Text, View } from "react-native";
|
||||
import { PortalProvider } from '@gorhom/portal'
|
||||
import React, { useState } from 'react'
|
||||
import { Alert, Platform, StyleSheet, Text, View } from 'react-native'
|
||||
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Fonts, Palette, gutters } from "../styles";
|
||||
import GradientButton from "./GradientButton";
|
||||
import { LinearGradient } from "./LinearGradient/LinearGradient";
|
||||
import Overlay from "./Overlay";
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { Fonts, Palette, gutters } from '../styles'
|
||||
import GradientButton from './GradientButton'
|
||||
import { LinearGradient } from './LinearGradient/LinearGradient'
|
||||
import Overlay from './Overlay'
|
||||
const WebAlertModal = ({ title, description, options }) => {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [visible, setVisible] = useState(true)
|
||||
|
||||
const confirmOption = options?.find(({ style }) => style !== "cancel");
|
||||
const cancelOption = options?.find(({ style }) => style === "cancel");
|
||||
const hasSecondaryAction = Boolean(cancelOption);
|
||||
const buttonContainerStyle = hasSecondaryAction
|
||||
? styles.actionButton
|
||||
: styles.singleActionButton;
|
||||
const confirmOption = options?.find(({ style }) => style !== 'cancel')
|
||||
const cancelOption = options?.find(({ style }) => style === 'cancel')
|
||||
const hasSecondaryAction = Boolean(cancelOption)
|
||||
const buttonContainerStyle = hasSecondaryAction ? styles.actionButton : styles.singleActionButton
|
||||
|
||||
const onConfirm = () => {
|
||||
setVisible(false);
|
||||
confirmOption?.onPress();
|
||||
};
|
||||
setVisible(false)
|
||||
confirmOption?.onPress()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setVisible(false);
|
||||
cancelOption?.onPress();
|
||||
};
|
||||
setVisible(false)
|
||||
cancelOption?.onPress()
|
||||
}
|
||||
|
||||
const renderDescription = () => {
|
||||
if (
|
||||
typeof description === "string" ||
|
||||
typeof description === "number"
|
||||
) {
|
||||
return <Text style={styles.description}>{description}</Text>;
|
||||
if (typeof description === 'string' || typeof description === 'number') {
|
||||
return <Text style={styles.description}>{description}</Text>
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return <View style={styles.customDescription}>{description}</View>;
|
||||
};
|
||||
return <View style={styles.customDescription}>{description}</View>
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalProvider>
|
||||
<Overlay
|
||||
isVisible={visible}
|
||||
contentContainerStyle={styles.overlayContent}
|
||||
>
|
||||
<Overlay isVisible={visible} contentContainerStyle={styles.overlayContent}>
|
||||
<View style={styles.modalWrapper}>
|
||||
<LinearGradient
|
||||
colors={["rgba(255,255,255,0.18)", "rgba(255,255,255,0.04)"]}
|
||||
colors={['rgba(255,255,255,0.18)', 'rgba(255,255,255,0.04)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.modalBorder}
|
||||
@@ -59,24 +51,19 @@ const WebAlertModal = ({ title, description, options }) => {
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{renderDescription()}
|
||||
<View style={styles.divider} />
|
||||
<View
|
||||
style={hasSecondaryAction ? styles.actionsRow : styles.actions}
|
||||
>
|
||||
<View style={hasSecondaryAction ? styles.actionsRow : styles.actions}>
|
||||
{cancelOption && (
|
||||
<GradientButton
|
||||
title={cancelOption.text}
|
||||
onPress={onCancel}
|
||||
colors={[
|
||||
"rgba(255,255,255,0.16)",
|
||||
"rgba(255,255,255,0.08)",
|
||||
]}
|
||||
colors={['rgba(255,255,255,0.16)', 'rgba(255,255,255,0.08)']}
|
||||
textStyle={styles.secondaryButtonText}
|
||||
gradientStyle={styles.secondaryButtonGradient}
|
||||
containerStyle={buttonContainerStyle}
|
||||
/>
|
||||
)}
|
||||
<GradientButton
|
||||
title={confirmOption?.text || "OK"}
|
||||
title={confirmOption?.text || 'OK'}
|
||||
onPress={onConfirm}
|
||||
containerStyle={buttonContainerStyle}
|
||||
/>
|
||||
@@ -86,16 +73,16 @@ const WebAlertModal = ({ title, description, options }) => {
|
||||
</View>
|
||||
</Overlay>
|
||||
</PortalProvider>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const alertPolyfill = (title, description, options, extra) => {
|
||||
const rootDiv = document.createElement("div");
|
||||
document.body.appendChild(rootDiv);
|
||||
const rootDiv = document.createElement('div')
|
||||
document.body.appendChild(rootDiv)
|
||||
|
||||
const closeModal = () => {
|
||||
document.body.removeChild(rootDiv);
|
||||
};
|
||||
document.body.removeChild(rootDiv)
|
||||
}
|
||||
|
||||
const WebAlertComponent = () => (
|
||||
<WebAlertModal
|
||||
@@ -104,38 +91,38 @@ const alertPolyfill = (title, description, options, extra) => {
|
||||
options={options}
|
||||
onDismiss={closeModal}
|
||||
/>
|
||||
);
|
||||
)
|
||||
|
||||
// Render the React component into the div
|
||||
require("react-dom").render(<WebAlertComponent />, rootDiv);
|
||||
};
|
||||
require('react-dom').render(<WebAlertComponent />, rootDiv)
|
||||
}
|
||||
|
||||
const customAlert = (title, description, options, extra) => {
|
||||
// Utilisation de la propriété userInterfaceStyle pour le mettre en mode sombre
|
||||
Alert.alert(title, description, options, {
|
||||
...extra,
|
||||
userInterfaceStyle: "dark",
|
||||
});
|
||||
};
|
||||
userInterfaceStyle: 'dark',
|
||||
})
|
||||
}
|
||||
|
||||
const alert = Platform.OS === "web" ? alertPolyfill : customAlert;
|
||||
const alert = Platform.OS === 'web' ? alertPolyfill : customAlert
|
||||
|
||||
export const showPremiumRequiredAlert = () =>
|
||||
alert(
|
||||
"Accès premium requis",
|
||||
"Vous devez payer pour créer une nouvelle musique."
|
||||
'Accès premium requis',
|
||||
'Vous devez payer pour créer une nouvelle musique.'
|
||||
// [{ text: "OK", style: "cancel" }]
|
||||
);
|
||||
)
|
||||
|
||||
export default alert;
|
||||
export default alert
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlayContent: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalWrapper: {
|
||||
width: "90%",
|
||||
width: '90%',
|
||||
maxWidth: 460,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
@@ -147,49 +134,49 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 23,
|
||||
paddingHorizontal: gutters * 1.8,
|
||||
paddingVertical: gutters,
|
||||
backgroundColor: "rgba(15, 12, 20, 0.9)",
|
||||
overflow: "hidden",
|
||||
backgroundColor: 'rgba(15, 12, 20, 0.9)',
|
||||
overflow: 'hidden',
|
||||
gap: gutters * 0.75,
|
||||
},
|
||||
title: Fonts({
|
||||
type: "mainTitle",
|
||||
type: 'mainTitle',
|
||||
fontSize: 3,
|
||||
style: { textAlign: "center" },
|
||||
style: { textAlign: 'center' },
|
||||
}),
|
||||
description: Fonts({
|
||||
type: "default",
|
||||
type: 'default',
|
||||
color: Palette.gray,
|
||||
fontSize: 2,
|
||||
style: { lineHeight: 22, textAlign: "center" },
|
||||
style: { lineHeight: 22, textAlign: 'center' },
|
||||
}),
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: "rgba(255,255,255,0.08)",
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
marginVertical: 0,
|
||||
},
|
||||
customDescription: {
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: "row",
|
||||
flexDirection: 'row',
|
||||
gap: gutters,
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
},
|
||||
actions: {
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
gap: gutters,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
singleActionButton: {
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
},
|
||||
secondaryButtonGradient: {
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255,255,255,0.2)",
|
||||
borderColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: Palette.gray,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Animated, StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import { Palette } from "../../styles";
|
||||
import React, { useMemo } from 'react'
|
||||
import { Animated, StyleSheet, useWindowDimensions, View } from 'react-native'
|
||||
import { Palette } from '../../styles'
|
||||
|
||||
const ORIENTATION = {
|
||||
HORIZONTAL: "horizontal",
|
||||
VERTICAL: "vertical",
|
||||
};
|
||||
HORIZONTAL: 'horizontal',
|
||||
VERTICAL: 'vertical',
|
||||
}
|
||||
|
||||
const AnimatedPaginationDot = ({
|
||||
data = [],
|
||||
@@ -16,44 +16,32 @@ const AnimatedPaginationDot = ({
|
||||
orientation = ORIENTATION.HORIZONTAL,
|
||||
expandingDotSize = 20,
|
||||
inactiveDotOpacity = 0.4,
|
||||
inactiveDotColor = "rgba(255,255,255,0.4)",
|
||||
inactiveDotColor = 'rgba(255,255,255,0.4)',
|
||||
activeDotColor = Palette.white,
|
||||
baseDotSize = 10,
|
||||
}) => {
|
||||
const animatedValue = useMemo(
|
||||
() => scrollValue || new Animated.Value(0),
|
||||
[scrollValue]
|
||||
);
|
||||
const animatedValue = useMemo(() => scrollValue || new Animated.Value(0), [scrollValue])
|
||||
|
||||
const { width, height } = useWindowDimensions();
|
||||
const { width, height } = useWindowDimensions()
|
||||
const distance = useMemo(() => {
|
||||
if (typeof itemDimension === "number" && itemDimension > 0) {
|
||||
return itemDimension;
|
||||
if (typeof itemDimension === 'number' && itemDimension > 0) {
|
||||
return itemDimension
|
||||
}
|
||||
if (orientation === ORIENTATION.VERTICAL) {
|
||||
return Math.max(height, 1);
|
||||
return Math.max(height, 1)
|
||||
}
|
||||
return Math.max(width, 1);
|
||||
}, [height, itemDimension, orientation, width]);
|
||||
return Math.max(width, 1)
|
||||
}, [height, itemDimension, orientation, width])
|
||||
|
||||
const resolvedDotStyle = useMemo(
|
||||
() => StyleSheet.flatten(dotStyle) || {},
|
||||
[dotStyle]
|
||||
);
|
||||
const defaultSize = Math.max(baseDotSize, 1);
|
||||
const resolvedDotStyle = useMemo(() => StyleSheet.flatten(dotStyle) || {}, [dotStyle])
|
||||
const defaultSize = Math.max(baseDotSize, 1)
|
||||
const baseWidth =
|
||||
typeof resolvedDotStyle?.width === "number"
|
||||
? resolvedDotStyle.width
|
||||
: defaultSize;
|
||||
typeof resolvedDotStyle?.width === 'number' ? resolvedDotStyle.width : defaultSize
|
||||
const baseHeight =
|
||||
typeof resolvedDotStyle?.height === "number"
|
||||
? resolvedDotStyle.height
|
||||
: defaultSize;
|
||||
typeof resolvedDotStyle?.height === 'number' ? resolvedDotStyle.height : defaultSize
|
||||
|
||||
const containerOrientationStyle =
|
||||
orientation === ORIENTATION.VERTICAL
|
||||
? styles.containerVertical
|
||||
: styles.containerHorizontal;
|
||||
orientation === ORIENTATION.VERTICAL ? styles.containerVertical : styles.containerHorizontal
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -61,28 +49,24 @@ const AnimatedPaginationDot = ({
|
||||
style={[styles.containerBase, containerOrientationStyle, containerStyle]}
|
||||
>
|
||||
{data.map((_, index) => {
|
||||
const inputRange = [
|
||||
(index - 1) * distance,
|
||||
index * distance,
|
||||
(index + 1) * distance,
|
||||
];
|
||||
const inputRange = [(index - 1) * distance, index * distance, (index + 1) * distance]
|
||||
|
||||
const staticSizeStyle = {
|
||||
width: baseWidth,
|
||||
height: baseHeight,
|
||||
};
|
||||
}
|
||||
|
||||
const color = animatedValue.interpolate({
|
||||
inputRange,
|
||||
outputRange: [inactiveDotColor, activeDotColor, inactiveDotColor],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
extrapolate: 'clamp',
|
||||
})
|
||||
|
||||
const opacity = animatedValue.interpolate({
|
||||
inputRange,
|
||||
outputRange: [inactiveDotOpacity, 1, inactiveDotOpacity],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
extrapolate: 'clamp',
|
||||
})
|
||||
|
||||
const primarySize = animatedValue.interpolate({
|
||||
inputRange,
|
||||
@@ -91,13 +75,11 @@ const AnimatedPaginationDot = ({
|
||||
expandingDotSize,
|
||||
orientation === ORIENTATION.VERTICAL ? baseHeight : baseWidth,
|
||||
],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
extrapolate: 'clamp',
|
||||
})
|
||||
|
||||
const animatedSizeStyle =
|
||||
orientation === ORIENTATION.VERTICAL
|
||||
? { height: primarySize }
|
||||
: { width: primarySize };
|
||||
orientation === ORIENTATION.VERTICAL ? { height: primarySize } : { width: primarySize }
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
@@ -110,30 +92,30 @@ const AnimatedPaginationDot = ({
|
||||
{ backgroundColor: color, opacity },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedPaginationDot.orientation = ORIENTATION;
|
||||
AnimatedPaginationDot.orientation = ORIENTATION
|
||||
|
||||
export default AnimatedPaginationDot;
|
||||
export default AnimatedPaginationDot
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
containerBase: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
containerHorizontal: {
|
||||
flexDirection: "row",
|
||||
flexDirection: 'row',
|
||||
},
|
||||
containerVertical: {
|
||||
flexDirection: "column",
|
||||
flexDirection: 'column',
|
||||
},
|
||||
dotBase: {
|
||||
borderRadius: 999,
|
||||
marginHorizontal: 4,
|
||||
marginVertical: 4,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useCallback } from "react";
|
||||
import { Platform, Pressable, StyleSheet, View } from "react-native";
|
||||
import ActionSheet, { SheetManager } from "react-native-actions-sheet";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { isWeb } from "../hooks/useLayoutType";
|
||||
import { gutters, Palette } from "../styles";
|
||||
import { Portal } from '@gorhom/portal'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { useCallback } from 'react'
|
||||
import { Platform, Pressable, StyleSheet, View } from 'react-native'
|
||||
import ActionSheet, { SheetManager } from 'react-native-actions-sheet'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { isWeb } from '../hooks/useLayoutType'
|
||||
import { gutters, Palette } from '../styles'
|
||||
|
||||
const AppActionSheet = ({
|
||||
id,
|
||||
children,
|
||||
webModal = false,
|
||||
onClose = () => {},
|
||||
...sheetProps
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const AppActionSheet = ({ id, children, webModal = false, onClose = () => {}, ...sheetProps }) => {
|
||||
const insets = useSafeAreaInsets()
|
||||
const handleRequestClose = useCallback(() => {
|
||||
if (id) {
|
||||
Promise.resolve(SheetManager.hide(id))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
onClose?.();
|
||||
});
|
||||
return;
|
||||
onClose?.()
|
||||
})
|
||||
return
|
||||
}
|
||||
onClose?.();
|
||||
}, [id, onClose]);
|
||||
onClose?.()
|
||||
}, [id, onClose])
|
||||
|
||||
if (isWeb && webModal) {
|
||||
return (
|
||||
@@ -43,7 +37,7 @@ const AppActionSheet = ({
|
||||
</View>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -62,7 +56,7 @@ const AppActionSheet = ({
|
||||
{...sheetProps}
|
||||
>
|
||||
<BlurView
|
||||
intensity={Platform.OS === "ios" || isWeb ? 20 : 10}
|
||||
intensity={Platform.OS === 'ios' || isWeb ? 20 : 10}
|
||||
style={{
|
||||
paddingTop: 36,
|
||||
paddingHorizontal: 14,
|
||||
@@ -70,7 +64,7 @@ const AppActionSheet = ({
|
||||
backgroundColor: Palette.glass,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
// experimentalBlurMethod={
|
||||
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
||||
@@ -79,33 +73,33 @@ const AppActionSheet = ({
|
||||
{children}
|
||||
</BlurView>
|
||||
</ActionSheet>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default AppActionSheet;
|
||||
export default AppActionSheet
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
webOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
position: "fixed",
|
||||
position: 'fixed',
|
||||
zIndex: 100,
|
||||
},
|
||||
webBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.55)",
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||
},
|
||||
webModalWrapper: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
webModalCard: {
|
||||
width: 480,
|
||||
maxWidth: "90%",
|
||||
maxWidth: '90%',
|
||||
borderRadius: 28,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
backgroundColor: Palette.glass,
|
||||
padding: 32,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import React from "react";
|
||||
import { Palette, Style } from "../styles";
|
||||
import { size } from "../styles/Style";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
import { View, Text, Pressable } from 'react-native'
|
||||
import React from 'react'
|
||||
import { Palette, Style } from '../styles'
|
||||
import { size } from '../styles/Style'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
|
||||
const AppCheckbox = ({ onPress, selected, label }) => {
|
||||
return (
|
||||
@@ -42,7 +42,7 @@ const AppCheckbox = ({ onPress, selected, label }) => {
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default AppCheckbox;
|
||||
export default AppCheckbox
|
||||
|
||||
@@ -1,93 +1,85 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Image,
|
||||
Linking,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { Image, Linking, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||
|
||||
import { icons } from "../assets";
|
||||
import { appleAppStoreUrl, googlePlayStoreUrl } from "../data";
|
||||
import useLayoutType from "../hooks/useLayoutType";
|
||||
import { Palette, gutters } from "../styles";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
import { icons } from '../assets'
|
||||
import { appleAppStoreUrl, googlePlayStoreUrl } from '../data'
|
||||
import useLayoutType from '../hooks/useLayoutType'
|
||||
import { Palette, gutters } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
|
||||
const STORAGE_KEY = "appDownloadBanner:dismissed";
|
||||
const STORAGE_KEY = 'appDownloadBanner:dismissed'
|
||||
|
||||
const AppDownloadBanner = () => {
|
||||
const { isMobileWeb } = useLayoutType();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
const { isMobileWeb } = useLayoutType()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [hasHydrated, setHasHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let mounted = true
|
||||
|
||||
if (!isMobileWeb) {
|
||||
setIsVisible(false);
|
||||
setHasHydrated(false);
|
||||
return undefined;
|
||||
setIsVisible(false)
|
||||
setHasHydrated(false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
AsyncStorage.getItem(STORAGE_KEY)
|
||||
.then((value) => {
|
||||
if (!mounted) return;
|
||||
setIsVisible(value !== "hidden");
|
||||
setHasHydrated(true);
|
||||
if (!mounted) return
|
||||
setIsVisible(value !== 'hidden')
|
||||
setHasHydrated(true)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setIsVisible(true);
|
||||
setHasHydrated(true);
|
||||
});
|
||||
if (!mounted) return
|
||||
setIsVisible(true)
|
||||
setHasHydrated(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [isMobileWeb]);
|
||||
mounted = false
|
||||
}
|
||||
}, [isMobileWeb])
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setIsVisible(false);
|
||||
AsyncStorage.setItem(STORAGE_KEY, "hidden").catch(() => {});
|
||||
}, []);
|
||||
setIsVisible(false)
|
||||
AsyncStorage.setItem(STORAGE_KEY, 'hidden').catch(() => {})
|
||||
}, [])
|
||||
|
||||
const openLink = useCallback((url) => {
|
||||
if (typeof url !== "string") return;
|
||||
const target = url.trim();
|
||||
if (!target) return;
|
||||
if (typeof url !== 'string') return
|
||||
const target = url.trim()
|
||||
if (!target) return
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
if (Platform.OS === 'web') {
|
||||
try {
|
||||
window.open(target, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
window.open(target, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
} catch (error) {
|
||||
console.warn("AppDownloadBanner: failed to open link in new tab", error);
|
||||
console.warn('AppDownloadBanner: failed to open link in new tab', error)
|
||||
}
|
||||
}
|
||||
|
||||
Linking.openURL(target).catch((error) => {
|
||||
console.warn("AppDownloadBanner: failed to open store link", error);
|
||||
});
|
||||
}, []);
|
||||
console.warn('AppDownloadBanner: failed to open store link', error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleOpenStore = useCallback(
|
||||
(store) => {
|
||||
if (store === "ios") {
|
||||
openLink(appleAppStoreUrl);
|
||||
return;
|
||||
if (store === 'ios') {
|
||||
openLink(appleAppStoreUrl)
|
||||
return
|
||||
}
|
||||
if (store === "android") {
|
||||
openLink(googlePlayStoreUrl);
|
||||
if (store === 'android') {
|
||||
openLink(googlePlayStoreUrl)
|
||||
}
|
||||
},
|
||||
[openLink],
|
||||
);
|
||||
[openLink]
|
||||
)
|
||||
|
||||
if (!isMobileWeb || !isVisible || !hasHydrated) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -101,8 +93,8 @@ const AppDownloadBanner = () => {
|
||||
<View style={styles.titleContent}>
|
||||
<Text style={styles.title}>Télécharge l'app MusicLand</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Pour une expérience mobile plus fluide, utilise l'application
|
||||
native et retrouve toutes les fonctionnalités.
|
||||
Pour une expérience mobile plus fluide, utilise l'application native et retrouve
|
||||
toutes les fonctionnalités.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -119,38 +111,38 @@ const AppDownloadBanner = () => {
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.cta, styles.appStoreCta]}
|
||||
onPress={() => handleOpenStore("ios")}
|
||||
onPress={() => handleOpenStore('ios')}
|
||||
>
|
||||
<Text style={styles.ctaText}>App Store</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.cta, styles.playStoreCta]}
|
||||
onPress={() => handleOpenStore("android")}
|
||||
onPress={() => handleOpenStore('android')}
|
||||
>
|
||||
<Text style={styles.ctaText}>Google Play</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "fixed",
|
||||
position: 'fixed',
|
||||
bottom: gutters,
|
||||
left: gutters,
|
||||
right: gutters,
|
||||
alignItems: "center",
|
||||
alignItems: 'center',
|
||||
zIndex: 80,
|
||||
},
|
||||
banner: {
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
maxWidth: 520,
|
||||
borderRadius: 18,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: "rgba(12, 10, 16, 0.95)",
|
||||
backgroundColor: 'rgba(12, 10, 16, 0.95)',
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.ultraLightWhite,
|
||||
shadowColor: Palette.black,
|
||||
@@ -160,29 +152,29 @@ const styles = StyleSheet.create({
|
||||
elevation: 10,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
titleRow: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoWrapper: {
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.06)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.06)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.ultraLightWhite,
|
||||
},
|
||||
logo: {
|
||||
width: "80%",
|
||||
height: "80%",
|
||||
resizeMode: "contain",
|
||||
width: '80%',
|
||||
height: '80%',
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
titleContent: {
|
||||
flex: 1,
|
||||
@@ -210,16 +202,16 @@ const styles = StyleSheet.create({
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
cta: {
|
||||
flex: 1,
|
||||
height: 46,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.ultraLightWhite,
|
||||
},
|
||||
@@ -237,6 +229,6 @@ const styles = StyleSheet.create({
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export default AppDownloadBanner;
|
||||
export default AppDownloadBanner
|
||||
|
||||
+20
-20
@@ -1,31 +1,31 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import { responsiveWidth } from "../actions/responsiveSizes.js";
|
||||
import { formatImageURL, getInitials } from "../helpers";
|
||||
import { Fonts, Palette, Style } from "../styles";
|
||||
import Badge from "./Badge.js";
|
||||
import { Text, View } from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
import { responsiveWidth } from '../actions/responsiveSizes.js'
|
||||
import { formatImageURL, getInitials } from '../helpers'
|
||||
import { Fonts, Palette, Style } from '../styles'
|
||||
import Badge from './Badge.js'
|
||||
|
||||
export default ({
|
||||
name = "",
|
||||
name = '',
|
||||
url = null,
|
||||
size = responsiveWidth(7),
|
||||
containerStyle = {},
|
||||
forceRawImage = false,
|
||||
badge = {},
|
||||
}) => {
|
||||
const uniqColorById = (uniqId = "test") => {
|
||||
const uniqColorById = (uniqId = 'test') => {
|
||||
// Calculer un nombre unique à partir de l'ID de l'employé
|
||||
let uniqueNumber = 0;
|
||||
let uniqueNumber = 0
|
||||
|
||||
for (let i = 0; i < uniqId?.length; i++) {
|
||||
uniqueNumber += uniqId.charCodeAt(i);
|
||||
uniqueNumber += uniqId.charCodeAt(i)
|
||||
}
|
||||
|
||||
// génère des couleurs pastels claires
|
||||
const color = `hsl(${uniqueNumber % 360}, 100%, 90%)`;
|
||||
const color = `hsl(${uniqueNumber % 360}, 100%, 90%)`
|
||||
|
||||
return color;
|
||||
};
|
||||
return color
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -39,14 +39,14 @@ export default ({
|
||||
<View
|
||||
style={{
|
||||
...Style.containerRound,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: url ? "transparent" : uniqColorById(name),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: url ? 'transparent' : uniqColorById(name),
|
||||
}}
|
||||
>
|
||||
{url ? (
|
||||
<Image
|
||||
cachePolicy={"memory"}
|
||||
cachePolicy={'memory'}
|
||||
source={{
|
||||
uri: forceRawImage ? url : formatImageURL({ url, size: 200 }),
|
||||
}}
|
||||
@@ -59,7 +59,7 @@ export default ({
|
||||
) : (
|
||||
<Text
|
||||
style={Fonts({
|
||||
type: "section",
|
||||
type: 'section',
|
||||
color: Palette.darkPurple,
|
||||
style: { fontSize: size / 3 },
|
||||
})}
|
||||
@@ -70,5 +70,5 @@ export default ({
|
||||
</View>
|
||||
<Badge {...badge} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Image, View } from 'react-native'
|
||||
import { subBadges } from '../../assets'
|
||||
export default function SubscriptionAvatar({ user }) {
|
||||
const subscriptionLevel = user.premiumLevel
|
||||
|
||||
switch (subscriptionLevel) {
|
||||
case 'starter':
|
||||
return <Image source={subBadges.starter} />
|
||||
case 'pro':
|
||||
return <Image source={subBadges.pro} />
|
||||
case 'premium':
|
||||
return <Image source={subBadges.premium} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
+10
-15
@@ -1,24 +1,19 @@
|
||||
import { Motion } from "@legendapp/motion";
|
||||
import { Motion } from '@legendapp/motion'
|
||||
|
||||
import { Fonts, Palette, Style } from "../styles";
|
||||
import { Fonts, Palette, Style } from '../styles'
|
||||
|
||||
const Badge = ({
|
||||
count = 0,
|
||||
size = 20,
|
||||
customContent = null,
|
||||
backgroundColor = null,
|
||||
} = {}) => {
|
||||
const Badge = ({ count = 0, size = 20, customContent = null, backgroundColor = null } = {}) => {
|
||||
if (!count && !customContent) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Motion.View
|
||||
animate={{ scale: 1 }}
|
||||
initial={{ scale: 0 }}
|
||||
transition={{ type: "tween", duration: 0.5 }}
|
||||
transition={{ type: 'tween', duration: 0.5 }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: -size / 4,
|
||||
right: -size / 4,
|
||||
backgroundColor: backgroundColor || Palette.red,
|
||||
@@ -34,7 +29,7 @@ const Badge = ({
|
||||
<Motion.Text
|
||||
animate={{ scale: 1 }}
|
||||
initial={{ scale: 0 }}
|
||||
transition={{ type: "tween", duration: 0.5 }}
|
||||
transition={{ type: 'tween', duration: 0.5 }}
|
||||
style={{
|
||||
...Fonts({ color: Palette.white }),
|
||||
}}
|
||||
@@ -43,7 +38,7 @@ const Badge = ({
|
||||
</Motion.Text>
|
||||
)}
|
||||
</Motion.View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Badge;
|
||||
export default Badge
|
||||
|
||||
@@ -1,40 +1,33 @@
|
||||
import { Image, Pressable, Text, View } from "react-native";
|
||||
import React from "reactn";
|
||||
import { responsiveWidth } from "../actions/responsiveSizes.js";
|
||||
import { Image, Pressable, Text, View } from 'react-native'
|
||||
import React from 'reactn'
|
||||
import { responsiveWidth } from '../actions/responsiveSizes.js'
|
||||
|
||||
import { icons } from "../assets";
|
||||
import { Fonts, Style, gutters } from "../styles";
|
||||
import { icons } from '../assets'
|
||||
import { Fonts, Style, gutters } from '../styles'
|
||||
|
||||
import { Routes } from "../navigation";
|
||||
import { navigate } from "../navigation/NavigationService";
|
||||
import { Routes } from '../navigation'
|
||||
import { navigate } from '../navigation/NavigationService'
|
||||
|
||||
import useLayoutType, { sidebarWidth } from "../hooks/useLayoutType.js";
|
||||
import useNotifications from "../hooks/useNotifications";
|
||||
import useLayoutType, { sidebarWidth } from '../hooks/useLayoutType.js'
|
||||
import useNotifications from '../hooks/useNotifications'
|
||||
|
||||
export default ({ title = "", containerStyle = {}, bell = false }) => {
|
||||
const { isDesktop } = useLayoutType();
|
||||
const notificationsContext = useNotifications();
|
||||
const unreadCount = notificationsContext?.unreadCount || 0;
|
||||
const hasUnread = unreadCount > 0;
|
||||
export default ({ title = '', containerStyle = {}, bell = false }) => {
|
||||
const { isDesktop } = useLayoutType()
|
||||
const notificationsContext = useNotifications()
|
||||
const unreadCount = notificationsContext?.unreadCount || 0
|
||||
const hasUnread = unreadCount > 0
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
Style.containerSpaceBetween,
|
||||
{ marginBottom: 20, ...containerStyle },
|
||||
]}
|
||||
>
|
||||
<View style={[Style.containerSpaceBetween, { marginBottom: 20, ...containerStyle }]}>
|
||||
<View style={Style.containerRow}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
...Fonts({
|
||||
type: "mainTitle",
|
||||
type: 'mainTitle',
|
||||
style: {
|
||||
marginRight: 10,
|
||||
maxWidth: isDesktop
|
||||
? sidebarWidth - 4 * gutters
|
||||
: responsiveWidth(70),
|
||||
maxWidth: isDesktop ? sidebarWidth - 4 * gutters : responsiveWidth(70),
|
||||
},
|
||||
}),
|
||||
}}
|
||||
@@ -65,5 +58,5 @@ export default ({ title = "", containerStyle = {}, bell = false }) => {
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Image,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import React from "react";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Palette, Style } from "../styles";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
import { icons } from "../assets";
|
||||
import { size } from "../styles/Style";
|
||||
import { View, Text, Pressable, StyleSheet, Image, Platform } from 'react-native'
|
||||
import React from 'react'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { Palette, Style } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
import { icons } from '../assets'
|
||||
import { size } from '../styles/Style'
|
||||
|
||||
const BlurItemButton = ({ onPress, title = "" }) => {
|
||||
const BlurItemButton = ({ onPress, title = '' }) => {
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<BlurView
|
||||
intensity={Platform.OS === "ios" ? 20 : 10}
|
||||
intensity={Platform.OS === 'ios' ? 20 : 10}
|
||||
style={styles.buttonContainer}
|
||||
// experimentalBlurMethod={
|
||||
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
||||
@@ -28,16 +21,16 @@ const BlurItemButton = ({ onPress, title = "" }) => {
|
||||
source={icons.chevronDown}
|
||||
style={{
|
||||
...size({ size: 15 }),
|
||||
transform: [{ rotate: "-90deg" }],
|
||||
transform: [{ rotate: '-90deg' }],
|
||||
}}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</BlurView>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default BlurItemButton;
|
||||
export default BlurItemButton
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
buttonContainer: {
|
||||
@@ -45,7 +38,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 12,
|
||||
height: 56,
|
||||
borderRadius: 14,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
backgroundColor: Palette.glass,
|
||||
},
|
||||
buttonText: {
|
||||
@@ -53,4 +46,4 @@ const styles = StyleSheet.create({
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
|
||||
import { GradientBorderView } from "../GradientBorderView";
|
||||
import { GradientBorderView } from '../GradientBorderView'
|
||||
|
||||
const BorderGradient = ({
|
||||
children,
|
||||
contentContainerStyle,
|
||||
gradientProps,
|
||||
...props
|
||||
}) => {
|
||||
const BorderGradient = ({ children, contentContainerStyle, gradientProps, ...props }) => {
|
||||
return (
|
||||
<GradientBorderView
|
||||
gradientProps={{
|
||||
@@ -26,13 +21,13 @@ const BorderGradient = ({
|
||||
{children}
|
||||
</View>
|
||||
</GradientBorderView>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default BorderGradient;
|
||||
export default BorderGradient
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
innerContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useState } from "react";
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { useState } from 'react'
|
||||
|
||||
const BorderGradient = ({ children, gradientProps, ...props }) => {
|
||||
const defaultGradientProps = {
|
||||
@@ -15,10 +15,9 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
|
||||
colors: [],
|
||||
useAngle: false,
|
||||
angle: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const { locations, end, start, useAngle, angle, onLayout, colors } =
|
||||
gradientProps;
|
||||
const { locations, end, start, useAngle, angle, onLayout, colors } = gradientProps
|
||||
|
||||
const {
|
||||
style,
|
||||
@@ -32,29 +31,29 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
|
||||
borderLeftWidth,
|
||||
borderRightWidth,
|
||||
borderBottomWidth,
|
||||
} = props;
|
||||
} = props
|
||||
|
||||
const propStart = start ?? defaultGradientProps?.start;
|
||||
const propEnd = end ?? defaultGradientProps?.end;
|
||||
const propStart = start ?? defaultGradientProps?.start
|
||||
const propEnd = end ?? defaultGradientProps?.end
|
||||
|
||||
const [state, setState] = useState({
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
})
|
||||
|
||||
const measure = (event) => {
|
||||
setState({
|
||||
width: event.nativeEvent.layout.width,
|
||||
height: event.nativeEvent.layout.height,
|
||||
});
|
||||
})
|
||||
if (onLayout) {
|
||||
onLayout(event);
|
||||
onLayout(event)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getAngle = () => {
|
||||
if (useAngle) {
|
||||
return angle + "deg";
|
||||
return angle + 'deg'
|
||||
}
|
||||
|
||||
// Math.atan2 handles Infinity
|
||||
@@ -63,26 +62,26 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
|
||||
state.width * (propEnd.y - propStart.y),
|
||||
state.height * (propEnd.x - propStart.x)
|
||||
) +
|
||||
Math.PI / 2;
|
||||
return _angle + "rad";
|
||||
};
|
||||
Math.PI / 2
|
||||
return _angle + 'rad'
|
||||
}
|
||||
|
||||
const getColors = () =>
|
||||
colors
|
||||
.map((color, index) => {
|
||||
const location = locations?.[index] ?? defaultGradientProps.locations;
|
||||
let locationStyle = "";
|
||||
const location = locations?.[index] ?? defaultGradientProps.locations
|
||||
let locationStyle = ''
|
||||
if (location) {
|
||||
locationStyle = " " + location * 100 + "%";
|
||||
locationStyle = ' ' + location * 100 + '%'
|
||||
}
|
||||
return color + locationStyle;
|
||||
return color + locationStyle
|
||||
})
|
||||
.join(",");
|
||||
.join(',')
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "relative",
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
@@ -100,35 +99,34 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
|
||||
borderLeftWidth,
|
||||
borderRightWidth,
|
||||
borderBottomWidth,
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'transparent',
|
||||
// borderImage: `linear-gradient(${getAngle()},${getColors()}) 1`,
|
||||
background: `linear-gradient(${getAngle()},${getColors()}) border-box`,
|
||||
WebkitMask:
|
||||
"linear-gradient(#fff 0 0) padding-box,linear-gradient(#fff 0 0)",
|
||||
WebkitMaskComposite: "xor",
|
||||
maskComposite: "exclude",
|
||||
overflow: "hidden",
|
||||
WebkitMask: 'linear-gradient(#fff 0 0) padding-box,linear-gradient(#fff 0 0)',
|
||||
WebkitMaskComposite: 'xor',
|
||||
maskComposite: 'exclude',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]}
|
||||
></View>
|
||||
<View style={styles.innerContainer}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default BorderGradient;
|
||||
export default BorderGradient
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
innerContainer: {
|
||||
flex: 1,
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
right: 0,
|
||||
left: 0,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import React from "react";
|
||||
import { Image, Pressable, Text, View } from "react-native";
|
||||
import { Palette, Style } from "../styles";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
import { size as sizeStyle } from "../styles/Style";
|
||||
import BorderGradient from "./BorderGradient/BorderGradient";
|
||||
import { BlurView } from 'expo-blur'
|
||||
import React from 'react'
|
||||
import { Image, Pressable, Text, View } from 'react-native'
|
||||
import { Palette, Style } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
import { size as sizeStyle } from '../styles/Style'
|
||||
import BorderGradient from './BorderGradient/BorderGradient'
|
||||
|
||||
const HEIGHT_BY_SIZE = {
|
||||
small: 40,
|
||||
medium: 50,
|
||||
large: 58,
|
||||
};
|
||||
}
|
||||
|
||||
const FONT_SIZE_BY_SIZE = {
|
||||
small: 13,
|
||||
medium: 15,
|
||||
large: 17,
|
||||
};
|
||||
}
|
||||
|
||||
const BorderGradientButton = ({
|
||||
title = "J’ai déjà mes paroles",
|
||||
title = 'J’ai déjà mes paroles',
|
||||
onPress,
|
||||
icon,
|
||||
titleStyle,
|
||||
containerStyle = {},
|
||||
tint = "dark",
|
||||
tint = 'dark',
|
||||
disabled = false,
|
||||
maxWidth = null,
|
||||
size = "medium",
|
||||
size = 'medium',
|
||||
height = null,
|
||||
}) => {
|
||||
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
|
||||
const buttonHeight =
|
||||
typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize];
|
||||
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
|
||||
const iconSize = resolvedSize === "small" ? 14 : 16;
|
||||
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : 'medium'
|
||||
const buttonHeight = typeof height === 'number' ? height : HEIGHT_BY_SIZE[resolvedSize]
|
||||
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]
|
||||
const iconSize = resolvedSize === 'small' ? 14 : 16
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -51,7 +50,7 @@ const BorderGradientButton = ({
|
||||
>
|
||||
<BorderGradient
|
||||
gradientProps={{
|
||||
colors: ["#F94697", "#7023F7"],
|
||||
colors: ['#F94697', '#7023F7'],
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 1, y: 0 },
|
||||
locations: [0, 1],
|
||||
@@ -66,27 +65,25 @@ const BorderGradientButton = ({
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#73737324",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#73737324',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<BlurView
|
||||
intensity={40}
|
||||
tint={tint}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
...Style.containerCenter,
|
||||
...Style.containerRow,
|
||||
borderRadius: 14,
|
||||
gap: 11,
|
||||
}}
|
||||
>
|
||||
{icon && (
|
||||
<Image source={icon} style={sizeStyle({ size: iconSize })} />
|
||||
)}
|
||||
{icon && <Image source={icon} style={sizeStyle({ size: iconSize })} />}
|
||||
<Text
|
||||
style={{
|
||||
fontSize,
|
||||
@@ -101,7 +98,7 @@ const BorderGradientButton = ({
|
||||
</View>
|
||||
</BorderGradient>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default BorderGradientButton;
|
||||
export default BorderGradientButton
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import React from "react";
|
||||
import { Image, Pressable, Text, View } from "react-native";
|
||||
import { Palette, Style } from "../styles";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
import { size } from "../styles/Style";
|
||||
import RotationBorder from "./RotationBorder/RotationBorder";
|
||||
import { BlurView } from 'expo-blur'
|
||||
import React from 'react'
|
||||
import { Image, Pressable, Text, View } from 'react-native'
|
||||
import { Palette, Style } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
import { size } from '../styles/Style'
|
||||
import RotationBorder from './RotationBorder/RotationBorder'
|
||||
|
||||
const BorderGradientButton = ({
|
||||
title = "J’ai déjà mes paroles",
|
||||
title = 'J’ai déjà mes paroles',
|
||||
onPress,
|
||||
icon,
|
||||
titleStyle,
|
||||
containerStyle = {},
|
||||
tint = "dark",
|
||||
tint = 'dark',
|
||||
disabled = false,
|
||||
maxWidth = null,
|
||||
}) => {
|
||||
@@ -20,35 +20,35 @@ const BorderGradientButton = ({
|
||||
...(maxWidth ? { maxWidth } : {}),
|
||||
...containerStyle,
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress} disabled={disabled} style={pressableStyle}>
|
||||
<RotationBorder
|
||||
borderWidth={2}
|
||||
borderRadius={14}
|
||||
colors={["#F94697", "#7023F7"]}
|
||||
colors={['#F94697', '#7023F7']}
|
||||
style={{
|
||||
height: 50,
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#000000b8",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#000000b8',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<BlurView
|
||||
intensity={40}
|
||||
tint={tint}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
...Style.containerCenter,
|
||||
...Style.containerRow,
|
||||
borderRadius: 14,
|
||||
@@ -70,7 +70,7 @@ const BorderGradientButton = ({
|
||||
</View>
|
||||
</RotationBorder>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default BorderGradientButton;
|
||||
export default BorderGradientButton
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import BottomSheet from "@gorhom/bottom-sheet";
|
||||
import BottomSheet from '@gorhom/bottom-sheet'
|
||||
|
||||
export default BottomSheet;
|
||||
export default BottomSheet
|
||||
|
||||
@@ -1,41 +1,37 @@
|
||||
import React, { useImperativeHandle, useState, useRef } from "react";
|
||||
import { View, TouchableOpacity, ScrollView } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { Motion } from "@legendapp/motion";
|
||||
import React, { useImperativeHandle, useState, useRef } from 'react'
|
||||
import { View, TouchableOpacity, ScrollView } from 'react-native'
|
||||
import { Portal } from '@gorhom/portal'
|
||||
import { Motion } from '@legendapp/motion'
|
||||
|
||||
import { Palette } from "../../styles";
|
||||
import {
|
||||
isDesktop,
|
||||
isLargeDesktop,
|
||||
sidebarWidth,
|
||||
} from "../../hooks/useLayoutType";
|
||||
import { Palette } from '../../styles'
|
||||
import { isDesktop, isLargeDesktop, sidebarWidth } from '../../hooks/useLayoutType'
|
||||
|
||||
export const SheetScrollView = ScrollView;
|
||||
export const SheetBackdrop = View;
|
||||
export const SheetScrollView = ScrollView
|
||||
export const SheetBackdrop = View
|
||||
|
||||
const BottomSheet = React.forwardRef((props, ref) => {
|
||||
const [showSheet, setShowSheet] = useState(false);
|
||||
const [showSheet, setShowSheet] = useState(false)
|
||||
|
||||
const bottomSheetRef = useRef();
|
||||
const bottomSheetRef = useRef()
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
snapToIndex: () => {
|
||||
setShowSheet(true);
|
||||
setShowSheet(true)
|
||||
},
|
||||
expand: () => {
|
||||
setShowSheet(true);
|
||||
setShowSheet(true)
|
||||
},
|
||||
collapse: () => closeBottomSheet(),
|
||||
close: () => closeBottomSheet(),
|
||||
}));
|
||||
}))
|
||||
|
||||
const closeBottomSheet = () => {
|
||||
setShowSheet(false);
|
||||
props.onChange(-1);
|
||||
};
|
||||
setShowSheet(false)
|
||||
props.onChange(-1)
|
||||
}
|
||||
|
||||
if (!showSheet) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -44,51 +40,49 @@ const BottomSheet = React.forwardRef((props, ref) => {
|
||||
initial={{ right: -500, opacity: 0 }}
|
||||
animate={{ right: 0, opacity: 1 }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
flex: 1,
|
||||
zIndex: 1000000,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={closeBottomSheet}
|
||||
ref={bottomSheetRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: "100%",
|
||||
width: isDesktop
|
||||
? sidebarWidth * (isLargeDesktop ? 2 : 1.5)
|
||||
: "100%",
|
||||
height: '100%',
|
||||
width: isDesktop ? sidebarWidth * (isLargeDesktop ? 2 : 1.5) : '100%',
|
||||
backgroundColor: Palette.lightPurple,
|
||||
overflow: "scroll",
|
||||
overflow: 'scroll',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</View>
|
||||
</Motion.View>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
||||
// TODO une croix pour fermer sur mobile web
|
||||
|
||||
export default BottomSheet;
|
||||
export default BottomSheet
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import { AnimatePresence, Motion } from "@legendapp/motion";
|
||||
import { useKeyboard } from "@react-native-community/hooks";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Keyboard, StyleSheet } from "react-native";
|
||||
import { AnimatePresence, Motion } from '@legendapp/motion'
|
||||
import { useKeyboard } from '@react-native-community/hooks'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Keyboard, StyleSheet } from 'react-native'
|
||||
|
||||
import BottomSheet from "./BottomSheet";
|
||||
import BottomSheet from './BottomSheet'
|
||||
|
||||
import useLayoutType from "../hooks/useLayoutType";
|
||||
import { Palette } from "../styles";
|
||||
import useLayoutType from '../hooks/useLayoutType'
|
||||
import { Palette } from '../styles'
|
||||
|
||||
export default ({
|
||||
children,
|
||||
bottomSheetRef,
|
||||
snapPoints = ["25%", "50%"],
|
||||
snapPoints = ['25%', '50%'],
|
||||
handleStyle = {},
|
||||
...rest
|
||||
}) => {
|
||||
const [currentSnapPointIndex, setCurrentSnapPointIndex] = useState(0);
|
||||
const [currentSnapPointIndex, setCurrentSnapPointIndex] = useState(0)
|
||||
|
||||
const { keyboardShown = false } = useKeyboard();
|
||||
const { isWeb } = useLayoutType();
|
||||
const { keyboardShown = false } = useKeyboard()
|
||||
const { isWeb } = useLayoutType()
|
||||
|
||||
const handleSheetChanges = useCallback((index) => {
|
||||
setCurrentSnapPointIndex(index);
|
||||
setCurrentSnapPointIndex(index)
|
||||
|
||||
if (index <= 0) {
|
||||
Keyboard.dismiss();
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (bottomSheetRef.current && !isWeb && currentSnapPointIndex > 0) {
|
||||
if (keyboardShown) {
|
||||
bottomSheetRef.current.expand();
|
||||
bottomSheetRef.current.expand()
|
||||
} else {
|
||||
bottomSheetRef.current.snapToIndex(1);
|
||||
bottomSheetRef.current.snapToIndex(1)
|
||||
}
|
||||
}
|
||||
}, [keyboardShown]);
|
||||
}, [keyboardShown])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -47,7 +47,7 @@ export default ({
|
||||
onPress={() => bottomSheetRef?.current?.close()}
|
||||
>
|
||||
<Motion.View
|
||||
key={"A"}
|
||||
key={'A'}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: Palette.black,
|
||||
@@ -57,10 +57,10 @@ export default ({
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
default: {
|
||||
type: "spring",
|
||||
type: 'spring',
|
||||
},
|
||||
opacity: {
|
||||
type: "timing",
|
||||
type: 'timing',
|
||||
},
|
||||
}}
|
||||
></Motion.View>
|
||||
@@ -83,5 +83,5 @@ export default ({
|
||||
{children}
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
+33
-36
@@ -1,17 +1,17 @@
|
||||
import { Motion } from "@legendapp/motion";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Text, View } from "react-native";
|
||||
import { Motion } from '@legendapp/motion'
|
||||
import * as Haptics from 'expo-haptics'
|
||||
import { Text, View } from 'react-native'
|
||||
|
||||
import { isDesktop, isMobile, isNative } from "../hooks/useLayoutType";
|
||||
import { Fonts, Palette } from "../styles";
|
||||
import Style, { gutters, mainBorderRadius } from "../styles/Style";
|
||||
import { isDesktop, isMobile, isNative } from '../hooks/useLayoutType'
|
||||
import { Fonts, Palette } from '../styles'
|
||||
import Style, { gutters, mainBorderRadius } from '../styles/Style'
|
||||
|
||||
export default ({
|
||||
type = "primary",
|
||||
theme = "default", // "default" | "radioactiv"
|
||||
type = 'primary',
|
||||
theme = 'default', // "default" | "radioactiv"
|
||||
|
||||
text,
|
||||
onPress = () => console.log("null"),
|
||||
onPress = () => console.log('null'),
|
||||
isAbsoluteBottom = false,
|
||||
|
||||
alternateAction = {},
|
||||
@@ -23,39 +23,39 @@ export default ({
|
||||
|
||||
isMainDesktopPanel = false,
|
||||
}) => {
|
||||
const buttonWidth = alternateAction?.text ? "49%" : "100%";
|
||||
const buttonWidth = alternateAction?.text ? '49%' : '100%'
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: alternateAction?.text ? "space-between" : "center",
|
||||
flexDirection: 'row',
|
||||
justifyContent: alternateAction?.text ? 'space-between' : 'center',
|
||||
...(isAbsoluteBottom
|
||||
? {
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
bottom: gutters * (isMobile ? 2 : 1),
|
||||
alignItems: "flex-end",
|
||||
alignItems: 'flex-end',
|
||||
...(isDesktop && !isMainDesktopPanel
|
||||
? {
|
||||
maxWidth: 600,
|
||||
minWidth: 400,
|
||||
alignSelf: "center",
|
||||
alignSelf: 'center',
|
||||
}
|
||||
: {
|
||||
right: gutters,
|
||||
left: gutters,
|
||||
alignSelf: "center",
|
||||
alignSelf: 'center',
|
||||
}),
|
||||
}
|
||||
: {
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
}),
|
||||
...contentContainerStyle,
|
||||
}}
|
||||
>
|
||||
{alternateAction?.text && alternateAction?.onPress ? (
|
||||
<BaseButton
|
||||
type={"secondary"}
|
||||
type={'secondary'}
|
||||
theme={alternateAction?.theme || theme}
|
||||
text={alternateAction.text}
|
||||
onPress={alternateAction.onPress}
|
||||
@@ -83,45 +83,42 @@ export default ({
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const BaseButton = ({
|
||||
type = "primary",
|
||||
theme = "default",
|
||||
type = 'primary',
|
||||
theme = 'default',
|
||||
text,
|
||||
onPress = () => console.log("null"),
|
||||
onPress = () => console.log('null'),
|
||||
containerStyle = {},
|
||||
textStyle = {},
|
||||
hasShadow = false,
|
||||
}) => {
|
||||
const primaryColor =
|
||||
theme === "radioactiv" ? Palette.radioactivGreen : Palette.primary;
|
||||
const primaryColor = theme === 'radioactiv' ? Palette.radioactivGreen : Palette.primary
|
||||
const primaryTransparentColor =
|
||||
theme === "radioactiv"
|
||||
? Palette.transparentRadioactivGreen
|
||||
: Palette.transparentPrimary;
|
||||
theme === 'radioactiv' ? Palette.transparentRadioactivGreen : Palette.transparentPrimary
|
||||
|
||||
const textColor = type === "secondary" ? primaryColor : Palette.darkPurple;
|
||||
const textColor = type === 'secondary' ? primaryColor : Palette.darkPurple
|
||||
|
||||
return (
|
||||
<Motion.Pressable
|
||||
whileTap={{ scale: 0.8 }}
|
||||
onPress={() => {
|
||||
if (isNative) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
||||
}
|
||||
onPress();
|
||||
onPress()
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
height: 50,
|
||||
marginTop: gutters / 2,
|
||||
...Style.containerRow,
|
||||
...Style.containerCenter,
|
||||
backgroundColor: primaryColor,
|
||||
borderRadius: mainBorderRadius,
|
||||
...(type === "secondary"
|
||||
...(type === 'secondary'
|
||||
? {
|
||||
backgroundColor: primaryTransparentColor,
|
||||
}
|
||||
@@ -134,7 +131,7 @@ export const BaseButton = ({
|
||||
<Text
|
||||
style={{
|
||||
...Fonts({
|
||||
type: "default",
|
||||
type: 'default',
|
||||
color: textColor,
|
||||
}),
|
||||
...textStyle,
|
||||
@@ -143,5 +140,5 @@ export const BaseButton = ({
|
||||
{text}
|
||||
</Text>
|
||||
</Motion.Pressable>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
+36
-36
@@ -1,32 +1,32 @@
|
||||
import { useState, useEffect } from "reactn";
|
||||
import { Pressable, TextInput, View, Image, Platform } from "react-native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useState, useEffect } from 'reactn'
|
||||
import { Pressable, TextInput, View, Image, Platform } from 'react-native'
|
||||
import { BlurView } from 'expo-blur'
|
||||
|
||||
import { Fonts, gutters, Palette } from "../styles";
|
||||
import Style from "../styles/Style";
|
||||
import { Fonts, gutters, Palette } from '../styles'
|
||||
import Style from '../styles/Style'
|
||||
|
||||
import { chatsRef } from "../config/firebase";
|
||||
import { chatsRef } from '../config/firebase'
|
||||
|
||||
import { icons } from "../assets";
|
||||
import { icons } from '../assets'
|
||||
|
||||
import { isWeb } from "../hooks/useLayoutType.js";
|
||||
import { isWeb } from '../hooks/useLayoutType.js'
|
||||
|
||||
import DocumentDropZone from "./DocumentDropZone.js";
|
||||
import FilesPreview from "./FilesPreview.js";
|
||||
import DocumentDropZone from './DocumentDropZone.js'
|
||||
import FilesPreview from './FilesPreview.js'
|
||||
|
||||
const ChatInput = ({
|
||||
message,
|
||||
setMessage,
|
||||
onSendMessage,
|
||||
containerStyle = {},
|
||||
placeholder = "",
|
||||
placeholder = '',
|
||||
chatID = null,
|
||||
}) => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [files, setFiles] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
setFiles([]);
|
||||
}, [chatID]);
|
||||
setFiles([])
|
||||
}, [chatID])
|
||||
|
||||
const handleMessageObject = () => {
|
||||
if (message.length > 0 || files.length > 0) {
|
||||
@@ -34,37 +34,37 @@ const ChatInput = ({
|
||||
customPayload: {
|
||||
files,
|
||||
},
|
||||
});
|
||||
setMessage("");
|
||||
setFiles([]);
|
||||
})
|
||||
setMessage('')
|
||||
setFiles([])
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e?.nativeEvent?.key?.toLowerCase() === "enter") {
|
||||
handleMessageObject();
|
||||
if (e?.nativeEvent?.key?.toLowerCase() === 'enter') {
|
||||
handleMessageObject()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
...Style.containerItem,
|
||||
backgroundColor: Palette.transparentDarkPurple,
|
||||
justifyContent: "center",
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.ultraLightWhite,
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
padding: 0,
|
||||
...containerStyle,
|
||||
}}
|
||||
>
|
||||
<BlurView
|
||||
intensity={Platform.OS !== "ios" ? 10 : 30}
|
||||
intensity={Platform.OS !== 'ios' ? 10 : 30}
|
||||
tint="dark"
|
||||
style={{ flex: 1, justifyContent: "center" }}
|
||||
style={{ flex: 1, justifyContent: 'center' }}
|
||||
// experimentalBlurMethod={
|
||||
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
||||
// }
|
||||
@@ -101,13 +101,13 @@ const ChatInput = ({
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
style={{
|
||||
width: "85%",
|
||||
...Fonts({ type: "default", style: {} }),
|
||||
width: '85%',
|
||||
...Fonts({ type: 'default', style: {} }),
|
||||
}}
|
||||
keyboardAppearance="dark"
|
||||
{...(!isWeb
|
||||
? {
|
||||
returnKeyType: "send",
|
||||
returnKeyType: 'send',
|
||||
onSubmitEditing: onSendMessage,
|
||||
}
|
||||
: {
|
||||
@@ -118,13 +118,13 @@ const ChatInput = ({
|
||||
<Pressable
|
||||
onPress={handleMessageObject}
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 50,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
@@ -138,7 +138,7 @@ const ChatInput = ({
|
||||
</View>
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatInput;
|
||||
export default ChatInput
|
||||
|
||||
@@ -1,78 +1,66 @@
|
||||
import { useState, useRef, useEffect, useGlobal, getGlobal } from "reactn";
|
||||
import {
|
||||
Pressable,
|
||||
TextInput,
|
||||
View,
|
||||
Image,
|
||||
Text,
|
||||
Keyboard,
|
||||
FlatList,
|
||||
} from "react-native";
|
||||
import { responsiveHeight } from "../actions/responsiveSizes.js";
|
||||
import { useDataFromRef } from "react-native-minuit/src/hooks";
|
||||
import { useKeyboard } from "@react-native-community/hooks";
|
||||
import moment from "moment";
|
||||
import { useState, useRef, useEffect, useGlobal, getGlobal } from 'reactn'
|
||||
import { Pressable, TextInput, View, Image, Text, Keyboard, FlatList } from 'react-native'
|
||||
import { responsiveHeight } from '../actions/responsiveSizes.js'
|
||||
import { useDataFromRef } from 'react-native-minuit/src/hooks'
|
||||
import { useKeyboard } from '@react-native-community/hooks'
|
||||
import moment from 'moment'
|
||||
|
||||
import { Fonts, gutters, Palette } from "../styles";
|
||||
import Style, { bubbleStyle } from "../styles/Style";
|
||||
import { Fonts, gutters, Palette } from '../styles'
|
||||
import Style, { bubbleStyle } from '../styles/Style'
|
||||
|
||||
import firebase, { chatsRef } from "../config/firebase";
|
||||
import firebase, { chatsRef } from '../config/firebase'
|
||||
|
||||
import { formatNameForConfidentiality } from "../helpers/index.js";
|
||||
import useLayoutType from "../hooks/useLayoutType.js";
|
||||
import { formatNameForConfidentiality } from '../helpers/index.js'
|
||||
import useLayoutType from '../hooks/useLayoutType.js'
|
||||
|
||||
import TypingLoader from "./TypingLoader";
|
||||
import Avatar from "./Avatar";
|
||||
import RenderChatFile from "./RenderChatFile.js";
|
||||
import HyperlinkContainer from "./HyperlinkContainer.js";
|
||||
import TypingLoader from './TypingLoader'
|
||||
import Avatar from './Avatar'
|
||||
import RenderChatFile from './RenderChatFile.js'
|
||||
import HyperlinkContainer from './HyperlinkContainer.js'
|
||||
|
||||
export default ({
|
||||
chatID = null,
|
||||
|
||||
layout = "default", // default | taskSideBar
|
||||
layout = 'default', // default | taskSideBar
|
||||
|
||||
containerStyle = {},
|
||||
messageListContainerStyle = {},
|
||||
}) => {
|
||||
const [currentUID] = useGlobal("currentUID");
|
||||
const [currentProjectData] = useGlobal("currentProjectData");
|
||||
const [currentUID] = useGlobal('currentUID')
|
||||
const [currentProjectData] = useGlobal('currentProjectData')
|
||||
|
||||
const [isTyping] = useState(false);
|
||||
const [isTyping] = useState(false)
|
||||
|
||||
const flatListRef = useRef();
|
||||
const flatListRef = useRef()
|
||||
|
||||
const { isNative } = useLayoutType();
|
||||
const { keyboardShown = false } = useKeyboard();
|
||||
const { isNative } = useLayoutType()
|
||||
const { keyboardShown = false } = useKeyboard()
|
||||
|
||||
const { data: messageList } = useDataFromRef({
|
||||
ref: chatID
|
||||
? chatsRef
|
||||
.doc(chatID)
|
||||
.collection("messages")
|
||||
.orderBy("createdAt", "desc")
|
||||
.limit(50)
|
||||
? chatsRef.doc(chatID).collection('messages').orderBy('createdAt', 'desc').limit(50)
|
||||
: null,
|
||||
simpleRef: false,
|
||||
listener: true,
|
||||
condition: chatID,
|
||||
refreshArray: [chatID],
|
||||
documentID: "messageID",
|
||||
});
|
||||
documentID: 'messageID',
|
||||
})
|
||||
|
||||
let conversation = [
|
||||
isTyping ? { senderID: "minuit.ai", userTyping: true } : null,
|
||||
isTyping ? { senderID: 'minuit.ai', userTyping: true } : null,
|
||||
...(messageList || []),
|
||||
].filter((item) => item);
|
||||
].filter((item) => item)
|
||||
|
||||
if (layout === "default") {
|
||||
conversation = conversation.reverse();
|
||||
if (layout === 'default') {
|
||||
conversation = conversation.reverse()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (flatListRef?.current && isNative && keyboardShown) {
|
||||
flatListRef?.current?.scrollToEnd?.({ animated: true });
|
||||
flatListRef?.current?.scrollToEnd?.({ animated: true })
|
||||
}
|
||||
}, [keyboardShown, isNative]);
|
||||
}, [keyboardShown, isNative])
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -92,33 +80,30 @@ export default ({
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyExtractor={(item, index) =>
|
||||
item?.messageID
|
||||
? `${item?.messageID?.toString()}-${index}`
|
||||
: `no-messageID-${index}`
|
||||
item?.messageID ? `${item?.messageID?.toString()}-${index}` : `no-messageID-${index}`
|
||||
}
|
||||
renderItem={({
|
||||
item: {
|
||||
createdAt = null,
|
||||
senderID,
|
||||
senderName = "",
|
||||
senderName = '',
|
||||
senderProfilePicture = null,
|
||||
text = "",
|
||||
text = '',
|
||||
userTyping = false,
|
||||
files = [],
|
||||
},
|
||||
index,
|
||||
}) => {
|
||||
const isCurrentUser = senderID === currentUID;
|
||||
const isChatbot = senderID === "minuit.ai";
|
||||
const isCurrentUser = senderID === currentUID
|
||||
const isChatbot = senderID === 'minuit.ai'
|
||||
|
||||
const senderData =
|
||||
currentProjectData?.teamMembers?.[senderID] || {};
|
||||
const senderData = currentProjectData?.teamMembers?.[senderID] || {}
|
||||
|
||||
const isDayChange =
|
||||
moment(createdAt?.toDate()).format("DD/MM/YYYY") !==
|
||||
moment(
|
||||
conversation[index - 1]?.createdAt?.toDate() || new Date()
|
||||
).format("DD/MM/YYYY") || !conversation[index - 1];
|
||||
moment(createdAt?.toDate()).format('DD/MM/YYYY') !==
|
||||
moment(conversation[index - 1]?.createdAt?.toDate() || new Date()).format(
|
||||
'DD/MM/YYYY'
|
||||
) || !conversation[index - 1]
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -132,21 +117,19 @@ export default ({
|
||||
<Text
|
||||
style={{
|
||||
...Fonts({
|
||||
type: "default",
|
||||
type: 'default',
|
||||
color: Palette.white,
|
||||
style: { textAlign: "center", opacity: 0.5 },
|
||||
style: { textAlign: 'center', opacity: 0.5 },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{moment(createdAt?.toDate()).format(
|
||||
"[Le] DD/MM/YYYY [à] HH:mm"
|
||||
)}
|
||||
{moment(createdAt?.toDate()).format('[Le] DD/MM/YYYY [à] HH:mm')}
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
...Style.separatorHorizontal,
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@@ -156,9 +139,9 @@ export default ({
|
||||
style={[
|
||||
Style.containerRow,
|
||||
{
|
||||
flexDirection: isCurrentUser ? "row-reverse" : "row",
|
||||
alignItems: "flex-end",
|
||||
width: "100%",
|
||||
flexDirection: isCurrentUser ? 'row-reverse' : 'row',
|
||||
alignItems: 'flex-end',
|
||||
width: '100%',
|
||||
marginBottom: gutters / 2,
|
||||
},
|
||||
]}
|
||||
@@ -175,21 +158,15 @@ export default ({
|
||||
}
|
||||
: {
|
||||
marginRight: gutters / 2,
|
||||
backgroundColor: "transparent",
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: Palette.primary,
|
||||
borderWidth: 1,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
name={
|
||||
isChatbot ? "m" : senderData?.name || senderName || ""
|
||||
}
|
||||
url={
|
||||
senderData?.profilePictureURL ||
|
||||
senderProfilePicture ||
|
||||
null
|
||||
}
|
||||
name={isChatbot ? 'm' : senderData?.name || senderName || ''}
|
||||
url={senderData?.profilePictureURL || senderProfilePicture || null}
|
||||
size={35}
|
||||
/>
|
||||
</View>
|
||||
@@ -197,7 +174,7 @@ export default ({
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: isCurrentUser ? "flex-end" : "flex-start",
|
||||
alignItems: isCurrentUser ? 'flex-end' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
{files?.map((props, index) => (
|
||||
@@ -232,11 +209,11 @@ export default ({
|
||||
<HyperlinkContainer>
|
||||
<Text
|
||||
style={Fonts({
|
||||
type: "default",
|
||||
type: 'default',
|
||||
style: {
|
||||
color: Palette.white,
|
||||
textAlign: isCurrentUser ? "right" : "left",
|
||||
width: "100%",
|
||||
textAlign: isCurrentUser ? 'right' : 'left',
|
||||
width: '100%',
|
||||
},
|
||||
})}
|
||||
>
|
||||
@@ -249,30 +226,29 @@ export default ({
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const onSendMessage = async ({
|
||||
chatID = null,
|
||||
projectID = null,
|
||||
message = "",
|
||||
message = '',
|
||||
setMessage = () => {},
|
||||
setIsTyping = () => {},
|
||||
customPayload = {},
|
||||
}) => {
|
||||
try {
|
||||
const currentUID = getGlobal()?.currentUID || null;
|
||||
const { name = "", profilePictureURL = null } =
|
||||
getGlobal()?.currentUserData || {};
|
||||
const currentUID = getGlobal()?.currentUID || null
|
||||
const { name = '', profilePictureURL = null } = getGlobal()?.currentUserData || {}
|
||||
|
||||
if (message.length > 0 || customPayload?.files?.length > 0) {
|
||||
Keyboard.dismiss();
|
||||
setMessage("");
|
||||
Keyboard.dismiss()
|
||||
setMessage('')
|
||||
|
||||
const messageData = {
|
||||
projectID,
|
||||
@@ -282,13 +258,13 @@ export const onSendMessage = async ({
|
||||
senderProfilePicture: profilePictureURL || null,
|
||||
text: message,
|
||||
...customPayload,
|
||||
};
|
||||
}
|
||||
|
||||
await chatsRef.doc(chatID).collection("messages").add(messageData);
|
||||
await chatsRef.doc(chatID).collection('messages').add(messageData)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.log(error)
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
setIsTyping(false)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { Image } from "react-native";
|
||||
import { icons } from "../assets";
|
||||
import React from 'react'
|
||||
import { Image } from 'react-native'
|
||||
import { icons } from '../assets'
|
||||
|
||||
const CoinIcon = ({ size = 22, style }) => {
|
||||
return (
|
||||
@@ -10,12 +10,12 @@ const CoinIcon = ({ size = 22, style }) => {
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
resizeMode: "contain",
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default CoinIcon;
|
||||
export default CoinIcon
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import React from "react";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { Routes } from "../navigation";
|
||||
import { navigate } from "../navigation/NavigationService";
|
||||
import { Palette } from "../styles";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
import { BlurView } from 'expo-blur'
|
||||
import React from 'react'
|
||||
import { Pressable, Text } from 'react-native'
|
||||
import { Routes } from '../navigation'
|
||||
import { navigate } from '../navigation/NavigationService'
|
||||
import { Palette } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
export default function ConnectBtn({ style }) {
|
||||
const handlePress = () => {
|
||||
navigate(Routes.Login);
|
||||
};
|
||||
navigate(Routes.Login)
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -22,9 +22,9 @@ export default function ConnectBtn({ style }) {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 15,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
@@ -35,9 +35,9 @@ export default function ConnectBtn({ style }) {
|
||||
marginRight: 5,
|
||||
}}
|
||||
>
|
||||
{"Se connecter"}
|
||||
{'Se connecter'}
|
||||
</Text>
|
||||
</BlurView>
|
||||
</Pressable>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import CoinIcon from "./CoinIcon";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
import React from 'react'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import CoinIcon from './CoinIcon'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
|
||||
const defaultFormatOptions = {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const formatAmount = (value, options = defaultFormatOptions) => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return null;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
...defaultFormatOptions,
|
||||
...options,
|
||||
}).format(value);
|
||||
}).format(value)
|
||||
} catch (_error) {
|
||||
return `${value}`;
|
||||
return `${value}`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const extractNumericValue = (value) => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
const CreditAmount = ({
|
||||
value,
|
||||
@@ -44,78 +44,62 @@ const CreditAmount = ({
|
||||
textStyle,
|
||||
iconSize = 18,
|
||||
iconStyle,
|
||||
iconPosition = "right",
|
||||
iconPosition = 'right',
|
||||
gap = 6,
|
||||
showPlus = false,
|
||||
formatterOptions,
|
||||
accessibilityLabel,
|
||||
}) => {
|
||||
const numericValue = React.useMemo(
|
||||
() => extractNumericValue(value),
|
||||
[value],
|
||||
);
|
||||
const numericValue = React.useMemo(() => extractNumericValue(value), [value])
|
||||
|
||||
const resolvedValue =
|
||||
numericValue !== null
|
||||
? numericValue
|
||||
: value ?? 0;
|
||||
const resolvedValue = numericValue !== null ? numericValue : (value ?? 0)
|
||||
|
||||
const formattedValue =
|
||||
numericValue !== null
|
||||
? formatAmount(resolvedValue, formatterOptions) ?? `${resolvedValue}`
|
||||
: typeof resolvedValue === "string"
|
||||
? (formatAmount(resolvedValue, formatterOptions) ?? `${resolvedValue}`)
|
||||
: typeof resolvedValue === 'string'
|
||||
? resolvedValue
|
||||
: `${resolvedValue}`;
|
||||
: `${resolvedValue}`
|
||||
|
||||
const prefix =
|
||||
numericValue !== null && showPlus && numericValue > 0 ? "+" : "";
|
||||
const prefix = numericValue !== null && showPlus && numericValue > 0 ? '+' : ''
|
||||
|
||||
const a11yLabel =
|
||||
accessibilityLabel || `${prefix}${formattedValue} pièces`;
|
||||
const a11yLabel = accessibilityLabel || `${prefix}${formattedValue} pièces`
|
||||
|
||||
const containerStyles = Array.isArray(style)
|
||||
? [styles.container, { gap }, ...style]
|
||||
: [styles.container, { gap }, style];
|
||||
: [styles.container, { gap }, style]
|
||||
|
||||
const textStyles = Array.isArray(textStyle)
|
||||
? [styles.value, ...textStyle]
|
||||
: [styles.value, textStyle];
|
||||
: [styles.value, textStyle]
|
||||
|
||||
const iconStyles = Array.isArray(iconStyle)
|
||||
? [styles.icon, ...iconStyle]
|
||||
: [styles.icon, iconStyle];
|
||||
: [styles.icon, iconStyle]
|
||||
|
||||
return (
|
||||
<View
|
||||
style={containerStyles}
|
||||
accessibilityRole="text"
|
||||
accessibilityLabel={a11yLabel}
|
||||
>
|
||||
{iconPosition === "left" ? (
|
||||
<CoinIcon size={iconSize} style={iconStyles} />
|
||||
) : null}
|
||||
<View style={containerStyles} accessibilityRole="text" accessibilityLabel={a11yLabel}>
|
||||
{iconPosition === 'left' ? <CoinIcon size={iconSize} style={iconStyles} /> : null}
|
||||
<Text style={textStyles}>{`${prefix}${formattedValue}`}</Text>
|
||||
{iconPosition === "right" ? (
|
||||
<CoinIcon size={iconSize} style={iconStyles} />
|
||||
) : null}
|
||||
{iconPosition === 'right' ? <CoinIcon size={iconSize} style={iconStyles} /> : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
value: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 16,
|
||||
color: "#fff",
|
||||
color: '#fff',
|
||||
},
|
||||
icon: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export default CreditAmount;
|
||||
export default CreditAmount
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Dialog from "react-native-dialog";
|
||||
|
||||
export const Container = Dialog.Container;
|
||||
export const Button = Dialog.Button;
|
||||
export const Title = Dialog.Title;
|
||||
export const Input = Dialog.Input;
|
||||
export const Description = Dialog.Description;
|
||||
import Dialog from 'react-native-dialog'
|
||||
|
||||
export const Container = Dialog.Container
|
||||
export const Button = Dialog.Button
|
||||
export const Title = Dialog.Title
|
||||
export const Input = Dialog.Input
|
||||
export const Description = Dialog.Description
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import React from 'react'
|
||||
import { Text, View } from 'react-native'
|
||||
|
||||
import { Input as MinuitInput } from "../Input";
|
||||
import { BaseButton as MinuitButton } from "../Button";
|
||||
import Overlay from "../Overlay";
|
||||
import { Input as MinuitInput } from '../Input'
|
||||
import { BaseButton as MinuitButton } from '../Button'
|
||||
import Overlay from '../Overlay'
|
||||
|
||||
import { Fonts, Palette, gutters } from "../../styles";
|
||||
import Style from "../../styles/Style";
|
||||
import { Fonts, Palette, gutters } from '../../styles'
|
||||
import Style from '../../styles/Style'
|
||||
|
||||
export const Container = ({ visible, setVisible = () => null, children }) => {
|
||||
return (
|
||||
<Overlay isVisible={visible}>
|
||||
<View style={{ ...Style.containerModal }}>{children}</View>
|
||||
</Overlay>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const Title = ({ children }) => {
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
...Fonts({
|
||||
type: "title",
|
||||
type: 'title',
|
||||
style: {
|
||||
textAlign: "center",
|
||||
textAlign: 'center',
|
||||
marginBottom: gutters,
|
||||
},
|
||||
}),
|
||||
@@ -31,17 +31,17 @@ export const Title = ({ children }) => {
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const Description = ({ children }) => {
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
...Fonts({
|
||||
type: "default",
|
||||
type: 'default',
|
||||
style: {
|
||||
textAlign: "center",
|
||||
textAlign: 'center',
|
||||
marginBottom: gutters / 2,
|
||||
},
|
||||
}),
|
||||
@@ -49,29 +49,24 @@ export const Description = ({ children }) => {
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const Button = ({
|
||||
label,
|
||||
onPress,
|
||||
type = "primary",
|
||||
containerStyle = {},
|
||||
}) => {
|
||||
export const Button = ({ label, onPress, type = 'primary', containerStyle = {} }) => {
|
||||
return (
|
||||
<MinuitButton
|
||||
containerStyle={{
|
||||
width: "100%",
|
||||
alignSelf: "center",
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
...containerStyle,
|
||||
}}
|
||||
text={label}
|
||||
onPress={onPress}
|
||||
type={type}
|
||||
/>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const Input = (props) => {
|
||||
return <MinuitInput {...props} setValue={props.onChangeText} />;
|
||||
};
|
||||
return <MinuitInput {...props} setValue={props.onChangeText} />
|
||||
}
|
||||
|
||||
+154
-156
@@ -1,27 +1,27 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import * as DocumentPicker from "expo-document-picker";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import moment from "moment";
|
||||
import { Image, Pressable, Text, View } from "react-native";
|
||||
import Compressor from "react-native-compressor";
|
||||
import useMinuit from "react-native-minuit/src/hooks/useMinuit";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "reactn";
|
||||
import { useActionSheet } from '@expo/react-native-action-sheet'
|
||||
import * as DocumentPicker from 'expo-document-picker'
|
||||
import * as ImagePicker from 'expo-image-picker'
|
||||
import moment from 'moment'
|
||||
import { Image, Pressable, Text, View } from 'react-native'
|
||||
import Compressor from 'react-native-compressor'
|
||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'reactn'
|
||||
|
||||
import { arrayUnion } from "../config/firebase";
|
||||
import { arrayUnion } from '../config/firebase'
|
||||
|
||||
import { icons } from "../assets";
|
||||
import { Fonts, Palette } from "../styles";
|
||||
import Style, { gutterConstant, mainBorderRadius } from "../styles/Style";
|
||||
import { icons } from '../assets'
|
||||
import { Fonts, Palette } from '../styles'
|
||||
import Style, { gutterConstant, mainBorderRadius } from '../styles/Style'
|
||||
|
||||
import { uploadFileToFirebase } from "../helpers/uploadToFirebase";
|
||||
import useLayoutType from "../hooks/useLayoutType";
|
||||
import { uploadFileToFirebase } from '../helpers/uploadToFirebase'
|
||||
import useLayoutType from '../hooks/useLayoutType'
|
||||
|
||||
const NATIVE_OPTIONS = [
|
||||
"Importer depuis la galerie",
|
||||
"Ajouter un fichier",
|
||||
"Prendre une photo ou une vidéo",
|
||||
"Annuler",
|
||||
];
|
||||
'Importer depuis la galerie',
|
||||
'Ajouter un fichier',
|
||||
'Prendre une photo ou une vidéo',
|
||||
'Annuler',
|
||||
]
|
||||
|
||||
const DocumentDropZone = ({
|
||||
containerStyle = {},
|
||||
@@ -35,162 +35,162 @@ const DocumentDropZone = ({
|
||||
customElement = null,
|
||||
shouldReturnObject = false,
|
||||
}) => {
|
||||
const dropRef = useRef(null);
|
||||
const dropRef = useRef(null)
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const { setIsLoading, setTooltip } = useMinuit();
|
||||
const { isDesktop, isWeb, isNative } = useLayoutType();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const { setIsLoading, setTooltip } = useMinuit()
|
||||
const { isDesktop, isWeb, isNative } = useLayoutType()
|
||||
const { showActionSheetWithOptions } = useActionSheet()
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb && dropRef.current) {
|
||||
const el = dropRef.current;
|
||||
const el = dropRef.current
|
||||
|
||||
const handleDragIn = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragOut = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!dropRef.current.contains(e.relatedTarget)) {
|
||||
console.log("drag left");
|
||||
setIsDragging(false);
|
||||
console.log('drag left')
|
||||
setIsDragging(false)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
let files = [...e.dataTransfer.files];
|
||||
let files = [...e.dataTransfer.files]
|
||||
|
||||
if (files.length > 0) {
|
||||
for (const file of files) {
|
||||
handleWebFile({ file });
|
||||
handleWebFile({ file })
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("dragenter", handleDragIn);
|
||||
el.addEventListener("dragleave", handleDragOut);
|
||||
el.addEventListener("dragover", (e) => e.preventDefault());
|
||||
el.addEventListener("drop", handleDrop);
|
||||
el.addEventListener('dragenter', handleDragIn)
|
||||
el.addEventListener('dragleave', handleDragOut)
|
||||
el.addEventListener('dragover', (e) => e.preventDefault())
|
||||
el.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("dragenter", handleDragIn);
|
||||
el.removeEventListener("dragleave", handleDragOut);
|
||||
el.removeEventListener("dragover", (e) => e.preventDefault());
|
||||
el.removeEventListener("drop", handleDrop);
|
||||
};
|
||||
el.removeEventListener('dragenter', handleDragIn)
|
||||
el.removeEventListener('dragleave', handleDragOut)
|
||||
el.removeEventListener('dragover', (e) => e.preventDefault())
|
||||
el.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [dropRef?.current]);
|
||||
}
|
||||
}, [dropRef?.current])
|
||||
|
||||
const handleWebFile = ({ file }) => {
|
||||
try {
|
||||
const { type = "" } = file;
|
||||
const reader = new FileReader();
|
||||
const { type = '' } = file
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result.split(",")[1]; // Vous pourriez avoir besoin de cette valeur base64 pour un upload direct
|
||||
const uri = reader.result; // URI en base64 du fichier
|
||||
const base64 = reader.result.split(',')[1] // Vous pourriez avoir besoin de cette valeur base64 pour un upload direct
|
||||
const uri = reader.result // URI en base64 du fichier
|
||||
|
||||
console.log("file", file);
|
||||
console.log('file', file)
|
||||
|
||||
onUploadDocument({ files: [{ name: file.name, uri, type }] });
|
||||
};
|
||||
onUploadDocument({ files: [{ name: file.name, uri, type }] })
|
||||
}
|
||||
|
||||
reader.onerror = (err) => {
|
||||
console.error("FileReader error", err);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file); // Lire le fichier et déclencher reader.onloadend lorsque c'est fait
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
setTooltip({ text: error.message, type: "error" });
|
||||
console.error('FileReader error', err)
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file) // Lire le fichier et déclencher reader.onloadend lorsque c'est fait
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
setTooltip({ text: error.message, type: 'error' })
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onAddFile = async ({} = {}) => {
|
||||
try {
|
||||
if (isNative) {
|
||||
const cancelButtonIndex = NATIVE_OPTIONS.length - 1;
|
||||
const cancelButtonIndex = NATIVE_OPTIONS.length - 1
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options: NATIVE_OPTIONS,
|
||||
cancelButtonIndex,
|
||||
userInterfaceStyle: "dark",
|
||||
userInterfaceStyle: 'dark',
|
||||
...Style.actionSheet,
|
||||
},
|
||||
async (selectedIndex) => {
|
||||
if (selectedIndex !== cancelButtonIndex) {
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
onChooseLibrary();
|
||||
break;
|
||||
onChooseLibrary()
|
||||
break
|
||||
case 1:
|
||||
onChooseDocumentPicker();
|
||||
break;
|
||||
onChooseDocumentPicker()
|
||||
break
|
||||
case 2:
|
||||
onTakePicture();
|
||||
break;
|
||||
onTakePicture()
|
||||
break
|
||||
default:
|
||||
break;
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
)
|
||||
} else {
|
||||
onChooseDocumentPicker();
|
||||
onChooseDocumentPicker()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
setTooltip({ text: error.message, type: "error" });
|
||||
console.log(error)
|
||||
setTooltip({ text: error.message, type: 'error' })
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onChooseLibrary = async ({} = {}) => {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||
allowsEditing: false,
|
||||
quality: 3,
|
||||
});
|
||||
})
|
||||
|
||||
if (result?.assets?.[0]?.uri) {
|
||||
const { uri, fileName = "" } = result?.assets?.[0];
|
||||
const { uri, fileName = '' } = result?.assets?.[0]
|
||||
|
||||
onUploadDocument({
|
||||
files: [
|
||||
{
|
||||
name: getAssetName({ fileName, uri }),
|
||||
uri,
|
||||
type: "IMAGE",
|
||||
type: 'IMAGE',
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
} else {
|
||||
throw new Error("Aucune image sélectionnée");
|
||||
throw new Error('Aucune image sélectionnée')
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onChooseDocumentPicker = async ({} = {}) => {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: "*/*",
|
||||
type: '*/*',
|
||||
copyToCacheDirectory: false,
|
||||
});
|
||||
})
|
||||
|
||||
if (result?.assets?.length > 0) {
|
||||
const { name = "", uri = null } = result?.assets[0] || {};
|
||||
const { name = '', uri = null } = result?.assets[0] || {}
|
||||
|
||||
if (!uri) {
|
||||
throw new Error("Erreur lors de l'ajout du document");
|
||||
throw new Error("Erreur lors de l'ajout du document")
|
||||
}
|
||||
|
||||
onUploadDocument({
|
||||
@@ -200,117 +200,117 @@ const DocumentDropZone = ({
|
||||
uri,
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
} else {
|
||||
console.log(result);
|
||||
throw new Error("Erreur lors de l'ajout du document");
|
||||
console.log(result)
|
||||
throw new Error("Erreur lors de l'ajout du document")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTakePicture = useCallback(async () => {
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync()
|
||||
|
||||
if (status !== "granted") {
|
||||
throw new Error("Permissions caméra non accordées");
|
||||
if (status !== 'granted') {
|
||||
throw new Error('Permissions caméra non accordées')
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||
allowsEditing: true,
|
||||
quality: 1,
|
||||
});
|
||||
})
|
||||
|
||||
if (result?.assets?.[0]?.uri) {
|
||||
const { uri, fileName = "" } = result?.assets?.[0];
|
||||
const { uri, fileName = '' } = result?.assets?.[0]
|
||||
|
||||
onUploadDocument({
|
||||
files: [
|
||||
{
|
||||
name: getAssetName({ fileName, uri }),
|
||||
uri,
|
||||
type: "IMAGE",
|
||||
type: 'IMAGE',
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
} else {
|
||||
throw new Error("Aucune image sélectionnée");
|
||||
throw new Error('Aucune image sélectionnée')
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
const getDefaultFileName = () => {
|
||||
const randomID = Math.random().toString(36).substring(7);
|
||||
return `${moment().format(`DD_MM_YYYY_HH_mm_ss`)}_${randomID}`;
|
||||
};
|
||||
const randomID = Math.random().toString(36).substring(7)
|
||||
return `${moment().format(`DD_MM_YYYY_HH_mm_ss`)}_${randomID}`
|
||||
}
|
||||
|
||||
const getAssetName = ({ fileName = "", uri = "" }) => {
|
||||
let name = fileName;
|
||||
const getAssetName = ({ fileName = '', uri = '' }) => {
|
||||
let name = fileName
|
||||
|
||||
if (!name && uri?.startsWith("file://")) {
|
||||
const splittedArray = uri?.split("/") || [];
|
||||
name = splittedArray?.[splittedArray?.length - 1] || "";
|
||||
if (!name && uri?.startsWith('file://')) {
|
||||
const splittedArray = uri?.split('/') || []
|
||||
name = splittedArray?.[splittedArray?.length - 1] || ''
|
||||
}
|
||||
|
||||
if (!name?.length) {
|
||||
let extension = uri?.split(";")?.[0]?.split("/")?.[1] || "";
|
||||
let extension = uri?.split(';')?.[0]?.split('/')?.[1] || ''
|
||||
|
||||
const defaultFileName = getDefaultFileName();
|
||||
const defaultFileName = getDefaultFileName()
|
||||
|
||||
if (extension?.length) {
|
||||
name = `${defaultFileName}.${extension}`;
|
||||
name = `${defaultFileName}.${extension}`
|
||||
} else {
|
||||
name = `${defaultFileName}`;
|
||||
name = `${defaultFileName}`
|
||||
}
|
||||
}
|
||||
|
||||
return name;
|
||||
};
|
||||
return name
|
||||
}
|
||||
|
||||
const onUploadDocument = async ({ files = [] } = {}) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setIsLoading(true)
|
||||
|
||||
const filesToUpdate = [];
|
||||
const filesToUpdate = []
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const { name = "", uri = null } = file;
|
||||
const { name = '', uri = null } = file
|
||||
|
||||
console.log(file);
|
||||
console.log(file)
|
||||
|
||||
if (!uri) {
|
||||
throw new Error("Erreur lors de l'ajout du document");
|
||||
throw new Error("Erreur lors de l'ajout du document")
|
||||
}
|
||||
|
||||
const fileName = name || getDefaultFileName();
|
||||
let type = "FILE";
|
||||
const fileName = name || getDefaultFileName()
|
||||
let type = 'FILE'
|
||||
|
||||
if (name?.toLowerCase()?.match(/\.(jpeg|jpg|gif|png)$/) != null) {
|
||||
type = "IMAGE";
|
||||
type = 'IMAGE'
|
||||
}
|
||||
|
||||
if (name?.toLowerCase()?.match(/\.(mp4|mov|avi|mkv)$/) != null) {
|
||||
type = "VIDEO";
|
||||
type = 'VIDEO'
|
||||
}
|
||||
|
||||
let compressedURI = uri;
|
||||
let thumbnailURI = null;
|
||||
let compressedURI = uri
|
||||
let thumbnailURI = null
|
||||
|
||||
if (isNative) {
|
||||
if (type === "IMAGE") {
|
||||
if (type === 'IMAGE') {
|
||||
compressedURI = await Compressor.Image.compress(uri, {
|
||||
compressionMethod: "manual",
|
||||
compressionMethod: 'manual',
|
||||
maxWidth: 1000,
|
||||
quality: 0.8,
|
||||
});
|
||||
} else if (type === "VIDEO") {
|
||||
compressedURI = await Compressor.Video.compress(uri);
|
||||
})
|
||||
} else if (type === 'VIDEO') {
|
||||
compressedURI = await Compressor.Video.compress(uri)
|
||||
}
|
||||
}
|
||||
|
||||
const { resultURI = null } = await uploadFileToFirebase({
|
||||
uri: compressedURI,
|
||||
path: `documents/${documentID}/files/${fileName}`,
|
||||
});
|
||||
})
|
||||
|
||||
if (resultURI) {
|
||||
if (shouldReturnObject) {
|
||||
@@ -319,39 +319,39 @@ const DocumentDropZone = ({
|
||||
uri: resultURI,
|
||||
type,
|
||||
thumbnailURI,
|
||||
});
|
||||
})
|
||||
} else {
|
||||
filesToUpdate.push(resultURI);
|
||||
filesToUpdate.push(resultURI)
|
||||
}
|
||||
} else {
|
||||
console.log("resultURI is null");
|
||||
console.log('resultURI is null')
|
||||
}
|
||||
})
|
||||
);
|
||||
)
|
||||
|
||||
if (filesToUpdate.length > 0) {
|
||||
if (documentExists) {
|
||||
await collectionRef.doc(documentID).update({
|
||||
files: arrayUnion(...filesToUpdate),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
if (setFiles) {
|
||||
setFiles((prev) => [...prev, ...filesToUpdate]);
|
||||
setFiles((prev) => [...prev, ...filesToUpdate])
|
||||
}
|
||||
}
|
||||
|
||||
setTooltip({ text: "Document(s) ajouté(s) avec succès" });
|
||||
setTooltip({ text: 'Document(s) ajouté(s) avec succès' })
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
setTooltip({ text: error.message, type: "error" });
|
||||
console.log(error)
|
||||
setTooltip({ text: error.message, type: 'error' })
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!documentID) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -361,11 +361,9 @@ const DocumentDropZone = ({
|
||||
<View
|
||||
style={{
|
||||
...Style.containerCenter,
|
||||
backgroundColor: isDragging
|
||||
? Palette.transparentGreen
|
||||
: Palette.transparentPrimary,
|
||||
backgroundColor: isDragging ? Palette.transparentGreen : Palette.transparentPrimary,
|
||||
borderRadius: mainBorderRadius,
|
||||
borderStyle: "dashed",
|
||||
borderStyle: 'dashed',
|
||||
borderWidth: 2,
|
||||
borderColor: isDragging ? Palette.green : Palette.primary,
|
||||
...containerStyle,
|
||||
@@ -380,20 +378,20 @@ const DocumentDropZone = ({
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
...Fonts({ type: "default" }),
|
||||
textAlign: "center",
|
||||
...Fonts({ type: 'default' }),
|
||||
textAlign: 'center',
|
||||
color: Palette.white,
|
||||
}}
|
||||
>
|
||||
{isWeb && isDesktop
|
||||
? `Glissez-déposez ici\nles fichiers, images et vidéos\nà ajouter.`
|
||||
: "Ajouter une image,\nun fichier ou une vidéo."}
|
||||
: 'Ajouter une image,\nun fichier ou une vidéo.'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default DocumentDropZone;
|
||||
export default DocumentDropZone
|
||||
|
||||
@@ -1,40 +1,34 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View } from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
|
||||
const DynamicImage = ({ uri, children, ...props }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [numRetries, setNumRetries] = useState(0);
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [numRetries, setNumRetries] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (numRetries < 15) {
|
||||
const timer = setTimeout(() => {
|
||||
setLoading(true);
|
||||
}, 2500);
|
||||
return () => clearTimeout(timer);
|
||||
setLoading(true)
|
||||
}, 2500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [numRetries]);
|
||||
}, [numRetries])
|
||||
|
||||
const handleError = () => {
|
||||
setLoading(false);
|
||||
setNumRetries(numRetries + 1);
|
||||
};
|
||||
setLoading(false)
|
||||
setNumRetries(numRetries + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{loading && numRetries < 15 && uri ? (
|
||||
<Image
|
||||
source={uri}
|
||||
contentFit="cover"
|
||||
transition={500}
|
||||
{...props}
|
||||
onError={handleError}
|
||||
/>
|
||||
<Image source={uri} contentFit="cover" transition={500} {...props} onError={handleError} />
|
||||
) : (
|
||||
<View {...props}>{children}</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default DynamicImage;
|
||||
export default DynamicImage
|
||||
|
||||
@@ -1,60 +1,54 @@
|
||||
import React, { useGlobal } from "reactn";
|
||||
import { View, Text } from "react-native";
|
||||
import React, { useGlobal } from 'reactn'
|
||||
import { View, Text } from 'react-native'
|
||||
|
||||
import useLayoutType from "../hooks/useLayoutType";
|
||||
import useLayoutType from '../hooks/useLayoutType'
|
||||
|
||||
import Button from "./Button";
|
||||
import Button from './Button'
|
||||
|
||||
import { Style, Fonts } from "../styles";
|
||||
import { responsiveScreenHeight } from "react-native-responsive-dimensions";
|
||||
import { Style, Fonts } from '../styles'
|
||||
import { responsiveScreenHeight } from 'react-native-responsive-dimensions'
|
||||
|
||||
const EmptyFlashListPlaceholder = ({
|
||||
loading = false,
|
||||
text = "-",
|
||||
buttonData = {},
|
||||
}) => {
|
||||
const [, setShowSearch] = useGlobal("showSearch");
|
||||
const EmptyFlashListPlaceholder = ({ loading = false, text = '-', buttonData = {} }) => {
|
||||
const [, setShowSearch] = useGlobal('showSearch')
|
||||
|
||||
const { isMobile } = useLayoutType;
|
||||
const { isMobile } = useLayoutType
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Text style={Fonts({ type: "default", style: { textAlign: "center" } })}>
|
||||
<Text style={Fonts({ type: 'default', style: { textAlign: 'center' } })}>
|
||||
Chargement en cours...
|
||||
</Text>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
...Style.containerCenter,
|
||||
alignSelf: "center",
|
||||
width: isMobile ? "100%" : "50%",
|
||||
alignSelf: 'center',
|
||||
width: isMobile ? '100%' : '50%',
|
||||
height: responsiveScreenHeight(50),
|
||||
}}
|
||||
>
|
||||
<Text style={Fonts({ type: "default", style: { textAlign: "center" } })}>
|
||||
{text}
|
||||
</Text>
|
||||
<Text style={Fonts({ type: 'default', style: { textAlign: 'center' } })}>{text}</Text>
|
||||
|
||||
{buttonData?.text?.length > 0 && (
|
||||
<Button
|
||||
text={buttonData?.text || "Ajouter une tâche"}
|
||||
text={buttonData?.text || 'Ajouter une tâche'}
|
||||
type="secondary"
|
||||
onPress={() => {
|
||||
setShowSearch(false);
|
||||
setShowSearch(false)
|
||||
|
||||
buttonData?.onPress?.() || (() => console.log("null"));
|
||||
buttonData?.onPress?.() || (() => console.log('null'))
|
||||
}}
|
||||
containerStyle={{
|
||||
alignSelf: "center",
|
||||
width: "100%",
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyFlashListPlaceholder;
|
||||
export default EmptyFlashListPlaceholder
|
||||
|
||||
@@ -1,54 +1,41 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Animated,
|
||||
FlatList,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { responsiveHeight } from "../../actions/responsiveSizes";
|
||||
import { ai, cardsImg } from "../../assets";
|
||||
import { gutters } from "../../styles";
|
||||
import { getCreationStageStates } from "../../utils/projectStages";
|
||||
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
|
||||
import PersonaCard from "../cards/PersonaCard/PersonaCard";
|
||||
import { BlurView } from 'expo-blur'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Animated, FlatList, Platform, StyleSheet, View, useWindowDimensions } from 'react-native'
|
||||
import { responsiveHeight } from '../../actions/responsiveSizes'
|
||||
import { ai, cardsImg } from '../../assets'
|
||||
import { gutters } from '../../styles'
|
||||
import { getCreationStageStates } from '../../utils/projectStages'
|
||||
import AnimatedPaginationDot from '../AnimatedPaginationDot/AnimatedPaginationDot'
|
||||
import PersonaCard from '../cards/PersonaCard/PersonaCard'
|
||||
|
||||
const STAGE_CARD_CONTENT = [
|
||||
{
|
||||
key: "songwriter",
|
||||
title: "Céline",
|
||||
description: "Let’s write lyrics together !",
|
||||
key: 'songwriter',
|
||||
title: 'Céline',
|
||||
description: 'Let’s write lyrics together !',
|
||||
image: ai.leftIcon,
|
||||
},
|
||||
{
|
||||
key: "beatmaker",
|
||||
title: "Theo",
|
||||
key: 'beatmaker',
|
||||
title: 'Theo',
|
||||
description: "Come back, when you'll have lyrics!",
|
||||
image: ai.rightIcon,
|
||||
},
|
||||
{
|
||||
key: "director",
|
||||
title: "Theo",
|
||||
key: 'director',
|
||||
title: 'Theo',
|
||||
description: "Theo t'accompagne pour créer ton playback.",
|
||||
image: ai.rightIcon,
|
||||
},
|
||||
{
|
||||
key: "publisher",
|
||||
title: "Publication",
|
||||
description: "Ta vidéo est prête ? Direction YouTube !",
|
||||
key: 'publisher',
|
||||
title: 'Publication',
|
||||
description: 'Ta vidéo est prête ? Direction YouTube !',
|
||||
image: cardsImg.production,
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const WEB_SCROLL_INACTIVE_DELTA = 0.05;
|
||||
const WEB_SCROLL_INACTIVE_DELTA = 0.05
|
||||
|
||||
const FeatureCarousel = ({
|
||||
style,
|
||||
@@ -59,231 +46,225 @@ const FeatureCarousel = ({
|
||||
}) => {
|
||||
const stageStates = useMemo(() => {
|
||||
if (stageStatesProp) {
|
||||
return stageStatesProp;
|
||||
return stageStatesProp
|
||||
}
|
||||
return getCreationStageStates(selectedProject);
|
||||
}, [stageStatesProp, selectedProject]);
|
||||
return getCreationStageStates(selectedProject)
|
||||
}, [stageStatesProp, selectedProject])
|
||||
|
||||
const stageStatesByKey = useMemo(() => {
|
||||
if (!Array.isArray(stageStates)) {
|
||||
return {};
|
||||
return {}
|
||||
}
|
||||
return stageStates.reduce((acc, stage) => {
|
||||
if (stage?.key) {
|
||||
acc[stage.key] = stage;
|
||||
acc[stage.key] = stage
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}, [stageStates]);
|
||||
return acc
|
||||
}, {})
|
||||
}, [stageStates])
|
||||
|
||||
const carouselItems = useMemo(
|
||||
() =>
|
||||
STAGE_CARD_CONTENT.map((item) => {
|
||||
const state = stageStatesByKey[item.key];
|
||||
const state = stageStatesByKey[item.key]
|
||||
return {
|
||||
...item,
|
||||
isLocked: state?.isLocked ?? true,
|
||||
description: state?.description ?? item.description,
|
||||
};
|
||||
}
|
||||
}),
|
||||
[stageStatesByKey]
|
||||
);
|
||||
)
|
||||
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const isWeb = Platform.OS === "web";
|
||||
const { height: windowHeight } = useWindowDimensions()
|
||||
const isWeb = Platform.OS === 'web'
|
||||
|
||||
const [viewportHeight, setViewportHeight] = useState(() =>
|
||||
Math.max(windowHeight, 1)
|
||||
);
|
||||
const [viewportHeight, setViewportHeight] = useState(() => Math.max(windowHeight, 1))
|
||||
|
||||
const updateSnapHeight = useCallback((height) => {
|
||||
if (!height || Number.isNaN(height)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
setViewportHeight((prev) => {
|
||||
if (prev == null || Math.abs(prev - height) > 0.5) {
|
||||
return height;
|
||||
return height
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
return prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
updateSnapHeight(Math.max(windowHeight, 1));
|
||||
}, [updateSnapHeight, windowHeight]);
|
||||
updateSnapHeight(Math.max(windowHeight, 1))
|
||||
}, [updateSnapHeight, windowHeight])
|
||||
|
||||
const itemHeight = Math.max(viewportHeight, 1);
|
||||
const itemHeight = Math.max(viewportHeight, 1)
|
||||
|
||||
const listRef = useRef(null);
|
||||
const pendingScrollRef = useRef(false);
|
||||
const alignTimeoutRef = useRef(null);
|
||||
const activeIndexRef = useRef(
|
||||
typeof activeIndex === "number" ? activeIndex : 0
|
||||
);
|
||||
const onActiveIndexChangeRef = useRef(onActiveIndexChange);
|
||||
const scrollY = useRef(new Animated.Value(0)).current;
|
||||
const listRef = useRef(null)
|
||||
const pendingScrollRef = useRef(false)
|
||||
const alignTimeoutRef = useRef(null)
|
||||
const activeIndexRef = useRef(typeof activeIndex === 'number' ? activeIndex : 0)
|
||||
const onActiveIndexChangeRef = useRef(onActiveIndexChange)
|
||||
const scrollY = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
onActiveIndexChangeRef.current = onActiveIndexChange;
|
||||
}, [onActiveIndexChange]);
|
||||
onActiveIndexChangeRef.current = onActiveIndexChange
|
||||
}, [onActiveIndexChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof activeIndex === "number") {
|
||||
activeIndexRef.current = activeIndex;
|
||||
if (typeof activeIndex === 'number') {
|
||||
activeIndexRef.current = activeIndex
|
||||
}
|
||||
}, [activeIndex]);
|
||||
}, [activeIndex])
|
||||
|
||||
const clampIndex = useCallback(
|
||||
(index) => {
|
||||
if (!carouselItems.length) {
|
||||
return 0;
|
||||
return 0
|
||||
}
|
||||
if (index < 0) {
|
||||
return 0;
|
||||
return 0
|
||||
}
|
||||
if (index >= carouselItems.length) {
|
||||
return carouselItems.length - 1;
|
||||
return carouselItems.length - 1
|
||||
}
|
||||
return index;
|
||||
return index
|
||||
},
|
||||
[carouselItems.length]
|
||||
);
|
||||
)
|
||||
|
||||
const clearPendingAlignment = useCallback(() => {
|
||||
if (alignTimeoutRef.current != null) {
|
||||
globalThis.clearTimeout(alignTimeoutRef.current);
|
||||
alignTimeoutRef.current = null;
|
||||
globalThis.clearTimeout(alignTimeoutRef.current)
|
||||
alignTimeoutRef.current = null
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]);
|
||||
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment])
|
||||
|
||||
const scrollToIndex = useCallback(
|
||||
(index, animated = true, heightOverride) => {
|
||||
const ref = listRef.current;
|
||||
const ref = listRef.current
|
||||
if (!ref) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const clamped = clampIndex(index);
|
||||
const height =
|
||||
heightOverride && heightOverride > 0 ? heightOverride : itemHeight;
|
||||
const clamped = clampIndex(index)
|
||||
const height = heightOverride && heightOverride > 0 ? heightOverride : itemHeight
|
||||
|
||||
if (isWeb) {
|
||||
if (!height) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
updateSnapHeight(height);
|
||||
updateSnapHeight(height)
|
||||
try {
|
||||
ref.scrollToOffset({ offset: clamped * height, animated });
|
||||
ref.scrollToOffset({ offset: clamped * height, animated })
|
||||
} catch (_error) {
|
||||
// Ignore scroll errors when list is not ready yet.
|
||||
}
|
||||
activeIndexRef.current = clamped;
|
||||
return;
|
||||
activeIndexRef.current = clamped
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
pendingScrollRef.current = !!animated;
|
||||
ref.scrollToIndex({ index: clamped, animated });
|
||||
activeIndexRef.current = clamped;
|
||||
pendingScrollRef.current = !!animated
|
||||
ref.scrollToIndex({ index: clamped, animated })
|
||||
activeIndexRef.current = clamped
|
||||
} catch (_error) {
|
||||
pendingScrollRef.current = false;
|
||||
pendingScrollRef.current = false
|
||||
}
|
||||
},
|
||||
[clampIndex, isWeb, itemHeight, updateSnapHeight]
|
||||
);
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
listRef.current == null ||
|
||||
typeof activeIndex !== "number" ||
|
||||
typeof activeIndex !== 'number' ||
|
||||
activeIndex < 0 ||
|
||||
activeIndex >= carouselItems.length ||
|
||||
(isWeb && !itemHeight)
|
||||
) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
scrollToIndex(activeIndex);
|
||||
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
||||
scrollToIndex(activeIndex)
|
||||
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current == null || (isWeb && !itemHeight)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
scrollToIndex(activeIndexRef.current, false);
|
||||
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
||||
scrollToIndex(activeIndexRef.current, false)
|
||||
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex])
|
||||
|
||||
const alignToOffset = useCallback(
|
||||
(offset, layoutHeight) => {
|
||||
const height =
|
||||
layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight;
|
||||
const height = layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight
|
||||
if (!height) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
updateSnapHeight(height);
|
||||
updateSnapHeight(height)
|
||||
|
||||
const currentIndex = activeIndexRef.current;
|
||||
const rawIndex = height ? offset / height : currentIndex;
|
||||
const currentIndex = activeIndexRef.current
|
||||
const rawIndex = height ? offset / height : currentIndex
|
||||
|
||||
let nextIndex = currentIndex;
|
||||
let nextIndex = currentIndex
|
||||
if (isWeb) {
|
||||
const delta = rawIndex - currentIndex;
|
||||
const delta = rawIndex - currentIndex
|
||||
if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) {
|
||||
if (Math.abs(delta) <= 1) {
|
||||
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1));
|
||||
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1))
|
||||
} else {
|
||||
nextIndex = clampIndex(currentIndex + Math.round(delta));
|
||||
nextIndex = clampIndex(currentIndex + Math.round(delta))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
nextIndex = clampIndex(Math.round(rawIndex));
|
||||
nextIndex = clampIndex(Math.round(rawIndex))
|
||||
}
|
||||
|
||||
const hasChanged = nextIndex !== activeIndexRef.current;
|
||||
const hasChanged = nextIndex !== activeIndexRef.current
|
||||
|
||||
if (hasChanged) {
|
||||
activeIndexRef.current = nextIndex;
|
||||
const callback = onActiveIndexChangeRef.current;
|
||||
activeIndexRef.current = nextIndex
|
||||
const callback = onActiveIndexChangeRef.current
|
||||
if (callback) {
|
||||
callback(nextIndex);
|
||||
callback(nextIndex)
|
||||
}
|
||||
}
|
||||
|
||||
if (isWeb || hasChanged) {
|
||||
const shouldAnimate = isWeb ? true : !isWeb;
|
||||
scrollToIndex(nextIndex, shouldAnimate, height);
|
||||
const shouldAnimate = isWeb ? true : !isWeb
|
||||
scrollToIndex(nextIndex, shouldAnimate, height)
|
||||
}
|
||||
},
|
||||
[clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight]
|
||||
);
|
||||
)
|
||||
|
||||
const handleScrollEnd = useCallback(
|
||||
(event) => {
|
||||
clearPendingAlignment();
|
||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
|
||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
|
||||
alignToOffset(offsetY, layoutHeight);
|
||||
clearPendingAlignment()
|
||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
|
||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
|
||||
alignToOffset(offsetY, layoutHeight)
|
||||
},
|
||||
[alignToOffset, clearPendingAlignment]
|
||||
);
|
||||
)
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event) => {
|
||||
if (!isWeb) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
|
||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
|
||||
clearPendingAlignment();
|
||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
|
||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
|
||||
clearPendingAlignment()
|
||||
alignTimeoutRef.current = globalThis.setTimeout(() => {
|
||||
alignToOffset(offsetY, layoutHeight);
|
||||
alignTimeoutRef.current = null;
|
||||
}, 80);
|
||||
alignToOffset(offsetY, layoutHeight)
|
||||
alignTimeoutRef.current = null
|
||||
}, 80)
|
||||
},
|
||||
[alignToOffset, clearPendingAlignment, isWeb]
|
||||
);
|
||||
)
|
||||
|
||||
const animatedScrollHandler = useMemo(
|
||||
() =>
|
||||
@@ -292,31 +273,26 @@ const FeatureCarousel = ({
|
||||
listener: isWeb ? handleScroll : undefined,
|
||||
}),
|
||||
[handleScroll, isWeb, scrollY]
|
||||
);
|
||||
)
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event) => {
|
||||
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0;
|
||||
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0
|
||||
if (layoutHeight > 0) {
|
||||
updateSnapHeight(layoutHeight);
|
||||
updateSnapHeight(layoutHeight)
|
||||
}
|
||||
},
|
||||
[updateSnapHeight]
|
||||
);
|
||||
)
|
||||
|
||||
const keyExtractor = useCallback((item) => item.key, []);
|
||||
const keyExtractor = useCallback((item) => item.key, [])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }) => (
|
||||
<PersonaCard
|
||||
item={item}
|
||||
index={index}
|
||||
isLock={item.isLocked}
|
||||
height={itemHeight}
|
||||
/>
|
||||
<PersonaCard item={item} index={index} isLock={item.isLocked} height={itemHeight} />
|
||||
),
|
||||
[itemHeight]
|
||||
);
|
||||
)
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(_data, index) => ({
|
||||
@@ -325,50 +301,44 @@ const FeatureCarousel = ({
|
||||
index,
|
||||
}),
|
||||
[itemHeight]
|
||||
);
|
||||
)
|
||||
|
||||
const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 });
|
||||
const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 })
|
||||
|
||||
const handleViewableItemsChangedRef = useRef();
|
||||
const handleViewableItemsChangedRef = useRef()
|
||||
if (!handleViewableItemsChangedRef.current) {
|
||||
handleViewableItemsChangedRef.current = ({ viewableItems }) => {
|
||||
if (!viewableItems?.length) return;
|
||||
const firstVisible = viewableItems.find((item) => item?.isViewable);
|
||||
if (!firstVisible || firstVisible.index == null) return;
|
||||
if (!viewableItems?.length) return
|
||||
const firstVisible = viewableItems.find((item) => item?.isViewable)
|
||||
if (!firstVisible || firstVisible.index == null) return
|
||||
if (pendingScrollRef.current) {
|
||||
if (firstVisible.index === activeIndexRef.current) {
|
||||
pendingScrollRef.current = false;
|
||||
pendingScrollRef.current = false
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
const callback = onActiveIndexChangeRef.current;
|
||||
const callback = onActiveIndexChangeRef.current
|
||||
if (callback && firstVisible.index !== activeIndexRef.current) {
|
||||
callback(firstVisible.index);
|
||||
callback(firstVisible.index)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
if (!itemHeight || !isWeb) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
return carouselItems.map((_, index) => index * itemHeight);
|
||||
}, [carouselItems, isWeb, itemHeight]);
|
||||
return carouselItems.map((_, index) => index * itemHeight)
|
||||
}, [carouselItems, isWeb, itemHeight])
|
||||
|
||||
const blurIntensity = isWeb ? 80 : 30;
|
||||
const blurIntensity = isWeb ? 80 : 30
|
||||
const dotsWrapperStyle = useMemo(
|
||||
() => [
|
||||
styles.dotsWrapperBase,
|
||||
isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative,
|
||||
],
|
||||
() => [styles.dotsWrapperBase, isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative],
|
||||
[isWeb]
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.container, isWeb && styles.containerWeb, style]}
|
||||
onLayout={handleLayout}
|
||||
>
|
||||
<View style={[styles.container, isWeb && styles.containerWeb, style]} onLayout={handleLayout}>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={carouselItems}
|
||||
@@ -385,11 +355,11 @@ const FeatureCarousel = ({
|
||||
maxToRenderPerBatch={2}
|
||||
windowSize={3}
|
||||
scrollEventThrottle={16}
|
||||
snapToAlignment={isWeb ? undefined : "start"}
|
||||
snapToAlignment={isWeb ? undefined : 'start'}
|
||||
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
|
||||
snapToOffsets={snapOffsets}
|
||||
disableIntervalMomentum={!isWeb}
|
||||
decelerationRate={!isWeb ? "fast" : undefined}
|
||||
decelerationRate={!isWeb ? 'fast' : undefined}
|
||||
style={styles.list}
|
||||
onScroll={animatedScrollHandler}
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
@@ -397,7 +367,7 @@ const FeatureCarousel = ({
|
||||
/>
|
||||
<BlurView
|
||||
intensity={blurIntensity}
|
||||
tint={Platform.OS === "web" ? undefined : "dark"}
|
||||
tint={Platform.OS === 'web' ? undefined : 'dark'}
|
||||
style={dotsWrapperStyle}
|
||||
pointerEvents="none"
|
||||
>
|
||||
@@ -414,16 +384,16 @@ const FeatureCarousel = ({
|
||||
/>
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default FeatureCarousel;
|
||||
export default FeatureCarousel
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
paddingBottom: responsiveHeight(5),
|
||||
paddingHorizontal: gutters,
|
||||
},
|
||||
@@ -434,28 +404,28 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
dotsWrapperBase: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(18, 18, 18, 0.2)",
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(18, 18, 18, 0.2)',
|
||||
},
|
||||
dotsWrapperWeb: {
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
maxHeight: "75%",
|
||||
alignSelf: "center",
|
||||
maxHeight: '75%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
dotsWrapperNative: {
|
||||
marginLeft: 12,
|
||||
alignSelf: "center",
|
||||
alignSelf: 'center',
|
||||
},
|
||||
dotsContainer: {
|
||||
flexDirection: "column",
|
||||
flexDirection: 'column',
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
@@ -463,6 +433,6 @@ const styles = StyleSheet.create({
|
||||
marginHorizontal: 0,
|
||||
marginVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: "rgba(255,255,255,0.4)",
|
||||
backgroundColor: 'rgba(255,255,255,0.4)',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { BlurView } from 'expo-blur'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Animated,
|
||||
ImageBackground,
|
||||
@@ -13,42 +7,42 @@ import {
|
||||
StyleSheet,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { ai, cardsImg } from "../../assets";
|
||||
import { getCreationStageStates } from "../../utils/projectStages";
|
||||
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
|
||||
import PersonaCard from "../cards/PersonaCard/PersonaCard";
|
||||
import { LinearGradient } from "../LinearGradient/LinearGradient";
|
||||
} from 'react-native'
|
||||
import { ai, cardsImg } from '../../assets'
|
||||
import { getCreationStageStates } from '../../utils/projectStages'
|
||||
import AnimatedPaginationDot from '../AnimatedPaginationDot/AnimatedPaginationDot'
|
||||
import PersonaCard from '../cards/PersonaCard/PersonaCard'
|
||||
import { LinearGradient } from '../LinearGradient/LinearGradient'
|
||||
|
||||
const STAGE_CARD_CONTENT = [
|
||||
{
|
||||
key: "songwriter",
|
||||
title: "Céline",
|
||||
description: "Let’s write lyrics together !",
|
||||
key: 'songwriter',
|
||||
title: 'Céline',
|
||||
description: 'Let’s write lyrics together !',
|
||||
image: ai.leftIcon,
|
||||
},
|
||||
{
|
||||
key: "beatmaker",
|
||||
title: "Theo",
|
||||
key: 'beatmaker',
|
||||
title: 'Theo',
|
||||
description: "Come back, when you'll have lyrics!",
|
||||
image: ai.rightIcon,
|
||||
},
|
||||
{
|
||||
key: "director",
|
||||
title: "Theo",
|
||||
key: 'director',
|
||||
title: 'Theo',
|
||||
description: "Theo t'accompagne pour créer ton playback.",
|
||||
image: ai.rightIcon,
|
||||
},
|
||||
{
|
||||
key: "publisher",
|
||||
title: "Publication",
|
||||
description: "Ta vidéo est prête ? Direction YouTube !",
|
||||
key: 'publisher',
|
||||
title: 'Publication',
|
||||
description: 'Ta vidéo est prête ? Direction YouTube !',
|
||||
image: cardsImg.production,
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const WEB_SCROLL_INACTIVE_DELTA = 0.05;
|
||||
const HOME_BACKGROUND_COLOR = "#425B87"; // Derived from the bottom color of the home hero image
|
||||
const WEB_SCROLL_INACTIVE_DELTA = 0.05
|
||||
const HOME_BACKGROUND_COLOR = '#425B87' // Derived from the bottom color of the home hero image
|
||||
const FeatureCarousel = ({
|
||||
style,
|
||||
selectedProject,
|
||||
@@ -60,257 +54,245 @@ const FeatureCarousel = ({
|
||||
}) => {
|
||||
const stageStates = useMemo(() => {
|
||||
if (stageStatesProp) {
|
||||
return stageStatesProp;
|
||||
return stageStatesProp
|
||||
}
|
||||
return getCreationStageStates(selectedProject);
|
||||
}, [stageStatesProp, selectedProject]);
|
||||
return getCreationStageStates(selectedProject)
|
||||
}, [stageStatesProp, selectedProject])
|
||||
|
||||
const stageStatesByKey = useMemo(() => {
|
||||
if (!Array.isArray(stageStates)) {
|
||||
return {};
|
||||
return {}
|
||||
}
|
||||
return stageStates.reduce((acc, stage) => {
|
||||
if (stage?.key) {
|
||||
acc[stage.key] = stage;
|
||||
acc[stage.key] = stage
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}, [stageStates]);
|
||||
return acc
|
||||
}, {})
|
||||
}, [stageStates])
|
||||
|
||||
const carouselItems = useMemo(
|
||||
() =>
|
||||
STAGE_CARD_CONTENT.map((item) => {
|
||||
const state = stageStatesByKey[item.key];
|
||||
const state = stageStatesByKey[item.key]
|
||||
return {
|
||||
...item,
|
||||
isLocked: state?.isLocked ?? true,
|
||||
description: state?.description ?? item.description,
|
||||
};
|
||||
}
|
||||
}),
|
||||
[stageStatesByKey]
|
||||
);
|
||||
)
|
||||
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const isWeb = Platform.OS === "web";
|
||||
const { height: windowHeight } = useWindowDimensions()
|
||||
const isWeb = Platform.OS === 'web'
|
||||
|
||||
const [viewportHeight, setViewportHeight] = useState(() =>
|
||||
Math.max(windowHeight, 1)
|
||||
);
|
||||
const [viewportHeight, setViewportHeight] = useState(() => Math.max(windowHeight, 1))
|
||||
|
||||
const updateSnapHeight = useCallback((height) => {
|
||||
if (!height || Number.isNaN(height)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
setViewportHeight((prev) => {
|
||||
if (prev == null || Math.abs(prev - height) > 0.5) {
|
||||
return height;
|
||||
return height
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
return prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
updateSnapHeight(Math.max(windowHeight, 1));
|
||||
}, [updateSnapHeight, windowHeight]);
|
||||
updateSnapHeight(Math.max(windowHeight, 1))
|
||||
}, [updateSnapHeight, windowHeight])
|
||||
|
||||
const itemHeight = Math.max(viewportHeight, 1);
|
||||
const itemHeight = Math.max(viewportHeight, 1)
|
||||
|
||||
const scrollViewRef = useRef(null);
|
||||
const alignTimeoutRef = useRef(null);
|
||||
const activeIndexRef = useRef(
|
||||
typeof activeIndex === "number" ? activeIndex : 0
|
||||
);
|
||||
const onActiveIndexChangeRef = useRef(onActiveIndexChange);
|
||||
const scrollY = useRef(new Animated.Value(0)).current;
|
||||
const scrollViewRef = useRef(null)
|
||||
const alignTimeoutRef = useRef(null)
|
||||
const activeIndexRef = useRef(typeof activeIndex === 'number' ? activeIndex : 0)
|
||||
const onActiveIndexChangeRef = useRef(onActiveIndexChange)
|
||||
const scrollY = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
onActiveIndexChangeRef.current = onActiveIndexChange;
|
||||
}, [onActiveIndexChange]);
|
||||
onActiveIndexChangeRef.current = onActiveIndexChange
|
||||
}, [onActiveIndexChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof activeIndex === "number") {
|
||||
activeIndexRef.current = activeIndex;
|
||||
if (typeof activeIndex === 'number') {
|
||||
activeIndexRef.current = activeIndex
|
||||
}
|
||||
}, [activeIndex]);
|
||||
}, [activeIndex])
|
||||
|
||||
const clampIndex = useCallback(
|
||||
(index) => {
|
||||
if (!carouselItems.length) {
|
||||
return 0;
|
||||
return 0
|
||||
}
|
||||
if (index < 0) {
|
||||
return 0;
|
||||
return 0
|
||||
}
|
||||
if (index >= carouselItems.length) {
|
||||
return carouselItems.length - 1;
|
||||
return carouselItems.length - 1
|
||||
}
|
||||
return index;
|
||||
return index
|
||||
},
|
||||
[carouselItems.length]
|
||||
);
|
||||
)
|
||||
|
||||
const clearPendingAlignment = useCallback(() => {
|
||||
if (alignTimeoutRef.current != null) {
|
||||
globalThis.clearTimeout(alignTimeoutRef.current);
|
||||
alignTimeoutRef.current = null;
|
||||
globalThis.clearTimeout(alignTimeoutRef.current)
|
||||
alignTimeoutRef.current = null
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]);
|
||||
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment])
|
||||
|
||||
const getScrollNode = useCallback(() => {
|
||||
const node = scrollViewRef.current;
|
||||
const node = scrollViewRef.current
|
||||
if (!node) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
if (typeof node.scrollTo === "function") {
|
||||
return node;
|
||||
if (typeof node.scrollTo === 'function') {
|
||||
return node
|
||||
}
|
||||
if (typeof node.getNode === "function") {
|
||||
return node.getNode();
|
||||
if (typeof node.getNode === 'function') {
|
||||
return node.getNode()
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
return null
|
||||
}, [])
|
||||
|
||||
const scrollToIndex = useCallback(
|
||||
(index, animated = true, heightOverride) => {
|
||||
const target = getScrollNode();
|
||||
const target = getScrollNode()
|
||||
if (!target) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const clamped = clampIndex(index);
|
||||
const height =
|
||||
heightOverride && heightOverride > 0 ? heightOverride : itemHeight;
|
||||
const clamped = clampIndex(index)
|
||||
const height = heightOverride && heightOverride > 0 ? heightOverride : itemHeight
|
||||
|
||||
if (!height) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
updateSnapHeight(height);
|
||||
const offset = clamped * height;
|
||||
updateSnapHeight(height)
|
||||
const offset = clamped * height
|
||||
|
||||
try {
|
||||
if (typeof target.scrollTo === "function") {
|
||||
target.scrollTo({ y: offset, animated });
|
||||
} else if (typeof target.scrollToOffset === "function") {
|
||||
target.scrollToOffset({ offset, animated });
|
||||
if (typeof target.scrollTo === 'function') {
|
||||
target.scrollTo({ y: offset, animated })
|
||||
} else if (typeof target.scrollToOffset === 'function') {
|
||||
target.scrollToOffset({ offset, animated })
|
||||
}
|
||||
} catch (_error) {
|
||||
// ScrollView not ready yet, ignore.
|
||||
}
|
||||
activeIndexRef.current = clamped;
|
||||
activeIndexRef.current = clamped
|
||||
},
|
||||
[clampIndex, getScrollNode, itemHeight, updateSnapHeight]
|
||||
);
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
scrollViewRef.current == null ||
|
||||
typeof activeIndex !== "number" ||
|
||||
typeof activeIndex !== 'number' ||
|
||||
activeIndex < 0 ||
|
||||
activeIndex >= carouselItems.length ||
|
||||
(isWeb && !itemHeight)
|
||||
) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
scrollToIndex(activeIndex);
|
||||
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
||||
scrollToIndex(activeIndex)
|
||||
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollViewRef.current == null || (isWeb && !itemHeight)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
scrollToIndex(activeIndexRef.current, false);
|
||||
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
||||
scrollToIndex(activeIndexRef.current, false)
|
||||
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !isFocused) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
if (!itemHeight) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
clearPendingAlignment();
|
||||
scrollToIndex(activeIndexRef.current, false);
|
||||
}, [
|
||||
clearPendingAlignment,
|
||||
isFocused,
|
||||
isWeb,
|
||||
itemHeight,
|
||||
scrollToIndex,
|
||||
]);
|
||||
clearPendingAlignment()
|
||||
scrollToIndex(activeIndexRef.current, false)
|
||||
}, [clearPendingAlignment, isFocused, isWeb, itemHeight, scrollToIndex])
|
||||
|
||||
const alignToOffset = useCallback(
|
||||
(offset, layoutHeight, options = {}) => {
|
||||
const { forceSnap = false } = options || {};
|
||||
const height =
|
||||
layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight;
|
||||
const { forceSnap = false } = options || {}
|
||||
const height = layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight
|
||||
if (!height) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
updateSnapHeight(height);
|
||||
updateSnapHeight(height)
|
||||
|
||||
const currentIndex = activeIndexRef.current;
|
||||
const rawIndex = height ? offset / height : currentIndex;
|
||||
const currentIndex = activeIndexRef.current
|
||||
const rawIndex = height ? offset / height : currentIndex
|
||||
|
||||
let nextIndex = currentIndex;
|
||||
let nextIndex = currentIndex
|
||||
if (isWeb) {
|
||||
const delta = rawIndex - currentIndex;
|
||||
const delta = rawIndex - currentIndex
|
||||
if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) {
|
||||
if (Math.abs(delta) <= 1) {
|
||||
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1));
|
||||
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1))
|
||||
} else {
|
||||
nextIndex = clampIndex(currentIndex + Math.round(delta));
|
||||
nextIndex = clampIndex(currentIndex + Math.round(delta))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
nextIndex = clampIndex(Math.round(rawIndex));
|
||||
nextIndex = clampIndex(Math.round(rawIndex))
|
||||
}
|
||||
|
||||
const hasChanged = nextIndex !== activeIndexRef.current;
|
||||
const hasChanged = nextIndex !== activeIndexRef.current
|
||||
|
||||
if (hasChanged) {
|
||||
activeIndexRef.current = nextIndex;
|
||||
const callback = onActiveIndexChangeRef.current;
|
||||
activeIndexRef.current = nextIndex
|
||||
const callback = onActiveIndexChangeRef.current
|
||||
if (callback) {
|
||||
callback(nextIndex);
|
||||
callback(nextIndex)
|
||||
}
|
||||
}
|
||||
|
||||
if (forceSnap || hasChanged) {
|
||||
scrollToIndex(nextIndex, true, height);
|
||||
scrollToIndex(nextIndex, true, height)
|
||||
}
|
||||
},
|
||||
[clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight]
|
||||
);
|
||||
)
|
||||
|
||||
const handleScrollEnd = useCallback(
|
||||
(event) => {
|
||||
clearPendingAlignment();
|
||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
|
||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
|
||||
alignToOffset(offsetY, layoutHeight, { forceSnap: true });
|
||||
clearPendingAlignment()
|
||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
|
||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
|
||||
alignToOffset(offsetY, layoutHeight, { forceSnap: true })
|
||||
},
|
||||
[alignToOffset, clearPendingAlignment]
|
||||
);
|
||||
)
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event) => {
|
||||
if (!isWeb) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
|
||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
|
||||
clearPendingAlignment();
|
||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
|
||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
|
||||
clearPendingAlignment()
|
||||
alignTimeoutRef.current = globalThis.setTimeout(() => {
|
||||
alignToOffset(offsetY, layoutHeight, { forceSnap: false });
|
||||
alignTimeoutRef.current = null;
|
||||
}, 80);
|
||||
alignToOffset(offsetY, layoutHeight, { forceSnap: false })
|
||||
alignTimeoutRef.current = null
|
||||
}, 80)
|
||||
},
|
||||
[alignToOffset, clearPendingAlignment, isWeb]
|
||||
);
|
||||
)
|
||||
|
||||
const animatedScrollHandler = useMemo(
|
||||
() =>
|
||||
@@ -319,19 +301,19 @@ const FeatureCarousel = ({
|
||||
listener: isWeb ? handleScroll : undefined,
|
||||
}),
|
||||
[handleScroll, isWeb, scrollY]
|
||||
);
|
||||
)
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event) => {
|
||||
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0;
|
||||
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0
|
||||
if (layoutHeight > 0) {
|
||||
updateSnapHeight(layoutHeight);
|
||||
updateSnapHeight(layoutHeight)
|
||||
}
|
||||
},
|
||||
[updateSnapHeight]
|
||||
);
|
||||
)
|
||||
|
||||
const hasBackgroundImage = !!backgroundImage;
|
||||
const hasBackgroundImage = !!backgroundImage
|
||||
|
||||
const renderContent = useMemo(
|
||||
() =>
|
||||
@@ -345,43 +327,35 @@ const FeatureCarousel = ({
|
||||
]}
|
||||
>
|
||||
<View style={styles.cardWrapper}>
|
||||
<PersonaCard
|
||||
item={item}
|
||||
index={index}
|
||||
isLock={item.isLocked}
|
||||
height={itemHeight}
|
||||
/>
|
||||
<PersonaCard item={item} index={index} isLock={item.isLocked} height={itemHeight} />
|
||||
</View>
|
||||
</View>
|
||||
)),
|
||||
[carouselItems, hasBackgroundImage, itemHeight]
|
||||
);
|
||||
)
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
if (!itemHeight || !isWeb) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
return carouselItems.map((_, index) => index * itemHeight);
|
||||
}, [carouselItems, isWeb, itemHeight]);
|
||||
return carouselItems.map((_, index) => index * itemHeight)
|
||||
}, [carouselItems, isWeb, itemHeight])
|
||||
|
||||
const blurIntensity = isWeb ? 80 : 30;
|
||||
const blurIntensity = isWeb ? 80 : 30
|
||||
const dotsWrapperStyle = useMemo(
|
||||
() => [
|
||||
styles.dotsWrapperBase,
|
||||
isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative,
|
||||
],
|
||||
() => [styles.dotsWrapperBase, isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative],
|
||||
[isWeb]
|
||||
);
|
||||
)
|
||||
|
||||
const containerProps = hasBackgroundImage
|
||||
? {
|
||||
source: backgroundImage,
|
||||
resizeMode: "cover",
|
||||
resizeMode: 'cover',
|
||||
imageStyle: styles.backgroundImage,
|
||||
}
|
||||
: {};
|
||||
: {}
|
||||
|
||||
const ContainerComponent = hasBackgroundImage ? ImageBackground : View;
|
||||
const ContainerComponent = hasBackgroundImage ? ImageBackground : View
|
||||
|
||||
return (
|
||||
<ContainerComponent
|
||||
@@ -396,11 +370,7 @@ const FeatureCarousel = ({
|
||||
>
|
||||
{hasBackgroundImage && (
|
||||
<LinearGradient
|
||||
colors={[
|
||||
"rgba(66, 91, 135, 0)",
|
||||
"rgba(66, 91, 135, 0.4)",
|
||||
HOME_BACKGROUND_COLOR,
|
||||
]}
|
||||
colors={['rgba(66, 91, 135, 0)', 'rgba(66, 91, 135, 0.4)', HOME_BACKGROUND_COLOR]}
|
||||
locations={[0, 0.75, 1]}
|
||||
style={styles.backgroundGradient}
|
||||
pointerEvents="none"
|
||||
@@ -416,7 +386,7 @@ const FeatureCarousel = ({
|
||||
snapToAlignment="start"
|
||||
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate={!isWeb ? "fast" : "normal"}
|
||||
decelerationRate={!isWeb ? 'fast' : 'normal'}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
onScroll={animatedScrollHandler}
|
||||
@@ -427,7 +397,7 @@ const FeatureCarousel = ({
|
||||
</Animated.ScrollView>
|
||||
<BlurView
|
||||
intensity={blurIntensity}
|
||||
tint={Platform.OS === "web" ? undefined : "dark"}
|
||||
tint={Platform.OS === 'web' ? undefined : 'dark'}
|
||||
style={dotsWrapperStyle}
|
||||
pointerEvents="none"
|
||||
>
|
||||
@@ -444,16 +414,16 @@ const FeatureCarousel = ({
|
||||
/>
|
||||
</BlurView>
|
||||
</ContainerComponent>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default FeatureCarousel;
|
||||
export default FeatureCarousel
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
containerWeb: {
|
||||
// paddingRight: 56,
|
||||
@@ -462,7 +432,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: HOME_BACKGROUND_COLOR,
|
||||
},
|
||||
containerTransparent: {
|
||||
backgroundColor: "transparent",
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
@@ -477,45 +447,45 @@ const styles = StyleSheet.create({
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
slide: {
|
||||
width: "100%",
|
||||
justifyContent: "flex-start",
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
slideColored: {
|
||||
backgroundColor: HOME_BACKGROUND_COLOR,
|
||||
},
|
||||
slideTransparent: {
|
||||
backgroundColor: "transparent",
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
cardWrapper: {
|
||||
flex: 1,
|
||||
width: "60%",
|
||||
justifyContent: "center",
|
||||
alignSelf: "center",
|
||||
width: '60%',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
dotsWrapperBase: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(18, 18, 18, 0.2)",
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(18, 18, 18, 0.2)',
|
||||
},
|
||||
dotsWrapperWeb: {
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
maxHeight: "75%",
|
||||
alignSelf: "center",
|
||||
maxHeight: '75%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
dotsWrapperNative: {
|
||||
marginLeft: 12,
|
||||
alignSelf: "center",
|
||||
alignSelf: 'center',
|
||||
},
|
||||
dotsContainer: {
|
||||
flexDirection: "column",
|
||||
flexDirection: 'column',
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
@@ -523,6 +493,6 @@ const styles = StyleSheet.create({
|
||||
marginHorizontal: 0,
|
||||
marginVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: "rgba(255,255,255,0.4)",
|
||||
backgroundColor: 'rgba(255,255,255,0.4)',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useContext, useGlobal } from "reactn";
|
||||
import { ScrollView, Text, View, Image, Pressable } from "react-native";
|
||||
import { useContext, useGlobal } from 'reactn'
|
||||
import { ScrollView, Text, View, Image, Pressable } from 'react-native'
|
||||
|
||||
import firebase, { arrayRemove } from "../config/firebase";
|
||||
import firebase, { arrayRemove } from '../config/firebase'
|
||||
|
||||
import { getFileNameFromURL } from "../helpers";
|
||||
import { Fonts, Palette, Style, gutters } from "../styles";
|
||||
import { icons } from "../assets";
|
||||
import { getFileNameFromURL } from '../helpers'
|
||||
import { Fonts, Palette, Style, gutters } from '../styles'
|
||||
import { icons } from '../assets'
|
||||
|
||||
import { WebViewContext } from "../providers/WebViewProvider";
|
||||
import alert from "./Alert";
|
||||
import { WebViewContext } from '../providers/WebViewProvider'
|
||||
import alert from './Alert'
|
||||
|
||||
export default ({
|
||||
files = [],
|
||||
@@ -17,67 +17,61 @@ export default ({
|
||||
collectionRef = null,
|
||||
containerStyle = {},
|
||||
}) => {
|
||||
const [, setIsLoading] = useGlobal("_isLoading");
|
||||
const [, setTooltip] = useGlobal("_tooltip");
|
||||
const [, setIsLoading] = useGlobal('_isLoading')
|
||||
const [, setTooltip] = useGlobal('_tooltip')
|
||||
|
||||
const { setWebViewUrl } = useContext(WebViewContext);
|
||||
const { setWebViewUrl } = useContext(WebViewContext)
|
||||
|
||||
const onDeleteFile = async (url) => {
|
||||
alert(
|
||||
"Êtes-vous sûr ?",
|
||||
"Cette action est irréversible.",
|
||||
'Êtes-vous sûr ?',
|
||||
'Cette action est irréversible.',
|
||||
[
|
||||
{
|
||||
text: "Annuler",
|
||||
style: "cancel",
|
||||
text: 'Annuler',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: "Confirmer",
|
||||
text: 'Confirmer',
|
||||
onPress: async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setIsLoading(true)
|
||||
|
||||
setFiles(
|
||||
files.filter((file) => file !== url && file.uri !== url)
|
||||
);
|
||||
await firebase.storage().refFromURL(url).delete();
|
||||
setFiles(files.filter((file) => file !== url && file.uri !== url))
|
||||
await firebase.storage().refFromURL(url).delete()
|
||||
|
||||
if (documentID) {
|
||||
await collectionRef.doc(documentID).update({
|
||||
files: arrayRemove(url),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
setTooltip({
|
||||
type: "success",
|
||||
text: "Fichier supprimé avec succès !",
|
||||
});
|
||||
type: 'success',
|
||||
text: 'Fichier supprimé avec succès !',
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.log(error)
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
style: "confirm",
|
||||
style: 'confirm',
|
||||
},
|
||||
],
|
||||
{ cancelable: false }
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
style={{ ...containerStyle }}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
<ScrollView horizontal style={{ ...containerStyle }} showsHorizontalScrollIndicator={false}>
|
||||
{files.map((file, index) => {
|
||||
const uri = file.uri || file;
|
||||
const uri = file.uri || file
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -97,7 +91,7 @@ export default ({
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={Fonts({
|
||||
type: "default",
|
||||
type: 'default',
|
||||
color: Palette.white,
|
||||
style: {
|
||||
maxWidth: files?.length > 1 ? 100 : 200,
|
||||
@@ -119,16 +113,12 @@ export default ({
|
||||
)}
|
||||
|
||||
<Pressable onPress={() => setWebViewUrl(uri)}>
|
||||
<Image
|
||||
source={icons.eye}
|
||||
style={Style.iconDefault}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Image source={icons.eye} style={Style.iconDefault} resizeMode="contain" />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,102 +1,86 @@
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import { View, Pressable, Text } from "react-native";
|
||||
import { VideoView, useVideoPlayer } from "expo-video";
|
||||
import { videos } from "../assets";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import { View, Pressable, Text } from 'react-native'
|
||||
import { VideoView, useVideoPlayer } from 'expo-video'
|
||||
import { videos } from '../assets'
|
||||
import { Portal } from '@gorhom/portal'
|
||||
|
||||
// Fullscreen vertical video overlay without controls
|
||||
// Props:
|
||||
// - url?: string | number (require), source of the video. Defaults to videos.test
|
||||
// - visible?: boolean, when false returns null
|
||||
// - onClose: () => void, called when user skips or when video ends
|
||||
const CLOSE_THRESHOLD_SECONDS = 0.35;
|
||||
const CLOSE_THRESHOLD_SECONDS = 0.35
|
||||
|
||||
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
const source = url
|
||||
? typeof url === "string"
|
||||
? { uri: url }
|
||||
: url
|
||||
: videos.test;
|
||||
const source = url ? (typeof url === 'string' ? { uri: url } : url) : videos.test
|
||||
|
||||
const hasClosedRef = useRef(false);
|
||||
const hasClosedRef = useRef(false)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasClosedRef.current) return;
|
||||
hasClosedRef.current = true;
|
||||
if (hasClosedRef.current) return
|
||||
hasClosedRef.current = true
|
||||
try {
|
||||
onClose?.();
|
||||
onClose?.()
|
||||
} catch (e) {}
|
||||
}, [onClose]);
|
||||
}, [onClose])
|
||||
|
||||
const player = useVideoPlayer(source, (p) => {
|
||||
p.loop = false;
|
||||
p.timeUpdateEventInterval = 0.25;
|
||||
});
|
||||
p.loop = false
|
||||
p.timeUpdateEventInterval = 0.25
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
hasClosedRef.current = false;
|
||||
hasClosedRef.current = false
|
||||
} else {
|
||||
hasClosedRef.current = true;
|
||||
hasClosedRef.current = true
|
||||
try {
|
||||
player?.pause?.();
|
||||
player?.pause?.()
|
||||
} catch (e) {}
|
||||
}
|
||||
}, [player, visible]);
|
||||
}, [player, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
try {
|
||||
player?.play?.();
|
||||
player?.play?.()
|
||||
} catch (e) {}
|
||||
}, [player, visible]);
|
||||
}, [player, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!player || !visible) return;
|
||||
const playToEndSub = player.addListener?.("playToEnd", handleClose);
|
||||
const timeUpdateSub = player.addListener?.(
|
||||
"timeUpdate",
|
||||
({ currentTime } = {}) => {
|
||||
if (!player || !visible) return
|
||||
const playToEndSub = player.addListener?.('playToEnd', handleClose)
|
||||
const timeUpdateSub = player.addListener?.('timeUpdate', ({ currentTime } = {}) => {
|
||||
if (!player?.duration || hasClosedRef.current) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const remaining = player.duration - currentTime;
|
||||
if (
|
||||
Number.isFinite(remaining) &&
|
||||
remaining <= CLOSE_THRESHOLD_SECONDS
|
||||
) {
|
||||
handleClose();
|
||||
const remaining = player.duration - currentTime
|
||||
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
);
|
||||
const playingChangeSub = player.addListener?.(
|
||||
"playingChange",
|
||||
({ isPlaying } = {}) => {
|
||||
})
|
||||
const playingChangeSub = player.addListener?.('playingChange', ({ isPlaying } = {}) => {
|
||||
if (isPlaying || !player?.duration || hasClosedRef.current) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const remaining = player.duration - (player.currentTime ?? 0);
|
||||
if (
|
||||
Number.isFinite(remaining) &&
|
||||
remaining <= CLOSE_THRESHOLD_SECONDS
|
||||
) {
|
||||
handleClose();
|
||||
const remaining = player.duration - (player.currentTime ?? 0)
|
||||
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
return () => {
|
||||
try {
|
||||
playToEndSub?.remove?.();
|
||||
timeUpdateSub?.remove?.();
|
||||
playingChangeSub?.remove?.();
|
||||
playToEndSub?.remove?.()
|
||||
timeUpdateSub?.remove?.()
|
||||
playingChangeSub?.remove?.()
|
||||
} catch (e) {}
|
||||
};
|
||||
}, [handleClose, player, visible]);
|
||||
}
|
||||
}, [handleClose, player, visible])
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -104,15 +88,15 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
paddingHorizontal: 10,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "black",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: 'black',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
@@ -133,22 +117,22 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
<Pressable
|
||||
onPress={handleClose}
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
right: 20,
|
||||
backgroundColor: "#00000080",
|
||||
backgroundColor: '#00000080',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: "#FFFFFF55",
|
||||
borderColor: '#FFFFFF55',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#FFF", fontSize: 14 }}>Passer la vidéo</Text>
|
||||
<Text style={{ color: '#FFF', fontSize: 14 }}>Passer la vidéo</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default FullscreenIntroVideo;
|
||||
export default FullscreenIntroVideo
|
||||
|
||||
@@ -1,183 +1,183 @@
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { Asset } from "expo-asset";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Portal } from '@gorhom/portal'
|
||||
import { Asset } from 'expo-asset'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
|
||||
const CLOSE_THRESHOLD_SECONDS = 0.35;
|
||||
const CLOSE_POLL_INTERVAL_MS = 500;
|
||||
const CLOSE_THRESHOLD_SECONDS = 0.35
|
||||
const CLOSE_POLL_INTERVAL_MS = 500
|
||||
|
||||
const overlayStyle = {
|
||||
position: "fixed",
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: "black",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: 'black',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 9999,
|
||||
};
|
||||
}
|
||||
|
||||
const videoStyle = {
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
};
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}
|
||||
|
||||
const closeButtonStyle = {
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
right: 20,
|
||||
backgroundColor: "#00000080",
|
||||
backgroundColor: '#00000080',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: "#FFFFFF55",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const closeTextStyle = {
|
||||
color: "#FFF",
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const resolveModuleUri = async (module) => {
|
||||
const asset = Asset.fromModule(module);
|
||||
|
||||
if (!asset.localUri && !asset.uri) {
|
||||
await asset.downloadAsync();
|
||||
borderColor: '#FFFFFF55',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
return asset.localUri ?? asset.uri ?? null;
|
||||
};
|
||||
const closeTextStyle = {
|
||||
color: '#FFF',
|
||||
fontSize: 14,
|
||||
}
|
||||
|
||||
const resolveModuleUri = async (module) => {
|
||||
const asset = Asset.fromModule(module)
|
||||
|
||||
if (!asset.localUri && !asset.uri) {
|
||||
await asset.downloadAsync()
|
||||
}
|
||||
|
||||
return asset.localUri ?? asset.uri ?? null
|
||||
}
|
||||
|
||||
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
const videoRef = useRef(null);
|
||||
const [uri, setUri] = useState(null);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const hasClosedRef = useRef(false);
|
||||
const videoRef = useRef(null)
|
||||
const [uri, setUri] = useState(null)
|
||||
const [muted, setMuted] = useState(false)
|
||||
const hasClosedRef = useRef(false)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasClosedRef.current) return;
|
||||
hasClosedRef.current = true;
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
if (hasClosedRef.current) return
|
||||
hasClosedRef.current = true
|
||||
onClose?.()
|
||||
}, [onClose])
|
||||
|
||||
const evaluateShouldClose = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
const video = videoRef.current
|
||||
|
||||
if (!video || hasClosedRef.current) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
if (video.ended) {
|
||||
handleClose();
|
||||
return;
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
const remaining = video.duration - video.currentTime;
|
||||
const remaining = video.duration - video.currentTime
|
||||
|
||||
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
|
||||
handleClose();
|
||||
handleClose()
|
||||
}
|
||||
}, [handleClose]);
|
||||
}, [handleClose])
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
let isMounted = true
|
||||
|
||||
const assignUri = (nextUri) => {
|
||||
if (isMounted) {
|
||||
setMuted(false);
|
||||
setUri(nextUri);
|
||||
setMuted(false)
|
||||
setUri(nextUri)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof url === "string") {
|
||||
assignUri(url);
|
||||
if (typeof url === 'string') {
|
||||
assignUri(url)
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
isMounted = false
|
||||
}
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const nextUri = await resolveModuleUri(url);
|
||||
assignUri(nextUri);
|
||||
const nextUri = await resolveModuleUri(url)
|
||||
assignUri(nextUri)
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setUri(null);
|
||||
setUri(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
load()
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [url]);
|
||||
isMounted = false
|
||||
}
|
||||
}, [url])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !uri) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
hasClosedRef.current = false;
|
||||
let rafId;
|
||||
hasClosedRef.current = false
|
||||
let rafId
|
||||
|
||||
const attemptPlay = () => {
|
||||
const video = videoRef.current;
|
||||
const video = videoRef.current
|
||||
|
||||
if (!video) {
|
||||
rafId = requestAnimationFrame(attemptPlay);
|
||||
return;
|
||||
rafId = requestAnimationFrame(attemptPlay)
|
||||
return
|
||||
}
|
||||
|
||||
video.currentTime = 0;
|
||||
const result = video.play();
|
||||
video.currentTime = 0
|
||||
const result = video.play()
|
||||
|
||||
if (result?.catch) {
|
||||
result.catch((error) => {
|
||||
if (error?.name === "NotAllowedError" && !muted) {
|
||||
setMuted(true);
|
||||
if (error?.name === 'NotAllowedError' && !muted) {
|
||||
setMuted(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
attemptPlay();
|
||||
const pollId = setInterval(evaluateShouldClose, CLOSE_POLL_INTERVAL_MS);
|
||||
attemptPlay()
|
||||
const pollId = setInterval(evaluateShouldClose, CLOSE_POLL_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
clearInterval(pollId);
|
||||
const video = videoRef.current;
|
||||
video?.pause();
|
||||
};
|
||||
}, [evaluateShouldClose, muted, uri, visible]);
|
||||
clearInterval(pollId)
|
||||
const video = videoRef.current
|
||||
video?.pause()
|
||||
}
|
||||
}, [evaluateShouldClose, muted, uri, visible])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
const video = videoRef.current
|
||||
|
||||
if (!video || !muted) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const result = video.play();
|
||||
const result = video.play()
|
||||
|
||||
if (result?.catch) {
|
||||
result.catch(() => {});
|
||||
result.catch(() => {})
|
||||
}
|
||||
}, [muted]);
|
||||
}, [muted])
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -204,7 +204,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
</Pressable>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default FullscreenIntroVideo;
|
||||
export default FullscreenIntroVideo
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
import React from "react";
|
||||
import { Image, Pressable, Text } from "react-native";
|
||||
import { Palette, Style } from "../styles";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
import { size } from "../styles/Style";
|
||||
import { LinearGradient } from "./LinearGradient/LinearGradient";
|
||||
import React from 'react'
|
||||
import { Image, Pressable, Text } from 'react-native'
|
||||
import { Palette, Style } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
import { size } from '../styles/Style'
|
||||
import { LinearGradient } from './LinearGradient/LinearGradient'
|
||||
|
||||
const HEIGHT_BY_SIZE = {
|
||||
small: 44,
|
||||
medium: 50,
|
||||
large: 58,
|
||||
};
|
||||
}
|
||||
|
||||
const FONT_SIZE_BY_SIZE = {
|
||||
small: 14,
|
||||
medium: 15,
|
||||
large: 17,
|
||||
};
|
||||
}
|
||||
|
||||
const GradientButton = ({
|
||||
title = "",
|
||||
colors = ["#F94697", "#7023F7"],
|
||||
title = '',
|
||||
colors = ['#F94697', '#7023F7'],
|
||||
onPress,
|
||||
props,
|
||||
containerStyle = {},
|
||||
icon,
|
||||
disabled = false,
|
||||
maxWidth = null,
|
||||
size = "medium",
|
||||
size = 'medium',
|
||||
textStyle = {},
|
||||
gradientStyle = {},
|
||||
height = null,
|
||||
}) => {
|
||||
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
|
||||
const buttonHeight =
|
||||
typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize];
|
||||
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
|
||||
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : 'medium'
|
||||
const buttonHeight = typeof height === 'number' ? height : HEIGHT_BY_SIZE[resolvedSize]
|
||||
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -79,7 +78,7 @@ const GradientButton = ({
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default GradientButton;
|
||||
export default GradientButton
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import Hyperlink from "react-native-hyperlink";
|
||||
import Hyperlink from 'react-native-hyperlink'
|
||||
|
||||
import { useWebView } from "../providers/WebViewProvider";
|
||||
import { Palette } from "../styles";
|
||||
import { useWebView } from '../providers/WebViewProvider'
|
||||
import { Palette } from '../styles'
|
||||
|
||||
const HyperlinkContainer = ({ children }) => {
|
||||
const { setWebViewUrl } = useWebView();
|
||||
const { setWebViewUrl } = useWebView()
|
||||
|
||||
return (
|
||||
<Hyperlink
|
||||
onPress={(url) => {
|
||||
setWebViewUrl(url);
|
||||
setWebViewUrl(url)
|
||||
}}
|
||||
linkStyle={{
|
||||
color: Palette.primary,
|
||||
textDecorationLine: "underline",
|
||||
textDecorationLine: 'underline',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Hyperlink>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default HyperlinkContainer;
|
||||
export default HyperlinkContainer
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Image, View } from "react-native";
|
||||
import { Image, View } from 'react-native'
|
||||
|
||||
import { Palette, Style } from "../styles";
|
||||
import { gutters, mainBorderRadius } from "../styles/Style";
|
||||
import { Palette, Style } from '../styles'
|
||||
import { gutters, mainBorderRadius } from '../styles/Style'
|
||||
|
||||
const IconContainer = ({ icon }) => {
|
||||
return (
|
||||
@@ -19,13 +19,13 @@ const IconContainer = ({ icon }) => {
|
||||
source={icon}
|
||||
resizeMode="contain"
|
||||
style={{
|
||||
width: "50%",
|
||||
height: "50%",
|
||||
width: '50%',
|
||||
height: '50%',
|
||||
tintColor: Palette.primary,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default IconContainer;
|
||||
export default IconContainer
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
import React, { useState, useEffect } from "reactn";
|
||||
import { Image, Pressable, Text, View } from "react-native";
|
||||
import { FlatGrid } from "react-native-super-grid";
|
||||
import React, { useState, useEffect } from 'reactn'
|
||||
import { Image, Pressable, Text, View } from 'react-native'
|
||||
import { FlatGrid } from 'react-native-super-grid'
|
||||
|
||||
import { responsiveWidth } from "../actions/responsiveSizes.js";
|
||||
import { responsiveWidth } from '../actions/responsiveSizes.js'
|
||||
|
||||
import { Fonts, Palette, Style, gutters } from "../styles";
|
||||
import { mainBorderRadius } from "../styles/Style";
|
||||
import { Fonts, Palette, Style, gutters } from '../styles'
|
||||
import { mainBorderRadius } from '../styles/Style'
|
||||
|
||||
import useLayoutType from "../hooks/useLayoutType.js";
|
||||
import useLayoutType from '../hooks/useLayoutType.js'
|
||||
|
||||
const IconSelector = ({ onClose } = {}) => {
|
||||
const { isNative } = useLayoutType();
|
||||
const { isNative } = useLayoutType()
|
||||
|
||||
const [currentIconIndex, setCurrentIconIndex] = useState(0);
|
||||
const [currentIconIndex, setCurrentIconIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (isNative) {
|
||||
// import("expo-dynamic-app-icon").then((module) => {
|
||||
// getAppIcon = module.getAppIcon;
|
||||
|
||||
// const iconIndex = getAppIcon();
|
||||
// setCurrentIconIndex(Number(iconIndex));
|
||||
// });
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<FlatGrid
|
||||
@@ -31,8 +30,8 @@ const IconSelector = ({ onClose } = {}) => {
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
...Fonts({ type: "title" }),
|
||||
textAlign: "center",
|
||||
...Fonts({ type: 'title' }),
|
||||
textAlign: 'center',
|
||||
marginBottom: gutters / 2,
|
||||
}}
|
||||
>
|
||||
@@ -40,10 +39,10 @@ const IconSelector = ({ onClose } = {}) => {
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
...Fonts({ type: "default" }),
|
||||
textAlign: "center",
|
||||
width: "60%",
|
||||
alignSelf: "center",
|
||||
...Fonts({ type: 'default' }),
|
||||
textAlign: 'center',
|
||||
width: '60%',
|
||||
alignSelf: 'center',
|
||||
marginBottom: gutters,
|
||||
}}
|
||||
>
|
||||
@@ -61,13 +60,13 @@ const IconSelector = ({ onClose } = {}) => {
|
||||
style={{ flex: 1, backgroundColor: Palette.darkPurple }}
|
||||
spacing={10}
|
||||
renderItem={({ item, index }) => {
|
||||
const isSelected = currentIconIndex === index + 1;
|
||||
const isSelected = currentIconIndex === index + 1
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={index}
|
||||
style={{
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
height: responsiveWidth(45),
|
||||
...Style.containerCenter,
|
||||
...Style.defaultShadows,
|
||||
@@ -75,7 +74,6 @@ const IconSelector = ({ onClose } = {}) => {
|
||||
onPress={() => {
|
||||
// import("expo-dynamic-app-icon").then((module) => {
|
||||
// setAppIcon = module.setAppIcon;
|
||||
|
||||
// setAppIcon((index + 1).toString());
|
||||
// setCurrentIconIndex(index + 1);
|
||||
// onClose?.();
|
||||
@@ -86,10 +84,10 @@ const IconSelector = ({ onClose } = {}) => {
|
||||
source={item}
|
||||
resizeMode="cover"
|
||||
style={{
|
||||
width: "90%",
|
||||
height: "90%",
|
||||
width: '90%',
|
||||
height: '90%',
|
||||
borderRadius: mainBorderRadius * 2,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
...(isSelected && {
|
||||
borderColor: Palette.primary,
|
||||
borderWidth: 2,
|
||||
@@ -97,10 +95,10 @@ const IconSelector = ({ onClose } = {}) => {
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default IconSelector;
|
||||
export default IconSelector
|
||||
|
||||
+64
-76
@@ -1,30 +1,22 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Image,
|
||||
InputAccessoryView,
|
||||
Keyboard,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useState } from 'react'
|
||||
import { Image, InputAccessoryView, Keyboard, Pressable, Text, TextInput, View } from 'react-native'
|
||||
// import CountryPicker, { DARK_THEME } from "react-native-country-picker-modal";
|
||||
import usePlaceApi from "react-native-minuit/src/hooks/usePlacesApi";
|
||||
import usePlaceApi from 'react-native-minuit/src/hooks/usePlacesApi'
|
||||
|
||||
import { icons } from "../assets";
|
||||
import { Fonts, Palette, Style, gutters } from "../styles";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
import { icons } from '../assets'
|
||||
import { Fonts, Palette, Style, gutters } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
|
||||
import { BlurView } from "expo-blur";
|
||||
import EyeSlashSVG from "../assets/UI/EyeSlashSVG";
|
||||
import EyeSVG from "../assets/UI/EyeSVG";
|
||||
import { GOOGLE_API_KEY } from "../data/keys";
|
||||
import { isWeb } from "../hooks/useLayoutType";
|
||||
import { BlurView } from 'expo-blur'
|
||||
import EyeSlashSVG from '../assets/UI/EyeSlashSVG'
|
||||
import EyeSVG from '../assets/UI/EyeSVG'
|
||||
import { GOOGLE_API_KEY } from '../data/keys'
|
||||
import { isWeb } from '../hooks/useLayoutType'
|
||||
|
||||
const Input = ({
|
||||
inputRef = null,
|
||||
label = "",
|
||||
placeholder = "",
|
||||
label = '',
|
||||
placeholder = '',
|
||||
|
||||
containerStyle = {},
|
||||
textInputStyle = {},
|
||||
@@ -34,48 +26,46 @@ const Input = ({
|
||||
|
||||
textInputProps = {},
|
||||
|
||||
type = "default", // "default" | "password" | "textarea" | "coinAmount" | "countryPicker" | "autoCompleteAddress"
|
||||
theme = "default", // "default" | "radioactiv" | "dashed"
|
||||
type = 'default', // "default" | "password" | "textarea" | "coinAmount" | "countryPicker" | "autoCompleteAddress"
|
||||
theme = 'default', // "default" | "radioactiv" | "dashed"
|
||||
|
||||
borderType = "none", // "solid" | "dashed" | "none"
|
||||
borderType = 'none', // "solid" | "dashed" | "none"
|
||||
|
||||
layout = "default", // "default" | "line"
|
||||
layout = 'default', // "default" | "line"
|
||||
|
||||
isNumeric = false,
|
||||
isBlur = false,
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
const isDefaultLayout = layout === "default";
|
||||
const isDefaultLayout = layout === 'default'
|
||||
|
||||
const mainColor =
|
||||
theme === "radioactiv" ? Palette.radioactivGreen : Palette.primary;
|
||||
const mainColor = theme === 'radioactiv' ? Palette.radioactivGreen : Palette.primary
|
||||
|
||||
const isRoundedRectangle =
|
||||
["textarea", "coinAmount"].includes(type) || layout === "default";
|
||||
const isRoundedRectangle = ['textarea', 'coinAmount'].includes(type) || layout === 'default'
|
||||
|
||||
const inputAccessoryViewID = "uniqueID";
|
||||
const inputAccessoryViewID = 'uniqueID'
|
||||
|
||||
const { places } = usePlaceApi({
|
||||
query: type === "autoCompleteAddress" ? value : "",
|
||||
query: type === 'autoCompleteAddress' ? value : '',
|
||||
apiKey: GOOGLE_API_KEY, // Your Google API Key
|
||||
queryFields: "formatted_address,geometry,name,address_components",
|
||||
queryCountries: ["fr"],
|
||||
language: "fr-FR",
|
||||
queryFields: 'formatted_address,geometry,name,address_components',
|
||||
queryCountries: ['fr'],
|
||||
language: 'fr-FR',
|
||||
minChars: 2,
|
||||
});
|
||||
})
|
||||
|
||||
const ContainerView = isBlur ? BlurView : View;
|
||||
const ContainerView = isBlur ? BlurView : View
|
||||
const resolvedKeyboardType =
|
||||
textInputProps?.keyboardType ??
|
||||
(isNumeric ? "numeric" : type === "email" ? "email-address" : "default");
|
||||
(isNumeric ? 'numeric' : type === 'email' ? 'email-address' : 'default')
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
...containerStyle,
|
||||
gap: 4,
|
||||
}}
|
||||
@@ -106,11 +96,11 @@ const Input = ({
|
||||
? {
|
||||
paddingVertical: 10,
|
||||
backgroundColor:
|
||||
type === "coinAmount"
|
||||
type === 'coinAmount'
|
||||
? Palette.transparentRadioactivGreen
|
||||
: Palette.glass,
|
||||
height: type === "textarea" ? 150 : 50,
|
||||
overflow: "hidden",
|
||||
height: type === 'textarea' ? 150 : 50,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 12,
|
||||
}
|
||||
: {}),
|
||||
@@ -120,68 +110,66 @@ const Input = ({
|
||||
borderBottomColor: mainColor,
|
||||
borderBottomWidth: 1,
|
||||
}),
|
||||
...(borderType === "dashed"
|
||||
...(borderType === 'dashed'
|
||||
? {
|
||||
borderStyle: "dashed",
|
||||
borderColor: isFocused
|
||||
? Palette.primary
|
||||
: Palette.transparentPrimary,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: isFocused ? Palette.primary : Palette.transparentPrimary,
|
||||
borderWidth: 1,
|
||||
}
|
||||
: {}),
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{type === "search" ? (
|
||||
{type === 'search' ? (
|
||||
<Image
|
||||
source={icons.search}
|
||||
style={[Style.iconDefault, { marginRight: 10 }]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : null}
|
||||
{type !== "countryPicker" && (
|
||||
{type !== 'countryPicker' && (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={Palette.gray}
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
multiline={type === "textarea"}
|
||||
editable={type !== "countryPicker"}
|
||||
multiline={type === 'textarea'}
|
||||
editable={type !== 'countryPicker'}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
style={{
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
...(isRoundedRectangle
|
||||
? {
|
||||
height: "100%",
|
||||
height: '100%',
|
||||
flex: 1,
|
||||
textAlignVertical: type === "textarea" ? "top" : "center",
|
||||
textAlignVertical: type === 'textarea' ? 'top' : 'center',
|
||||
}
|
||||
: { textAlign: "center" }),
|
||||
: { textAlign: 'center' }),
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
...(isWeb
|
||||
? {
|
||||
lineHeight: "auto",
|
||||
lineHeight: 'auto',
|
||||
}
|
||||
: {}),
|
||||
...textInputStyle,
|
||||
}}
|
||||
{...(type === "password"
|
||||
{...(type === 'password'
|
||||
? {
|
||||
secureTextEntry: !showPassword,
|
||||
autoCapitalize: "none",
|
||||
autoCompleteType: "password",
|
||||
textContentType: "password",
|
||||
autoCapitalize: 'none',
|
||||
autoCompleteType: 'password',
|
||||
textContentType: 'password',
|
||||
}
|
||||
: {})}
|
||||
{...(type === "email"
|
||||
{...(type === 'email'
|
||||
? {
|
||||
autoCapitalize: "none",
|
||||
autoCompleteType: "email",
|
||||
textContentType: "emailAddress",
|
||||
autoCapitalize: 'none',
|
||||
autoCompleteType: 'email',
|
||||
textContentType: 'emailAddress',
|
||||
}
|
||||
: {})}
|
||||
keyboardType={resolvedKeyboardType}
|
||||
@@ -190,21 +178,21 @@ const Input = ({
|
||||
{...textInputProps}
|
||||
/>
|
||||
)}
|
||||
{type === "password" ? (
|
||||
{type === 'password' ? (
|
||||
<Pressable onPress={() => setShowPassword(!showPassword)}>
|
||||
{showPassword ? <EyeSVG /> : <EyeSlashSVG />}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ContainerView>
|
||||
|
||||
{type === "autoCompleteAddress" &&
|
||||
{type === 'autoCompleteAddress' &&
|
||||
places?.[0]?.description &&
|
||||
places?.[0]?.description !== value &&
|
||||
places.map((place, index) => (
|
||||
<Pressable
|
||||
key={index}
|
||||
onPress={() => {
|
||||
setValue(place?.description);
|
||||
setValue(place?.description)
|
||||
}}
|
||||
style={{
|
||||
...Style.containerSpaceBetween,
|
||||
@@ -216,19 +204,19 @@ const Input = ({
|
||||
...Fonts({}),
|
||||
}}
|
||||
>
|
||||
{place?.description || "-"}
|
||||
{place?.description || '-'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{(type === "textarea" || isNumeric) && !isWeb && (
|
||||
{(type === 'textarea' || isNumeric) && !isWeb && (
|
||||
<InputAccessoryView nativeID={inputAccessoryViewID}>
|
||||
<Pressable
|
||||
onPress={() => Keyboard.dismiss()}
|
||||
style={{
|
||||
...Style.containerRow,
|
||||
justifyContent: "flex-end",
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: Palette.transparentPrimary,
|
||||
padding: gutters,
|
||||
paddingVertical: gutters / 2,
|
||||
@@ -245,7 +233,7 @@ const Input = ({
|
||||
</InputAccessoryView>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export { Input };
|
||||
export { Input }
|
||||
|
||||
+49
-68
@@ -1,78 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text, Image, Pressable, StyleSheet } from "react-native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useState } from 'react'
|
||||
import { View, Text, Image, Pressable, StyleSheet } from 'react-native'
|
||||
import { BlurView } from 'expo-blur'
|
||||
|
||||
import Switch from "../components/Switch";
|
||||
import OptionSelector from "../components/OptionSelector";
|
||||
import { Container, Title, Input, Button } from "../components/Dialog";
|
||||
import Switch from '../components/Switch'
|
||||
import OptionSelector from '../components/OptionSelector'
|
||||
import { Container, Title, Input, Button } from '../components/Dialog'
|
||||
|
||||
import { Fonts, Style, gutters } from "../styles";
|
||||
import { icons } from "../assets";
|
||||
import { Fonts, Style, gutters } from '../styles'
|
||||
import { icons } from '../assets'
|
||||
|
||||
const labelOptions = {
|
||||
name: "Nouveau nom",
|
||||
email: "Nouvelle adresse email",
|
||||
password: "Nouveau mot de passe",
|
||||
language: "Nouvelle langue",
|
||||
};
|
||||
name: 'Nouveau nom',
|
||||
email: 'Nouvelle adresse email',
|
||||
password: 'Nouveau mot de passe',
|
||||
language: 'Nouvelle langue',
|
||||
}
|
||||
|
||||
const languageOptions = {
|
||||
fr: "Français",
|
||||
en: "English",
|
||||
es: "Español",
|
||||
de: "Deutsch",
|
||||
it: "Italiano",
|
||||
};
|
||||
fr: 'Français',
|
||||
en: 'English',
|
||||
es: 'Español',
|
||||
de: 'Deutsch',
|
||||
it: 'Italiano',
|
||||
}
|
||||
|
||||
export default ({ itemKey, type, value, title, onUpdateValue }) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [inputData, setInputData] = useState(value);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [inputData, setInputData] = useState(value)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={{}}>
|
||||
<View style={Style.containerSpaceBetween}>
|
||||
<Text style={Fonts({ type: "section" })}>{title}</Text>
|
||||
{type === "boolean" ? (
|
||||
<Text style={Fonts({ type: 'section' })}>{title}</Text>
|
||||
{type === 'boolean' ? (
|
||||
<Switch
|
||||
value={value}
|
||||
setValue={(newValue) =>
|
||||
onUpdateValue({ key: itemKey, value: newValue })
|
||||
}
|
||||
setValue={(newValue) => onUpdateValue({ key: itemKey, value: newValue })}
|
||||
/>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => setShowDialog(true)}
|
||||
style={[
|
||||
Style.containerRow,
|
||||
{ maxWidth: "50%", justifyContent: "flex-end" },
|
||||
]}
|
||||
style={[Style.containerRow, { maxWidth: '50%', justifyContent: 'flex-end' }]}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={Fonts({
|
||||
type: "section",
|
||||
type: 'section',
|
||||
style: {
|
||||
opacity: 0.5,
|
||||
textAlign: "right",
|
||||
width: "80%",
|
||||
textAlign: 'right',
|
||||
width: '80%',
|
||||
marginRight: gutters,
|
||||
},
|
||||
})}
|
||||
>
|
||||
{itemKey === "password"
|
||||
? "********"
|
||||
: itemKey === "language"
|
||||
{itemKey === 'password'
|
||||
? '********'
|
||||
: itemKey === 'language'
|
||||
? languageOptions[value] || value
|
||||
: value}
|
||||
</Text>
|
||||
|
||||
<Image
|
||||
source={icons.edit}
|
||||
style={Style.iconDefault}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Image source={icons.edit} style={Style.iconDefault} resizeMode="contain" />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -83,37 +74,33 @@ export default ({ itemKey, type, value, title, onUpdateValue }) => {
|
||||
<Container
|
||||
visible={showDialog}
|
||||
blurComponentIOS={
|
||||
<BlurView
|
||||
style={StyleSheet.absoluteFill}
|
||||
blurType="xdark"
|
||||
blurAmount={50}
|
||||
/>
|
||||
<BlurView style={StyleSheet.absoluteFill} blurType="xdark" blurAmount={50} />
|
||||
}
|
||||
>
|
||||
<Title>{`Changement ${title?.toLowerCase()}`}</Title>
|
||||
|
||||
{["password", "email"].includes(itemKey) && (
|
||||
{['password', 'email'].includes(itemKey) && (
|
||||
<Input
|
||||
label="Mot de passe actuel"
|
||||
value={currentPassword}
|
||||
onChangeText={(text) => setCurrentPassword(text)}
|
||||
keyboardType="visible-password"
|
||||
type={"password"}
|
||||
type={'password'}
|
||||
containerStyle={{ marginBottom: gutters / 2 }}
|
||||
/>
|
||||
)}
|
||||
{itemKey === "language" ? (
|
||||
{itemKey === 'language' ? (
|
||||
<OptionSelector
|
||||
optionTypeList={languageOptions}
|
||||
selected={inputData || "fr"}
|
||||
selected={inputData || 'fr'}
|
||||
setSelected={setInputData}
|
||||
containerStyle={{ marginBottom: gutters / 2 }}
|
||||
colorMap={{
|
||||
fr: { primary: "#F94697", secondary: "#F946971A" },
|
||||
en: { primary: "#7023F7", secondary: "#7023F71A" },
|
||||
es: { primary: "#FDBA74", secondary: "#FDBA741A" },
|
||||
de: { primary: "#60A5FA", secondary: "#60A5FA1A" },
|
||||
it: { primary: "#34D399", secondary: "#34D3991A" },
|
||||
fr: { primary: '#F94697', secondary: '#F946971A' },
|
||||
en: { primary: '#7023F7', secondary: '#7023F71A' },
|
||||
es: { primary: '#FDBA74', secondary: '#FDBA741A' },
|
||||
de: { primary: '#60A5FA', secondary: '#60A5FA1A' },
|
||||
it: { primary: '#34D399', secondary: '#34D3991A' },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -121,9 +108,7 @@ export default ({ itemKey, type, value, title, onUpdateValue }) => {
|
||||
label={labelOptions[itemKey]}
|
||||
value={inputData}
|
||||
onChangeText={(text) => setInputData(text)}
|
||||
autoCapitalize={
|
||||
["password", "email"].includes(itemKey) ? "none" : "words"
|
||||
}
|
||||
autoCapitalize={['password', 'email'].includes(itemKey) ? 'none' : 'words'}
|
||||
type={itemKey}
|
||||
containerStyle={{ marginBottom: gutters / 2 }}
|
||||
/>
|
||||
@@ -131,16 +116,12 @@ export default ({ itemKey, type, value, title, onUpdateValue }) => {
|
||||
<Button
|
||||
label="Valider"
|
||||
onPress={() => {
|
||||
onUpdateValue({ key: itemKey, value: inputData, currentPassword });
|
||||
setShowDialog(false);
|
||||
onUpdateValue({ key: itemKey, value: inputData, currentPassword })
|
||||
setShowDialog(false)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Annuler"
|
||||
onPress={() => setShowDialog(false)}
|
||||
type={"secondary"}
|
||||
/>
|
||||
<Button label="Annuler" onPress={() => setShowDialog(false)} type={'secondary'} />
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useKeyboard } from "@react-native-community/hooks";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React from "react";
|
||||
import { Platform, StyleSheet, View } from "react-native";
|
||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||
import BorderGradient from "../BorderGradient/BorderGradient";
|
||||
import { useKeyboard } from '@react-native-community/hooks'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import React from 'react'
|
||||
import { Platform, StyleSheet, View } from 'react-native'
|
||||
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||
import BorderGradient from '../BorderGradient/BorderGradient'
|
||||
|
||||
const ItemContainer = ({
|
||||
height = responsiveHeight(40),
|
||||
@@ -12,23 +12,19 @@ const ItemContainer = ({
|
||||
style,
|
||||
disableKeyboardHeight = false,
|
||||
}) => {
|
||||
const { keyboardShown } = useKeyboard();
|
||||
const { keyboardShown } = useKeyboard()
|
||||
|
||||
return (
|
||||
<BorderGradient
|
||||
gradientProps={{
|
||||
colors: ["#FFFFFF00", "#FFFFFF"],
|
||||
colors: ['#FFFFFF00', '#FFFFFF'],
|
||||
start: { x: 0.3, y: 0 },
|
||||
end: { x: 1, y: 1 },
|
||||
...gradientProps,
|
||||
}}
|
||||
style={{
|
||||
...styles.borderGradientStyle,
|
||||
height: !disableKeyboardHeight
|
||||
? keyboardShown
|
||||
? responsiveHeight(30)
|
||||
: height
|
||||
: height,
|
||||
height: !disableKeyboardHeight ? (keyboardShown ? responsiveHeight(30) : height) : height,
|
||||
|
||||
...style,
|
||||
}}
|
||||
@@ -40,23 +36,23 @@ const ItemContainer = ({
|
||||
android: 100,
|
||||
web: 100,
|
||||
})}
|
||||
tint={"dark"}
|
||||
tint={'dark'}
|
||||
style={{ flex: 1, padding: 6 }}
|
||||
>
|
||||
{children}
|
||||
</BlurView>
|
||||
</View>
|
||||
</BorderGradient>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ItemContainer;
|
||||
export default ItemContainer
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
borderGradientStyle: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 20,
|
||||
shadowColor: "#000",
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
@@ -68,7 +64,7 @@ const styles = StyleSheet.create({
|
||||
blurContainer: {
|
||||
flex: 1,
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import omit from "lodash/omit";
|
||||
import BorderGradient from "../BorderGradient/BorderGradient";
|
||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Style } from "../../styles";
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import omit from 'lodash/omit'
|
||||
import BorderGradient from '../BorderGradient/BorderGradient'
|
||||
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { Style } from '../../styles'
|
||||
|
||||
const ITEM_BORDER_RADIUS = 24;
|
||||
const ITEM_BORDER_RADIUS = 24
|
||||
const CONTAINER_STYLE_PROPS = [
|
||||
"margin",
|
||||
"marginTop",
|
||||
"marginBottom",
|
||||
"marginLeft",
|
||||
"marginRight",
|
||||
"marginHorizontal",
|
||||
"marginVertical",
|
||||
"alignSelf",
|
||||
"alignItems",
|
||||
"justifyContent",
|
||||
"width",
|
||||
"minWidth",
|
||||
"maxWidth",
|
||||
];
|
||||
'margin',
|
||||
'marginTop',
|
||||
'marginBottom',
|
||||
'marginLeft',
|
||||
'marginRight',
|
||||
'marginHorizontal',
|
||||
'marginVertical',
|
||||
'alignSelf',
|
||||
'alignItems',
|
||||
'justifyContent',
|
||||
'width',
|
||||
'minWidth',
|
||||
'maxWidth',
|
||||
]
|
||||
|
||||
const ItemContainer = ({
|
||||
height = responsiveHeight(40),
|
||||
@@ -32,33 +32,33 @@ const ItemContainer = ({
|
||||
style,
|
||||
disableKeyboardHeight = false, // parity with native signature
|
||||
}) => {
|
||||
const [containerLayout, setContainerLayout] = useState(null);
|
||||
const flattenedStyle = StyleSheet.flatten(style) || {};
|
||||
const [containerLayout, setContainerLayout] = useState(null)
|
||||
const flattenedStyle = StyleSheet.flatten(style) || {}
|
||||
const containerStyleOverrides = useMemo(() => {
|
||||
return CONTAINER_STYLE_PROPS.reduce((acc, key) => {
|
||||
if (typeof flattenedStyle[key] !== "undefined") {
|
||||
acc[key] = flattenedStyle[key];
|
||||
if (typeof flattenedStyle[key] !== 'undefined') {
|
||||
acc[key] = flattenedStyle[key]
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}, [flattenedStyle]);
|
||||
return acc
|
||||
}, {})
|
||||
}, [flattenedStyle])
|
||||
|
||||
const {
|
||||
width: styleWidth,
|
||||
maxWidth: styleMaxWidth,
|
||||
minWidth: styleMinWidth,
|
||||
...remainingContainerStyle
|
||||
} = containerStyleOverrides;
|
||||
} = containerStyleOverrides
|
||||
|
||||
const gradientStyleOverrides = useMemo(
|
||||
() => omit(flattenedStyle, CONTAINER_STYLE_PROPS),
|
||||
[flattenedStyle]
|
||||
);
|
||||
)
|
||||
|
||||
const baseHeight = typeof height === "number" ? height : undefined;
|
||||
const measuredHeight = containerLayout?.height ?? baseHeight;
|
||||
const resolvedWidth = styleWidth ?? width ?? "100%";
|
||||
const resolvedMaxWidth = styleMaxWidth ?? maxWidth;
|
||||
const baseHeight = typeof height === 'number' ? height : undefined
|
||||
const measuredHeight = containerLayout?.height ?? baseHeight
|
||||
const resolvedWidth = styleWidth ?? width ?? '100%'
|
||||
const resolvedMaxWidth = styleMaxWidth ?? maxWidth
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -72,7 +72,7 @@ const ItemContainer = ({
|
||||
>
|
||||
<BorderGradient
|
||||
gradientProps={{
|
||||
colors: ["rgba(72, 51, 51, 0)", "#FFFFFF"],
|
||||
colors: ['rgba(72, 51, 51, 0)', '#FFFFFF'],
|
||||
start: { x: 0.3, y: 0 },
|
||||
end: { x: 1, y: 1 },
|
||||
locations: [0, 1],
|
||||
@@ -91,32 +91,28 @@ const ItemContainer = ({
|
||||
baseHeight ? { height: baseHeight } : null,
|
||||
]}
|
||||
onLayout={(e) => {
|
||||
setContainerLayout(e.nativeEvent.layout);
|
||||
setContainerLayout(e.nativeEvent.layout)
|
||||
}}
|
||||
>
|
||||
<BlurView
|
||||
intensity={disableKeyboardHeight ? 35 : 45}
|
||||
tint="dark"
|
||||
style={styles.blurView}
|
||||
>
|
||||
<BlurView intensity={disableKeyboardHeight ? 35 : 45} tint="dark" style={styles.blurView}>
|
||||
{children}
|
||||
</BlurView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ItemContainer;
|
||||
export default ItemContainer
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
position: "relative",
|
||||
alignSelf: "stretch",
|
||||
position: 'relative',
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
borderGradientStyle: {
|
||||
borderWidth: 1.2,
|
||||
borderRadius: ITEM_BORDER_RADIUS,
|
||||
shadowColor: "rgba(3, 0, 18, 0.58)",
|
||||
shadowColor: 'rgba(3, 0, 18, 0.58)',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
@@ -124,21 +120,21 @@ const styles = StyleSheet.create({
|
||||
shadowOpacity: 0.22,
|
||||
shadowRadius: 18,
|
||||
elevation: 8,
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
},
|
||||
contentWrapper: {
|
||||
borderRadius: ITEM_BORDER_RADIUS,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
zIndex: 1,
|
||||
},
|
||||
blurView: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 28,
|
||||
justifyContent: "center",
|
||||
alignSelf: "stretch",
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from "react";
|
||||
import { Image, Pressable, Text, View } from "react-native";
|
||||
import React from 'react'
|
||||
import { Image, Pressable, Text, View } from 'react-native'
|
||||
|
||||
import { responsiveHeight } from "../actions/responsiveSizes";
|
||||
import { responsiveHeight } from '../actions/responsiveSizes'
|
||||
|
||||
import { Fonts, Palette, Style } from "../styles";
|
||||
import { icons } from "../assets";
|
||||
import IconContainer from "./IconContainer";
|
||||
import { Fonts, Palette, Style } from '../styles'
|
||||
import { icons } from '../assets'
|
||||
import IconContainer from './IconContainer'
|
||||
|
||||
const ItemRowList = ({
|
||||
title,
|
||||
@@ -14,7 +14,7 @@ const ItemRowList = ({
|
||||
textStyle = {},
|
||||
addMarginTopFromPrevious = false,
|
||||
containerStyle = {},
|
||||
separatorPosition = "bottom",
|
||||
separatorPosition = 'bottom',
|
||||
}) => {
|
||||
return (
|
||||
<View
|
||||
@@ -23,27 +23,21 @@ const ItemRowList = ({
|
||||
...containerStyle,
|
||||
}}
|
||||
>
|
||||
{separatorPosition === "top" && (
|
||||
<View style={Style.separatorHorizontal} />
|
||||
)}
|
||||
{separatorPosition === 'top' && <View style={Style.separatorHorizontal} />}
|
||||
|
||||
<Pressable onPress={action} style={Style.containerSpaceBetween}>
|
||||
<View style={Style.containerRow}>
|
||||
{icon && <IconContainer icon={icon} />}
|
||||
|
||||
<Text style={Fonts({ type: "section", style: textStyle })}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={Fonts({ type: 'section', style: textStyle })}>{title}</Text>
|
||||
</View>
|
||||
|
||||
<Image source={icons.arrowRight} style={Style.iconSmall} />
|
||||
</Pressable>
|
||||
|
||||
{separatorPosition === "bottom" && (
|
||||
<View style={Style.separatorHorizontal} />
|
||||
)}
|
||||
{separatorPosition === 'bottom' && <View style={Style.separatorHorizontal} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ItemRowList;
|
||||
export default ItemRowList
|
||||
|
||||
@@ -1,72 +1,68 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Palette } from "../styles";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
import React, { useMemo } from 'react'
|
||||
import { Text, View } from 'react-native'
|
||||
import { Palette } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
|
||||
export function groupAlignedWordsToLines(alignedWords = [], { removeTags = true } = {}) {
|
||||
const out = [];
|
||||
let buf = [];
|
||||
let start = null;
|
||||
const out = []
|
||||
let buf = []
|
||||
let start = null
|
||||
const clean = (txt) =>
|
||||
String(txt || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const isSentenceEnd = (txt) => /[.!?]$/.test((txt || "").trim());
|
||||
const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || "").trim());
|
||||
String(txt || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim())
|
||||
const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || '').trim())
|
||||
|
||||
for (let i = 0; i < alignedWords.length; i++) {
|
||||
const w = alignedWords[i] || {};
|
||||
const original = String(w.word || "");
|
||||
const textNoNewline = original.replace(/\n/g, " ");
|
||||
const textNoTag = removeTags
|
||||
? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, "")
|
||||
: textNoNewline;
|
||||
const next = alignedWords[i + 1] || null;
|
||||
const gapToNext = next
|
||||
? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0))
|
||||
: 0;
|
||||
const w = alignedWords[i] || {}
|
||||
const original = String(w.word || '')
|
||||
const textNoNewline = original.replace(/\n/g, ' ')
|
||||
const textNoTag = removeTags ? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, '') : textNoNewline
|
||||
const next = alignedWords[i + 1] || null
|
||||
const gapToNext = next ? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0)) : 0
|
||||
|
||||
if (buf.length === 0) start = Number(w.startS || 0);
|
||||
if (buf.length === 0) start = Number(w.startS || 0)
|
||||
|
||||
if (isSectionTag(textNoNewline)) {
|
||||
if (!removeTags) {
|
||||
const joined = clean(textNoNewline);
|
||||
const joined = clean(textNoNewline)
|
||||
if (joined)
|
||||
out.push({
|
||||
text: joined,
|
||||
startS: start ?? Number(w.startS || 0),
|
||||
endS: Number(w.endS || 0),
|
||||
});
|
||||
})
|
||||
}
|
||||
buf = [];
|
||||
start = null;
|
||||
continue;
|
||||
buf = []
|
||||
start = null
|
||||
continue
|
||||
}
|
||||
|
||||
if (!clean(textNoTag)) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
|
||||
buf.push(textNoTag);
|
||||
buf.push(textNoTag)
|
||||
|
||||
const eolByNewline = /\n/.test(original);
|
||||
const eolByPause = gapToNext >= 0.6; // threshold for a logical break
|
||||
const eolByPunct = isSentenceEnd(textNoTag);
|
||||
const isLast = i === alignedWords.length - 1;
|
||||
const eolByNewline = /\n/.test(original)
|
||||
const eolByPause = gapToNext >= 0.6 // threshold for a logical break
|
||||
const eolByPunct = isSentenceEnd(textNoTag)
|
||||
const isLast = i === alignedWords.length - 1
|
||||
|
||||
if (eolByNewline || eolByPause || eolByPunct || isLast) {
|
||||
const joined = clean(buf.join(" "));
|
||||
const joined = clean(buf.join(' '))
|
||||
if (joined)
|
||||
out.push({
|
||||
text: joined,
|
||||
startS: start ?? Number(w.startS || 0),
|
||||
endS: Number(w.endS || 0),
|
||||
});
|
||||
buf = [];
|
||||
start = null;
|
||||
})
|
||||
buf = []
|
||||
start = null
|
||||
}
|
||||
}
|
||||
return out;
|
||||
return out
|
||||
}
|
||||
|
||||
export default function KaraokeLyrics({
|
||||
@@ -78,30 +74,24 @@ export default function KaraokeLyrics({
|
||||
const lines = useMemo(
|
||||
() => groupAlignedWordsToLines(alignedWords, { removeTags }),
|
||||
[alignedWords, removeTags]
|
||||
);
|
||||
)
|
||||
|
||||
const currentLineIdx = useMemo(() => {
|
||||
if (!lines || lines.length === 0) return -1;
|
||||
if (!lines || lines.length === 0) return -1
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const L = lines[i];
|
||||
if (currentTimeS >= (L.startS || 0) && currentTimeS <= (L.endS || 0))
|
||||
return i;
|
||||
const L = lines[i]
|
||||
if (currentTimeS >= (L.startS || 0) && currentTimeS <= (L.endS || 0)) return i
|
||||
}
|
||||
if (currentTimeS > (lines[lines.length - 1]?.endS || 0))
|
||||
return lines.length - 1;
|
||||
return -1;
|
||||
}, [lines, currentTimeS]);
|
||||
if (currentTimeS > (lines[lines.length - 1]?.endS || 0)) return lines.length - 1
|
||||
return -1
|
||||
}, [lines, currentTimeS])
|
||||
|
||||
if (!lines.length) return null;
|
||||
if (!lines.length) return null
|
||||
|
||||
const prev =
|
||||
showContext && currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : "";
|
||||
const curr =
|
||||
currentLineIdx >= 0 ? lines[currentLineIdx]?.text : lines[0]?.text;
|
||||
const prev = showContext && currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : ''
|
||||
const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : lines[0]?.text
|
||||
const next =
|
||||
showContext && currentLineIdx + 1 < lines.length
|
||||
? lines[currentLineIdx + 1]?.text
|
||||
: "";
|
||||
showContext && currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : ''
|
||||
|
||||
return (
|
||||
<View style={{ gap: 4 }}>
|
||||
@@ -121,7 +111,7 @@ export default function KaraokeLyrics({
|
||||
style={{
|
||||
color: Palette.white,
|
||||
fontSize: 18,
|
||||
textAlign: "center",
|
||||
textAlign: 'center',
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
}}
|
||||
>
|
||||
@@ -130,9 +120,9 @@ export default function KaraokeLyrics({
|
||||
{next ? (
|
||||
<Text
|
||||
style={{
|
||||
color: "#FFFFFF99",
|
||||
color: '#FFFFFF99',
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
textAlign: 'center',
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
}}
|
||||
>
|
||||
@@ -140,5 +130,5 @@ export default function KaraokeLyrics({
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { FlatList, Platform, SectionList, Text, View } from "react-native";
|
||||
import useLayoutType from "../../hooks/useLayoutType";
|
||||
import CreateLyricsHeader from "../../screens/Writing/components/CreateLyricsHeader";
|
||||
import { Palette } from "../../styles";
|
||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||
import { LinearGradient } from "../LinearGradient/LinearGradient";
|
||||
import { BlurView } from 'expo-blur'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { FlatList, Platform, SectionList, Text, View } from 'react-native'
|
||||
import useLayoutType from '../../hooks/useLayoutType'
|
||||
import CreateLyricsHeader from '../../screens/Writing/components/CreateLyricsHeader'
|
||||
import { Palette } from '../../styles'
|
||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||
import { LinearGradient } from '../LinearGradient/LinearGradient'
|
||||
|
||||
const BLUE_SELECTION_GRADIENT = ["#4673F9", "#7023F7"];
|
||||
const BLUE_SELECTION_GRADIENT = ['#4673F9', '#7023F7']
|
||||
|
||||
const buildItemKey = (value, category) =>
|
||||
category ? `${category}::${value}` : `${value}`;
|
||||
const buildItemKey = (value, category) => (category ? `${category}::${value}` : `${value}`)
|
||||
|
||||
const ListSelection = ({
|
||||
options = [],
|
||||
variant = "simple",
|
||||
variant = 'simple',
|
||||
selected: selectedProp,
|
||||
setSelected: setSelectedProp,
|
||||
multiple = false,
|
||||
@@ -27,92 +26,88 @@ const ListSelection = ({
|
||||
itemContainerStyle,
|
||||
itemOuterStyle,
|
||||
itemTextStyle,
|
||||
highlightColor = "#F94697",
|
||||
highlightColor = '#F94697',
|
||||
disableHover = false,
|
||||
}) => {
|
||||
// état interne si non contrôlé
|
||||
const [internalSelected, setInternalSelected] = useState(
|
||||
variant === "sectioned" ? {} : multiple ? [] : null
|
||||
);
|
||||
const selected = selectedProp !== undefined ? selectedProp : internalSelected;
|
||||
const setSelected =
|
||||
setSelectedProp !== undefined ? setSelectedProp : setInternalSelected;
|
||||
variant === 'sectioned' ? {} : multiple ? [] : null
|
||||
)
|
||||
const selected = selectedProp !== undefined ? selectedProp : internalSelected
|
||||
const setSelected = setSelectedProp !== undefined ? setSelectedProp : setInternalSelected
|
||||
|
||||
const { isWeb } = useLayoutType();
|
||||
const [hoveredKey, setHoveredKey] = useState(null);
|
||||
const { isWeb } = useLayoutType()
|
||||
const [hoveredKey, setHoveredKey] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (disableHover && hoveredKey !== null) setHoveredKey(null);
|
||||
}, [disableHover, hoveredKey]);
|
||||
if (disableHover && hoveredKey !== null) setHoveredKey(null)
|
||||
}, [disableHover, hoveredKey])
|
||||
|
||||
// helpers
|
||||
const valueOf = useMemo(() => {
|
||||
if (typeof getItemValue === "function") return getItemValue;
|
||||
if (variant === "simple") return (item) => item;
|
||||
return (item) => item?.title ?? item;
|
||||
}, [getItemValue, variant]);
|
||||
if (typeof getItemValue === 'function') return getItemValue
|
||||
if (variant === 'simple') return (item) => item
|
||||
return (item) => item?.title ?? item
|
||||
}, [getItemValue, variant])
|
||||
|
||||
const formatValue = (item) =>
|
||||
typeof formatSelectedValue === "function"
|
||||
? formatSelectedValue(item)
|
||||
: valueOf(item);
|
||||
typeof formatSelectedValue === 'function' ? formatSelectedValue(item) : valueOf(item)
|
||||
|
||||
const extractSelectedComparable = (s) => {
|
||||
if (typeof selectedValueExtractor === "function")
|
||||
return selectedValueExtractor(s);
|
||||
if (s && typeof s === "object" && "title" in s) return s.title;
|
||||
return s;
|
||||
};
|
||||
if (typeof selectedValueExtractor === 'function') return selectedValueExtractor(s)
|
||||
if (s && typeof s === 'object' && 'title' in s) return s.title
|
||||
return s
|
||||
}
|
||||
|
||||
const isSelected = (val, category) => {
|
||||
if (variant === "sectioned") return selected?.[category] === val;
|
||||
if (variant === 'sectioned') return selected?.[category] === val
|
||||
if (multiple) {
|
||||
const list = Array.isArray(selected) ? selected : [];
|
||||
return list.some((v) => v === val);
|
||||
const list = Array.isArray(selected) ? selected : []
|
||||
return list.some((v) => v === val)
|
||||
}
|
||||
return extractSelectedComparable(selected) === val
|
||||
}
|
||||
return extractSelectedComparable(selected) === val;
|
||||
};
|
||||
|
||||
const toggleSelect = (item, category) => {
|
||||
const val = valueOf(item);
|
||||
const val = valueOf(item)
|
||||
|
||||
if (variant === "sectioned") {
|
||||
const current = selected && typeof selected === "object" ? selected : {};
|
||||
const next = { ...current };
|
||||
if (current?.[category] === val) next[category] = null;
|
||||
else next[category] = formatValue(item);
|
||||
setSelected(next);
|
||||
return;
|
||||
if (variant === 'sectioned') {
|
||||
const current = selected && typeof selected === 'object' ? selected : {}
|
||||
const next = { ...current }
|
||||
if (current?.[category] === val) next[category] = null
|
||||
else next[category] = formatValue(item)
|
||||
setSelected(next)
|
||||
return
|
||||
}
|
||||
|
||||
if (multiple) {
|
||||
const list = Array.isArray(selected) ? selected : [];
|
||||
const exists = list.some((v) => v === val);
|
||||
if (exists) setSelected(list.filter((v) => v !== val));
|
||||
const list = Array.isArray(selected) ? selected : []
|
||||
const exists = list.some((v) => v === val)
|
||||
if (exists) setSelected(list.filter((v) => v !== val))
|
||||
else if (!maxSelection || list.length < maxSelection)
|
||||
setSelected([...list, formatValue(item)]);
|
||||
setSelected([...list, formatValue(item)])
|
||||
} else {
|
||||
if (extractSelectedComparable(selected) === val) setSelected(null);
|
||||
else setSelected(formatValue(item));
|
||||
if (extractSelectedComparable(selected) === val) setSelected(null)
|
||||
else setSelected(formatValue(item))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// contenus élémentaires : toujours encapsuler le texte dans <Text>
|
||||
const renderSimpleContent = (label) => (
|
||||
<View style={{ minHeight: 40, justifyContent: "center" }}>
|
||||
<View style={{ minHeight: 40, justifyContent: 'center' }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
textAlign: "left",
|
||||
textAlign: 'left',
|
||||
...itemTextStyle,
|
||||
}}
|
||||
>
|
||||
{typeof label === "string" ? label : String(label)}
|
||||
{typeof label === 'string' ? label : String(label)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
|
||||
const renderTitleDescriptionContent = (item) => (
|
||||
<View style={{ paddingVertical: 6 }}>
|
||||
@@ -125,14 +120,14 @@ const ListSelection = ({
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: FONT_FAMILY.InterBold }}>{item?.title}</Text>
|
||||
<Text>{` : ${item?.description ?? ""}`}</Text>
|
||||
<Text>{` : ${item?.description ?? ''}`}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
|
||||
// applique le gradient bleu UNIQUEMENT sur web et quand sélectionné
|
||||
const withWebBlueGradientIfSelected = (content, sel) => {
|
||||
if (!isWeb || !sel) return content;
|
||||
if (!isWeb || !sel) return content
|
||||
// wrap dans un View pour éviter texte brut direct sous le gradient (compat RN Web)
|
||||
return (
|
||||
<LinearGradient
|
||||
@@ -144,62 +139,53 @@ const ListSelection = ({
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<View>{content}</View>
|
||||
</LinearGradient>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
// ============ SECTIONED ============
|
||||
if (variant === "sectioned") {
|
||||
if (variant === 'sectioned') {
|
||||
return (
|
||||
<SectionList
|
||||
sections={options}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
renderItem={({ item, section }) => {
|
||||
const cat = section?.title;
|
||||
const val = valueOf(item);
|
||||
const sel = isSelected(val, cat);
|
||||
const itemKey = buildItemKey(String(val ?? ""), cat);
|
||||
const isHovered = isWeb && !disableHover && hoveredKey === itemKey;
|
||||
const showHoverOutline = isHovered && !sel;
|
||||
const showSelectionOutline = !isWeb && sel;
|
||||
const cat = section?.title
|
||||
const val = valueOf(item)
|
||||
const sel = isSelected(val, cat)
|
||||
const itemKey = buildItemKey(String(val ?? ''), cat)
|
||||
const isHovered = isWeb && !disableHover && hoveredKey === itemKey
|
||||
const showHoverOutline = isHovered && !sel
|
||||
const showSelectionOutline = !isWeb && sel
|
||||
|
||||
const baseContent = renderSimpleContent(item);
|
||||
const baseContent = renderSimpleContent(item)
|
||||
const headerContainerStyle = {
|
||||
borderWidth: 0,
|
||||
borderColor: "transparent",
|
||||
borderColor: 'transparent',
|
||||
borderRadius: 14,
|
||||
...itemContainerStyle,
|
||||
};
|
||||
if (sel && isWeb)
|
||||
headerContainerStyle.backgroundColor = "transparent";
|
||||
}
|
||||
if (sel && isWeb) headerContainerStyle.backgroundColor = 'transparent'
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[{ position: "relative" }, itemOuterStyle]}
|
||||
onMouseEnter={
|
||||
isWeb && !disableHover
|
||||
? () => setHoveredKey(itemKey)
|
||||
: undefined
|
||||
}
|
||||
onMouseLeave={
|
||||
isWeb && !disableHover ? () => setHoveredKey(null) : undefined
|
||||
}
|
||||
style={[{ position: 'relative' }, itemOuterStyle]}
|
||||
onMouseEnter={isWeb && !disableHover ? () => setHoveredKey(itemKey) : undefined}
|
||||
onMouseLeave={isWeb && !disableHover ? () => setHoveredKey(null) : undefined}
|
||||
>
|
||||
<CreateLyricsHeader
|
||||
onPress={() => toggleSelect(item, cat)}
|
||||
tint={sel ? "default" : "dark"}
|
||||
tint={sel ? 'default' : 'dark'}
|
||||
colors={[Palette.tran, Palette.tran]}
|
||||
showBorder={!sel}
|
||||
disableBlur={sel && isWeb}
|
||||
blurViewStyle={
|
||||
sel && isWeb
|
||||
? { paddingHorizontal: 0, paddingVertical: 0 }
|
||||
: undefined
|
||||
sel && isWeb ? { paddingHorizontal: 0, paddingVertical: 0 } : undefined
|
||||
}
|
||||
containerStyle={headerContainerStyle}
|
||||
>
|
||||
@@ -210,7 +196,7 @@ const ListSelection = ({
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -223,21 +209,17 @@ const ListSelection = ({
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}}
|
||||
renderSectionHeader={({ section: { title } }) => (
|
||||
<View
|
||||
style={{ alignSelf: "flex-start", marginLeft: 10, marginBottom: 6 }}
|
||||
>
|
||||
<View style={{ alignSelf: 'flex-start', marginLeft: 10, marginBottom: 6 }}>
|
||||
<BlurView
|
||||
intensity={40}
|
||||
tint="dark"
|
||||
experimentalBlurMethod={
|
||||
Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
||||
}
|
||||
experimentalBlurMethod={Platform.OS !== 'ios' ? 'dimezisBlurView' : 'none'}
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
}}
|
||||
@@ -256,7 +238,7 @@ const ListSelection = ({
|
||||
)}
|
||||
keyExtractor={(item, idx) => `${valueOf(item)}-${idx}`}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// ============ FLAT (simple/titleDescription/emotion) ============
|
||||
@@ -266,66 +248,54 @@ const ListSelection = ({
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
renderItem={({ item }) => {
|
||||
const val = valueOf(item);
|
||||
const sel = isSelected(val);
|
||||
const useEmotionColors = variant === "emotion";
|
||||
const val = valueOf(item)
|
||||
const sel = isSelected(val)
|
||||
const useEmotionColors = variant === 'emotion'
|
||||
|
||||
// pour "emotion", on masque le bord gauche si sélectionné
|
||||
const borderColors = useEmotionColors
|
||||
? sel
|
||||
? [Palette.tran, Palette.tran]
|
||||
: item?.color
|
||||
: [Palette.tran, Palette.tran];
|
||||
const tint = useEmotionColors ? "dark" : sel ? "default" : "dark";
|
||||
: [Palette.tran, Palette.tran]
|
||||
const tint = useEmotionColors ? 'dark' : sel ? 'default' : 'dark'
|
||||
|
||||
const itemKey = buildItemKey(String(val ?? ""));
|
||||
const isHovered = isWeb && !disableHover && hoveredKey === itemKey;
|
||||
const showHoverOutline = isHovered && !sel;
|
||||
const itemKey = buildItemKey(String(val ?? ''))
|
||||
const isHovered = isWeb && !disableHover && hoveredKey === itemKey
|
||||
const showHoverOutline = isHovered && !sel
|
||||
const showSelectionOutline =
|
||||
!isWeb &&
|
||||
(variant === "simple" ||
|
||||
variant === "emotion" ||
|
||||
variant === "titleDescription") &&
|
||||
sel;
|
||||
(variant === 'simple' || variant === 'emotion' || variant === 'titleDescription') &&
|
||||
sel
|
||||
|
||||
const baseContent =
|
||||
variant === "simple"
|
||||
? renderSimpleContent(item)
|
||||
: renderTitleDescriptionContent(item);
|
||||
variant === 'simple' ? renderSimpleContent(item) : renderTitleDescriptionContent(item)
|
||||
|
||||
const headerContainerStyle = {
|
||||
borderWidth: 0,
|
||||
borderColor: "transparent",
|
||||
borderColor: 'transparent',
|
||||
borderRadius: 14,
|
||||
...itemContainerStyle,
|
||||
};
|
||||
if (sel && isWeb) headerContainerStyle.backgroundColor = "transparent";
|
||||
}
|
||||
if (sel && isWeb) headerContainerStyle.backgroundColor = 'transparent'
|
||||
|
||||
const shouldShowBorder = !sel;
|
||||
const shouldShowBorder = !sel
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[{ position: "relative" }, itemOuterStyle]}
|
||||
onMouseEnter={
|
||||
isWeb && !disableHover ? () => setHoveredKey(itemKey) : undefined
|
||||
}
|
||||
onMouseLeave={
|
||||
isWeb && !disableHover ? () => setHoveredKey(null) : undefined
|
||||
}
|
||||
style={[{ position: 'relative' }, itemOuterStyle]}
|
||||
onMouseEnter={isWeb && !disableHover ? () => setHoveredKey(itemKey) : undefined}
|
||||
onMouseLeave={isWeb && !disableHover ? () => setHoveredKey(null) : undefined}
|
||||
>
|
||||
<CreateLyricsHeader
|
||||
colors={borderColors}
|
||||
tint={tint}
|
||||
onPress={() => toggleSelect(item)}
|
||||
gradientProps={
|
||||
useEmotionColors && !sel ? { locations: [0.24, 1] } : undefined
|
||||
}
|
||||
gradientProps={useEmotionColors && !sel ? { locations: [0.24, 1] } : undefined}
|
||||
showBorder={shouldShowBorder}
|
||||
disableBlur={sel && isWeb} // web: pas de blur si gradient
|
||||
blurViewStyle={
|
||||
sel && isWeb
|
||||
? { paddingHorizontal: 0, paddingVertical: 0 }
|
||||
: undefined
|
||||
sel && isWeb ? { paddingHorizontal: 0, paddingVertical: 0 } : undefined
|
||||
}
|
||||
containerStyle={headerContainerStyle}
|
||||
>
|
||||
@@ -336,7 +306,7 @@ const ListSelection = ({
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -349,11 +319,11 @@ const ListSelection = ({
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}}
|
||||
keyExtractor={(item, idx) => `${valueOf(item)}-${idx}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ListSelection;
|
||||
export default ListSelection
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import NativeMaskedView from "@react-native-masked-view/masked-view";
|
||||
import NativeMaskedView from '@react-native-masked-view/masked-view'
|
||||
|
||||
const MaskedView = NativeMaskedView;
|
||||
export default MaskedView;
|
||||
const MaskedView = NativeMaskedView
|
||||
export default MaskedView
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import React from 'react'
|
||||
import { View } from 'react-native'
|
||||
|
||||
function MaskedView({ maskElement, ...props }) {
|
||||
return React.createElement(View, props, maskElement);
|
||||
return React.createElement(View, props, maskElement)
|
||||
}
|
||||
|
||||
export default MaskedView;
|
||||
export default MaskedView
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user