feat: fixes and formatter
This commit is contained in:
+5
-5
@@ -1,12 +1,12 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ["expo", "prettier"],
|
extends: ['expo', 'prettier'],
|
||||||
plugins: ["prettier"],
|
plugins: ['prettier'],
|
||||||
rules: {
|
rules: {
|
||||||
"prettier/prettier": [
|
'prettier/prettier': [
|
||||||
"error",
|
'error',
|
||||||
{
|
{
|
||||||
singleQuote: false,
|
singleQuote: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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 '@expo/metro-runtime'
|
||||||
import { PortalProvider } from "@gorhom/portal";
|
import { PortalProvider } from '@gorhom/portal'
|
||||||
import { DefaultTheme, NavigationContainer } from "@react-navigation/native";
|
import { DefaultTheme, NavigationContainer } from '@react-navigation/native'
|
||||||
import { useFonts } from "expo-font";
|
import { useFonts } from 'expo-font'
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from 'expo-splash-screen'
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from 'expo-status-bar'
|
||||||
import moment from "moment";
|
import moment from 'moment'
|
||||||
import "moment/locale/fr";
|
import 'moment/locale/fr'
|
||||||
import { Platform, Text, TextInput } from "react-native";
|
import { Platform, Text, TextInput } from 'react-native'
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||||
import React, {
|
import React, { setGlobal, useCallback, useEffect, useGlobal, useState } from 'reactn'
|
||||||
setGlobal,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useGlobal,
|
|
||||||
useState,
|
|
||||||
} from "reactn";
|
|
||||||
|
|
||||||
import { MainStack, Routes } from "./src/navigation";
|
import { MainStack, Routes } from './src/navigation'
|
||||||
import { Palette } from "./src/styles";
|
import { Palette } from './src/styles'
|
||||||
|
|
||||||
import { navigationRef, reset } from "./src/navigation/NavigationService";
|
import { navigationRef, reset } from './src/navigation/NavigationService'
|
||||||
|
|
||||||
import { MenuProvider } from "react-native-popup-menu";
|
import { MenuProvider } from 'react-native-popup-menu'
|
||||||
import initialGlobalState from "./src/config/initialGlobalState";
|
import initialGlobalState from './src/config/initialGlobalState'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Inter_400Regular,
|
Inter_400Regular,
|
||||||
@@ -30,143 +24,143 @@ import {
|
|||||||
Inter_500Medium,
|
Inter_500Medium,
|
||||||
Inter_600SemiBold,
|
Inter_600SemiBold,
|
||||||
Inter_700Bold,
|
Inter_700Bold,
|
||||||
} from "@expo-google-fonts/inter";
|
} from '@expo-google-fonts/inter'
|
||||||
import firebase from "./src/config/firebase";
|
import firebase from './src/config/firebase'
|
||||||
import Providers from "./src/providers";
|
import Providers from './src/providers'
|
||||||
|
|
||||||
import { LogBox } from "react-native";
|
import { LogBox } from 'react-native'
|
||||||
import CommentsBottomSheet from "./src/components/bottomsheets/CommentsBottomSheet";
|
import CommentsBottomSheet from './src/components/bottomsheets/CommentsBottomSheet'
|
||||||
import ShareQrModalContainer from "./src/components/modal/ShareQrModalContainer";
|
import ShareQrModalContainer from './src/components/modal/ShareQrModalContainer'
|
||||||
import AppDownloadBanner from "./src/components/AppDownloadBanner";
|
import AppDownloadBanner from './src/components/AppDownloadBanner'
|
||||||
import MobileSplashVideo from "./src/components/MobileSplashVideo";
|
import MobileSplashVideo from './src/components/MobileSplashVideo'
|
||||||
import "./src/utils/Sheet";
|
import './src/utils/Sheet'
|
||||||
|
|
||||||
console.disableYellowBox = true;
|
console.disableYellowBox = true
|
||||||
console.reportErrorsAsExceptions = false;
|
console.reportErrorsAsExceptions = false
|
||||||
|
|
||||||
moment.locale("fr");
|
moment.locale('fr')
|
||||||
|
|
||||||
// Keep typography stable regardless of the user's system font scaling on mobile
|
// Keep typography stable regardless of the user's system font scaling on mobile
|
||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== 'web') {
|
||||||
if (Text.defaultProps == null) {
|
if (Text.defaultProps == null) {
|
||||||
Text.defaultProps = {};
|
Text.defaultProps = {}
|
||||||
}
|
}
|
||||||
if (TextInput.defaultProps == null) {
|
if (TextInput.defaultProps == null) {
|
||||||
TextInput.defaultProps = {};
|
TextInput.defaultProps = {}
|
||||||
}
|
}
|
||||||
Text.defaultProps.allowFontScaling = false;
|
Text.defaultProps.allowFontScaling = false
|
||||||
TextInput.defaultProps.allowFontScaling = false;
|
TextInput.defaultProps.allowFontScaling = false
|
||||||
}
|
}
|
||||||
|
|
||||||
setGlobal(initialGlobalState);
|
setGlobal(initialGlobalState)
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync()
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [, setCurrentUID] = useGlobal("currentUID");
|
const [, setCurrentUID] = useGlobal('currentUID')
|
||||||
const [, setCurrentUserRoles] = useGlobal("currentUserRoles");
|
const [, setCurrentUserRoles] = useGlobal('currentUserRoles')
|
||||||
|
|
||||||
const [appIsReady, setAppIsReady] = useState(false);
|
const [appIsReady, setAppIsReady] = useState(false)
|
||||||
|
|
||||||
const [isInitializing, setInitializing] = useState(true);
|
const [isInitializing, setInitializing] = useState(true)
|
||||||
|
|
||||||
const routeNameRef = React.useRef(null);
|
const routeNameRef = React.useRef(null)
|
||||||
|
|
||||||
const syncActiveRoute = useCallback(() => {
|
const syncActiveRoute = useCallback(() => {
|
||||||
const currentRoute = navigationRef.current?.getCurrentRoute();
|
const currentRoute = navigationRef.current?.getCurrentRoute()
|
||||||
const currentName = currentRoute?.name ?? null;
|
const currentName = currentRoute?.name ?? null
|
||||||
|
|
||||||
if (routeNameRef.current !== currentName) {
|
if (routeNameRef.current !== currentName) {
|
||||||
routeNameRef.current = currentName;
|
routeNameRef.current = currentName
|
||||||
setGlobal({ activeRouteName: currentName });
|
setGlobal({ activeRouteName: currentName })
|
||||||
}
|
}
|
||||||
}, [setGlobal]);
|
}, [setGlobal])
|
||||||
|
|
||||||
const handleNavigationReady = useCallback(() => {
|
const handleNavigationReady = useCallback(() => {
|
||||||
syncActiveRoute();
|
syncActiveRoute()
|
||||||
}, [syncActiveRoute]);
|
}, [syncActiveRoute])
|
||||||
|
|
||||||
const handleNavigationStateChange = useCallback(() => {
|
const handleNavigationStateChange = useCallback(() => {
|
||||||
syncActiveRoute();
|
syncActiveRoute()
|
||||||
}, [syncActiveRoute]);
|
}, [syncActiveRoute])
|
||||||
|
|
||||||
const [loaded] = useFonts({
|
const [loaded] = useFonts({
|
||||||
NewYorkSemibold: require("./src/assets/fonts/NewYork-Semibold.ttf"),
|
NewYorkSemibold: require('./src/assets/fonts/NewYork-Semibold.ttf'),
|
||||||
OpenSansRegular: require("./src/assets/fonts/OpenSans-Regular.ttf"),
|
OpenSansRegular: require('./src/assets/fonts/OpenSans-Regular.ttf'),
|
||||||
InterRegular: Inter_400Regular,
|
InterRegular: Inter_400Regular,
|
||||||
InterMedium: Inter_500Medium,
|
InterMedium: Inter_500Medium,
|
||||||
InterSemiBold: Inter_600SemiBold,
|
InterSemiBold: Inter_600SemiBold,
|
||||||
InterBold: Inter_700Bold,
|
InterBold: Inter_700Bold,
|
||||||
InterRegularItalic: Inter_400Regular_Italic,
|
InterRegularItalic: Inter_400Regular_Italic,
|
||||||
HelveticaNeueRegular: require("./src/assets/fonts/HelveticaNeueRegular.ttf"),
|
HelveticaNeueRegular: require('./src/assets/fonts/HelveticaNeueRegular.ttf'),
|
||||||
HelveticaNeueMedium: require("./src/assets/fonts/HelveticaNeueMedium.ttf"),
|
HelveticaNeueMedium: require('./src/assets/fonts/HelveticaNeueMedium.ttf'),
|
||||||
HelveticaNeueBold: require("./src/assets/fonts/HelveticaNeueBold.ttf"),
|
HelveticaNeueBold: require('./src/assets/fonts/HelveticaNeueBold.ttf'),
|
||||||
OwnersRegular: require("./src/assets/fonts/OwnersRegular.ttf"),
|
OwnersRegular: require('./src/assets/fonts/OwnersRegular.ttf'),
|
||||||
OwnersMedium: require("./src/assets/fonts/OwnersMedium.ttf"),
|
OwnersMedium: require('./src/assets/fonts/OwnersMedium.ttf'),
|
||||||
OwnersBold: require("./src/assets/fonts/OwnersBold.ttf"),
|
OwnersBold: require('./src/assets/fonts/OwnersBold.ttf'),
|
||||||
});
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
setAppIsReady(true);
|
setAppIsReady(true)
|
||||||
LogBox.ignoreAllLogs();
|
LogBox.ignoreAllLogs()
|
||||||
}
|
}
|
||||||
}, [loaded]);
|
}, [loaded])
|
||||||
|
|
||||||
const onLayoutRootView = useCallback(async () => {
|
const onLayoutRootView = useCallback(async () => {
|
||||||
if (appIsReady) {
|
if (appIsReady) {
|
||||||
await SplashScreen.hideAsync();
|
await SplashScreen.hideAsync()
|
||||||
}
|
}
|
||||||
}, [appIsReady, loaded]);
|
}, [appIsReady, loaded])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscriber = firebase.auth().onAuthStateChanged(onAuthStateChanged);
|
const subscriber = firebase.auth().onAuthStateChanged(onAuthStateChanged)
|
||||||
return subscriber;
|
return subscriber
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === 'web') {
|
||||||
const s = document.createElement("script");
|
const s = document.createElement('script')
|
||||||
s.src = "https://minuit.app/embed/report-a-problem.js";
|
s.src = 'https://minuit.app/embed/report-a-problem.js'
|
||||||
s.async = true;
|
s.async = true
|
||||||
s.setAttribute("data-project-id", "playbackproduction");
|
s.setAttribute('data-project-id', 'playbackproduction')
|
||||||
s.setAttribute("data-position", "right");
|
s.setAttribute('data-position', 'right')
|
||||||
s.setAttribute("data-offset", "16");
|
s.setAttribute('data-offset', '16')
|
||||||
s.setAttribute("data-primary-color", "#FB68A8");
|
s.setAttribute('data-primary-color', '#FB68A8')
|
||||||
s.setAttribute("data-bg-primary-color", "#0E0E12");
|
s.setAttribute('data-bg-primary-color', '#0E0E12')
|
||||||
s.setAttribute("data-bg-secondary-color", "#1D1825");
|
s.setAttribute('data-bg-secondary-color', '#1D1825')
|
||||||
s.setAttribute("data-text-color", "#F3F0F5");
|
s.setAttribute('data-text-color', '#F3F0F5')
|
||||||
|
|
||||||
document.body.appendChild(s);
|
document.body.appendChild(s)
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
try {
|
||||||
s.remove();
|
s.remove()
|
||||||
} catch (_) { }
|
} catch (_) {}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}, []);
|
}
|
||||||
|
}, [])
|
||||||
const onAuthStateChanged = async (user) => {
|
const onAuthStateChanged = async (user) => {
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
setInitializing(false);
|
setInitializing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user?.uid) {
|
if (user?.uid) {
|
||||||
setCurrentUID(user.uid);
|
setCurrentUID(user.uid)
|
||||||
|
|
||||||
const idTokenResult = await user.getIdTokenResult();
|
const idTokenResult = await user.getIdTokenResult()
|
||||||
setCurrentUserRoles(idTokenResult?.claims?.roles || []);
|
setCurrentUserRoles(idTokenResult?.claims?.roles || [])
|
||||||
} else {
|
} else {
|
||||||
setCurrentUID(null);
|
setCurrentUID(null)
|
||||||
setGlobal(initialGlobalState);
|
setGlobal(initialGlobalState)
|
||||||
reset({
|
reset({
|
||||||
index: 0,
|
index: 0,
|
||||||
routes: [{ name: Routes.Splash }],
|
routes: [{ name: Routes.Splash }],
|
||||||
});
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (!loaded || isInitializing) {
|
if (!loaded || isInitializing) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -177,8 +171,7 @@ const App = () => {
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor:
|
backgroundColor: Platform.OS === 'web' ? 'transparent' : Palette.darkPurple,
|
||||||
Platform.OS === "web" ? "transparent" : Palette.darkPurple,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -191,10 +184,7 @@ const App = () => {
|
|||||||
...DefaultTheme,
|
...DefaultTheme,
|
||||||
colors: {
|
colors: {
|
||||||
...DefaultTheme.colors,
|
...DefaultTheme.colors,
|
||||||
background:
|
background: Platform.OS === 'web' ? 'transparent' : Palette.darkPurple,
|
||||||
Platform.OS === "web"
|
|
||||||
? "transparent"
|
|
||||||
: Palette.darkPurple,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
ref={navigationRef}
|
ref={navigationRef}
|
||||||
@@ -202,12 +192,10 @@ const App = () => {
|
|||||||
onStateChange={handleNavigationStateChange}
|
onStateChange={handleNavigationStateChange}
|
||||||
documentTitle={{
|
documentTitle={{
|
||||||
formatter: (options) =>
|
formatter: (options) =>
|
||||||
options?.title
|
options?.title ? `${options?.title} - MusicLand` : 'MusicLand',
|
||||||
? `${options?.title} - MusicLand`
|
|
||||||
: "MusicLand",
|
|
||||||
}}
|
}}
|
||||||
linking={{
|
linking={{
|
||||||
prefixes: ["musicland://", "https://musicland-one.vercel.app"],
|
prefixes: ['musicland://', 'https://musicland-one.vercel.app'],
|
||||||
config: {
|
config: {
|
||||||
screens: {
|
screens: {
|
||||||
// Add any deep link configurations here if needed
|
// Add any deep link configurations here if needed
|
||||||
@@ -227,7 +215,7 @@ const App = () => {
|
|||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default App;
|
export default App
|
||||||
|
|||||||
+5
-8
@@ -1,10 +1,7 @@
|
|||||||
module.exports = function (api) {
|
module.exports = function (api) {
|
||||||
api.cache(true);
|
api.cache(true)
|
||||||
return {
|
return {
|
||||||
presets: ["babel-preset-expo"],
|
presets: ['babel-preset-expo'],
|
||||||
plugins: [
|
plugins: ['@babel/plugin-proposal-export-namespace-from', 'react-native-reanimated/plugin'],
|
||||||
"@babel/plugin-proposal-export-namespace-from",
|
}
|
||||||
"react-native-reanimated/plugin",
|
}
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
+89
-131
@@ -1,69 +1,69 @@
|
|||||||
const fs = require("fs");
|
const fs = require('fs')
|
||||||
const path = require("path");
|
const path = require('path')
|
||||||
const parser = require("@babel/parser");
|
const parser = require('@babel/parser')
|
||||||
const traverse = require("@babel/traverse").default;
|
const traverse = require('@babel/traverse').default
|
||||||
|
|
||||||
const entryFile = path.resolve(__dirname, "index.js");
|
const entryFile = path.resolve(__dirname, 'index.js')
|
||||||
const srcDir = path.resolve(__dirname, "src");
|
const srcDir = path.resolve(__dirname, 'src')
|
||||||
|
|
||||||
// Extensions de fichiers à analyser
|
// Extensions de fichiers à analyser
|
||||||
const scriptExtensions = [".js", ".jsx", ".ts", ".tsx", ".web.js"];
|
const scriptExtensions = ['.js', '.jsx', '.ts', '.tsx', '.web.js']
|
||||||
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".svg"];
|
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg']
|
||||||
|
|
||||||
// Fonction pour obtenir tous les fichiers dans un dossier avec des extensions spécifiques
|
// Fonction pour obtenir tous les fichiers dans un dossier avec des extensions spécifiques
|
||||||
function getAllFiles(dir, extensions, fileList = []) {
|
function getAllFiles(dir, extensions, fileList = []) {
|
||||||
const files = fs.readdirSync(dir);
|
const files = fs.readdirSync(dir)
|
||||||
files.forEach(function (file) {
|
files.forEach(function (file) {
|
||||||
const filePath = path.join(dir, file);
|
const filePath = path.join(dir, file)
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath)
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
getAllFiles(filePath, extensions, fileList);
|
getAllFiles(filePath, extensions, fileList)
|
||||||
} else if (extensions.includes(path.extname(file))) {
|
} else if (extensions.includes(path.extname(file))) {
|
||||||
fileList.push(filePath);
|
fileList.push(filePath)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
return fileList;
|
return fileList
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifie si un chemin est un fichier valide
|
// Vérifie si un chemin est un fichier valide
|
||||||
function isValidFile(filePath) {
|
function isValidFile(filePath) {
|
||||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
return fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Résout le chemin de l'import en un fichier valide
|
// Résout le chemin de l'import en un fichier valide
|
||||||
function resolveImport(filePath, importPath) {
|
function resolveImport(filePath, importPath) {
|
||||||
let importedFile = path.resolve(path.dirname(filePath), importPath);
|
let importedFile = path.resolve(path.dirname(filePath), importPath)
|
||||||
|
|
||||||
// Liste des extensions à tester, y compris .web.js
|
// Liste des extensions à tester, y compris .web.js
|
||||||
const allExtensions = scriptExtensions;
|
const allExtensions = scriptExtensions
|
||||||
|
|
||||||
// Si le chemin n'a pas d'extension, essayer avec différentes extensions
|
// Si le chemin n'a pas d'extension, essayer avec différentes extensions
|
||||||
if (!path.extname(importedFile)) {
|
if (!path.extname(importedFile)) {
|
||||||
for (const ext of allExtensions) {
|
for (const ext of allExtensions) {
|
||||||
if (isValidFile(importedFile + ext)) {
|
if (isValidFile(importedFile + ext)) {
|
||||||
return importedFile + ext;
|
return importedFile + ext
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si un fichier avec l'extension actuelle existe, le retourner
|
// Si un fichier avec l'extension actuelle existe, le retourner
|
||||||
if (isValidFile(importedFile)) {
|
if (isValidFile(importedFile)) {
|
||||||
return importedFile;
|
return importedFile
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si le chemin est un répertoire, chercher un index avec les extensions
|
// Si le chemin est un répertoire, chercher un index avec les extensions
|
||||||
if (fs.existsSync(importedFile) && fs.statSync(importedFile).isDirectory()) {
|
if (fs.existsSync(importedFile) && fs.statSync(importedFile).isDirectory()) {
|
||||||
const indexFiles = allExtensions.map((ext) => "index" + ext);
|
const indexFiles = allExtensions.map((ext) => 'index' + ext)
|
||||||
for (const indexFile of indexFiles) {
|
for (const indexFile of indexFiles) {
|
||||||
const indexFilePath = path.join(importedFile, indexFile);
|
const indexFilePath = path.join(importedFile, indexFile)
|
||||||
if (isValidFile(indexFilePath)) {
|
if (isValidFile(indexFilePath)) {
|
||||||
return indexFilePath;
|
return indexFilePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aucun fichier valide trouvé
|
// Aucun fichier valide trouvé
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Analyse des fichiers pour trouver les imports
|
// Analyse des fichiers pour trouver les imports
|
||||||
@@ -74,178 +74,136 @@ function getDependencies(
|
|||||||
usedFiles = new Set(),
|
usedFiles = new Set(),
|
||||||
usedAssets = new Set()
|
usedAssets = new Set()
|
||||||
) {
|
) {
|
||||||
if (visitedFiles.has(filePath))
|
if (visitedFiles.has(filePath)) return { usedDependencies, usedFiles, usedAssets }
|
||||||
return { usedDependencies, usedFiles, usedAssets };
|
visitedFiles.add(filePath)
|
||||||
visitedFiles.add(filePath);
|
usedFiles.add(filePath)
|
||||||
usedFiles.add(filePath);
|
|
||||||
|
|
||||||
const content = fs.readFileSync(filePath, "utf-8");
|
const content = fs.readFileSync(filePath, 'utf-8')
|
||||||
let ast;
|
let ast
|
||||||
try {
|
try {
|
||||||
ast = parser.parse(content, {
|
ast = parser.parse(content, {
|
||||||
sourceType: "module",
|
sourceType: 'module',
|
||||||
plugins: ["jsx", "typescript", "classProperties", "dynamicImport"],
|
plugins: ['jsx', 'typescript', 'classProperties', 'dynamicImport'],
|
||||||
});
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Erreur lors de l'analyse du fichier ${filePath}:`, error);
|
console.error(`Erreur lors de l'analyse du fichier ${filePath}:`, error)
|
||||||
return { usedDependencies, usedFiles, usedAssets };
|
return { usedDependencies, usedFiles, usedAssets }
|
||||||
}
|
}
|
||||||
|
|
||||||
traverse(ast, {
|
traverse(ast, {
|
||||||
ImportDeclaration({ node }) {
|
ImportDeclaration({ node }) {
|
||||||
const importPath = node.source.value;
|
const importPath = node.source.value
|
||||||
handleImport(
|
handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets)
|
||||||
filePath,
|
|
||||||
importPath,
|
|
||||||
visitedFiles,
|
|
||||||
usedDependencies,
|
|
||||||
usedFiles,
|
|
||||||
usedAssets
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
CallExpression({ node }) {
|
CallExpression({ node }) {
|
||||||
if (
|
if (
|
||||||
node.callee.type === "Import" ||
|
node.callee.type === 'Import' ||
|
||||||
(node.callee.name === "require" && node.arguments.length)
|
(node.callee.name === 'require' && node.arguments.length)
|
||||||
) {
|
) {
|
||||||
const importArg = node.arguments[0];
|
const importArg = node.arguments[0]
|
||||||
if (importArg && importArg.type === "StringLiteral") {
|
if (importArg && importArg.type === 'StringLiteral') {
|
||||||
const importPath = importArg.value;
|
const importPath = importArg.value
|
||||||
handleImport(
|
handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets)
|
||||||
filePath,
|
|
||||||
importPath,
|
|
||||||
visitedFiles,
|
|
||||||
usedDependencies,
|
|
||||||
usedFiles,
|
|
||||||
usedAssets
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
// Vérifier si un fichier .web.js correspondant existe
|
// Vérifier si un fichier .web.js correspondant existe
|
||||||
if (filePath.endsWith(".js")) {
|
if (filePath.endsWith('.js')) {
|
||||||
const webFilePath = filePath.replace(/\.js$/, ".web.js");
|
const webFilePath = filePath.replace(/\.js$/, '.web.js')
|
||||||
if (isValidFile(webFilePath)) {
|
if (isValidFile(webFilePath)) {
|
||||||
getDependencies(
|
getDependencies(webFilePath, visitedFiles, usedDependencies, usedFiles, usedAssets)
|
||||||
webFilePath,
|
|
||||||
visitedFiles,
|
|
||||||
usedDependencies,
|
|
||||||
usedFiles,
|
|
||||||
usedAssets
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { usedDependencies, usedFiles, usedAssets };
|
return { usedDependencies, usedFiles, usedAssets }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fonction pour gérer les imports
|
// Fonction pour gérer les imports
|
||||||
function handleImport(
|
function handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets) {
|
||||||
filePath,
|
if (importPath.startsWith('.')) {
|
||||||
importPath,
|
|
||||||
visitedFiles,
|
|
||||||
usedDependencies,
|
|
||||||
usedFiles,
|
|
||||||
usedAssets
|
|
||||||
) {
|
|
||||||
if (importPath.startsWith(".")) {
|
|
||||||
// Chemin relatif
|
// Chemin relatif
|
||||||
const resolvedPath = resolveImport(filePath, importPath);
|
const resolvedPath = resolveImport(filePath, importPath)
|
||||||
if (resolvedPath) {
|
if (resolvedPath) {
|
||||||
const ext = path.extname(resolvedPath);
|
const ext = path.extname(resolvedPath)
|
||||||
if (scriptExtensions.includes(ext)) {
|
if (scriptExtensions.includes(ext)) {
|
||||||
getDependencies(
|
getDependencies(resolvedPath, visitedFiles, usedDependencies, usedFiles, usedAssets)
|
||||||
resolvedPath,
|
|
||||||
visitedFiles,
|
|
||||||
usedDependencies,
|
|
||||||
usedFiles,
|
|
||||||
usedAssets
|
|
||||||
);
|
|
||||||
} else if (imageExtensions.includes(ext)) {
|
} else if (imageExtensions.includes(ext)) {
|
||||||
usedAssets.add(resolvedPath);
|
usedAssets.add(resolvedPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Dépendance de node_modules
|
// Dépendance de node_modules
|
||||||
const dep = importPath.split("/")[0];
|
const dep = importPath.split('/')[0]
|
||||||
usedDependencies.add(dep);
|
usedDependencies.add(dep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
;(async () => {
|
||||||
// Étape 1: Obtenir tous les fichiers utilisés
|
// Étape 1: Obtenir tous les fichiers utilisés
|
||||||
const allScriptFiles = getAllFiles(srcDir, scriptExtensions);
|
const allScriptFiles = getAllFiles(srcDir, scriptExtensions)
|
||||||
const allAssetFiles = getAllFiles(srcDir, imageExtensions);
|
const allAssetFiles = getAllFiles(srcDir, imageExtensions)
|
||||||
|
|
||||||
const { usedDependencies, usedFiles, usedAssets } =
|
const { usedDependencies, usedFiles, usedAssets } = getDependencies(entryFile)
|
||||||
getDependencies(entryFile);
|
|
||||||
|
|
||||||
// Étape 2: Supprimer les fichiers scripts inutilisés
|
// Étape 2: Supprimer les fichiers scripts inutilisés
|
||||||
const unusedScriptFiles = allScriptFiles.filter(
|
const unusedScriptFiles = allScriptFiles.filter((file) => !usedFiles.has(file))
|
||||||
(file) => !usedFiles.has(file)
|
|
||||||
);
|
|
||||||
|
|
||||||
unusedScriptFiles.forEach((file) => {
|
unusedScriptFiles.forEach((file) => {
|
||||||
fs.unlinkSync(file);
|
fs.unlinkSync(file)
|
||||||
console.log(`Fichier script supprimé: ${file}`);
|
console.log(`Fichier script supprimé: ${file}`)
|
||||||
});
|
})
|
||||||
|
|
||||||
// Étape 3: Supprimer les images non utilisées
|
// Étape 3: Supprimer les images non utilisées
|
||||||
const unusedAssetFiles = allAssetFiles.filter(
|
const unusedAssetFiles = allAssetFiles.filter((file) => !usedAssets.has(file))
|
||||||
(file) => !usedAssets.has(file)
|
|
||||||
);
|
|
||||||
|
|
||||||
unusedAssetFiles.forEach((file) => {
|
unusedAssetFiles.forEach((file) => {
|
||||||
fs.unlinkSync(file);
|
fs.unlinkSync(file)
|
||||||
console.log(`Fichier image supprimé: ${file}`);
|
console.log(`Fichier image supprimé: ${file}`)
|
||||||
});
|
})
|
||||||
|
|
||||||
// Étape 4: Optimiser les images utilisées sans perte de qualité
|
// Étape 4: Optimiser les images utilisées sans perte de qualité
|
||||||
async function optimizeImages(usedAssets) {
|
async function optimizeImages(usedAssets) {
|
||||||
// Import dynamique des modules ES
|
// Import dynamique des modules ES
|
||||||
const imagemin = (await import("imagemin")).default;
|
const imagemin = (await import('imagemin')).default
|
||||||
const imageminOptipng = (await import("imagemin-optipng")).default;
|
const imageminOptipng = (await import('imagemin-optipng')).default
|
||||||
const imageminJpegtran = (await import("imagemin-jpegtran")).default;
|
const imageminJpegtran = (await import('imagemin-jpegtran')).default
|
||||||
const imageminGifsicle = (await import("imagemin-gifsicle")).default;
|
const imageminGifsicle = (await import('imagemin-gifsicle')).default
|
||||||
const imageminSvgo = (await import("imagemin-svgo")).default;
|
const imageminSvgo = (await import('imagemin-svgo')).default
|
||||||
|
|
||||||
for (const file of usedAssets) {
|
for (const file of usedAssets) {
|
||||||
const ext = path.extname(file).toLowerCase();
|
const ext = path.extname(file).toLowerCase()
|
||||||
const plugins = [];
|
const plugins = []
|
||||||
|
|
||||||
if (ext === ".png") {
|
if (ext === '.png') {
|
||||||
plugins.push(imageminOptipng({ optimizationLevel: 3 }));
|
plugins.push(imageminOptipng({ optimizationLevel: 3 }))
|
||||||
} else if (ext === ".jpg" || ext === ".jpeg") {
|
} else if (ext === '.jpg' || ext === '.jpeg') {
|
||||||
plugins.push(imageminJpegtran({ progressive: true }));
|
plugins.push(imageminJpegtran({ progressive: true }))
|
||||||
} else if (ext === ".gif") {
|
} else if (ext === '.gif') {
|
||||||
plugins.push(imageminGifsicle({ optimizationLevel: 3 }));
|
plugins.push(imageminGifsicle({ optimizationLevel: 3 }))
|
||||||
} else if (ext === ".svg") {
|
} else if (ext === '.svg') {
|
||||||
plugins.push(imageminSvgo());
|
plugins.push(imageminSvgo())
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const optimized = await imagemin([file], {
|
const optimized = await imagemin([file], {
|
||||||
destination: path.dirname(file),
|
destination: path.dirname(file),
|
||||||
plugins: plugins,
|
plugins: plugins,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (optimized && optimized.length > 0) {
|
if (optimized && optimized.length > 0) {
|
||||||
console.log(`Image optimisée: ${file}`);
|
console.log(`Image optimisée: ${file}`)
|
||||||
} else {
|
} else {
|
||||||
console.log(`Image déjà optimisée ou non optimisable: ${file}`);
|
console.log(`Image déjà optimisée ou non optimisable: ${file}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(`Erreur lors de l'optimisation de l'image ${file}:`, error)
|
||||||
`Erreur lors de l'optimisation de l'image ${file}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await optimizeImages(usedAssets);
|
await optimizeImages(usedAssets)
|
||||||
})();
|
})()
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y";
|
exports.GEMINI_API_KEY = 'AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y'
|
||||||
|
|
||||||
exports.SUNO_API_KEY = "c1636e04f606811511e19ec6e1545aa6"; // api key
|
exports.SUNO_API_KEY = 'c1636e04f606811511e19ec6e1545aa6' // api key
|
||||||
|
|
||||||
exports.RESEND_API_KEY = "re_NLcHmYiz_4LoFrzvPBbShNQBgNGTQmBbu";
|
exports.RESEND_API_KEY = 're_NLcHmYiz_4LoFrzvPBbShNQBgNGTQmBbu'
|
||||||
|
|
||||||
exports.STRIPE_SECRET_KEY =
|
exports.STRIPE_SECRET_KEY =
|
||||||
"sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu";
|
'sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu'
|
||||||
exports.STRIPE_WEBHOOK_SECRET = "whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW";
|
exports.STRIPE_WEBHOOK_SECRET = 'whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW'
|
||||||
exports.STRIPE_RETURN_URL = "";
|
exports.STRIPE_RETURN_URL = ''
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
exports.SUNO_API_BASE = "https://api.sunoapi.org";
|
exports.SUNO_API_BASE = 'https://api.sunoapi.org'
|
||||||
exports.SUNO_API_PATH = "/api/v1/generate";
|
exports.SUNO_API_PATH = '/api/v1/generate'
|
||||||
exports.SUNO_STATUS_PATH = "/api/v1/generate/record-info";
|
exports.SUNO_STATUS_PATH = '/api/v1/generate/record-info'
|
||||||
exports.SUNO_TIMESTAMPED_LYRICS_PATH =
|
exports.SUNO_TIMESTAMPED_LYRICS_PATH = '/api/v1/generate/get-timestamped-lyrics'
|
||||||
"/api/v1/generate/get-timestamped-lyrics";
|
exports.SUNO_MODEL = 'V5'
|
||||||
exports.SUNO_MODEL = "V5";
|
|
||||||
exports.SUNO_CALLBACK_URL =
|
exports.SUNO_CALLBACK_URL =
|
||||||
"https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback";
|
'https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
exports.BATCH_TYPE = {
|
exports.BATCH_TYPE = {
|
||||||
ADD: "ADD",
|
ADD: 'ADD',
|
||||||
UPDATE: "UPDATE",
|
UPDATE: 'UPDATE',
|
||||||
DELETE: "DELETE",
|
DELETE: 'DELETE',
|
||||||
};
|
}
|
||||||
|
|||||||
+16
-17
@@ -1,18 +1,17 @@
|
|||||||
const fs = require("fs");
|
const fs = require('fs')
|
||||||
const path = require("path");
|
const path = require('path')
|
||||||
|
|
||||||
const musicLandLogoBase64 = fs.readFileSync(
|
const musicLandLogoBase64 = fs.readFileSync(path.join(__dirname, '../assets/musicLandLogo.png'), {
|
||||||
path.join(__dirname, "../assets/musicLandLogo.png"),
|
encoding: 'base64',
|
||||||
{ encoding: "base64" },
|
})
|
||||||
);
|
const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`
|
||||||
const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`;
|
|
||||||
|
|
||||||
function basicTemplate({ title = "", content = "", button = null }) {
|
function basicTemplate({ title = '', content = '', button = null }) {
|
||||||
const btn = button?.url
|
const btn = button?.url
|
||||||
? `<p><a href="${button.url}" style="display:inline-block;padding:10px 16px;background:#6C5CE7;color:#fff;border-radius:8px;text-decoration:none">${
|
? `<p><a href="${button.url}" style="display:inline-block;padding:10px 16px;background:#6C5CE7;color:#fff;border-radius:8px;text-decoration:none">${
|
||||||
button?.label || "Ouvrir"
|
button?.label || 'Ouvrir'
|
||||||
}</a></p>`
|
}</a></p>`
|
||||||
: "";
|
: ''
|
||||||
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
|
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
@@ -50,12 +49,12 @@ function basicTemplate({ title = "", content = "", button = null }) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</body></html>`;
|
</body></html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function welcomeTemplate({ firstName = "", lastName = "" }) {
|
function welcomeTemplate({ firstName = '', lastName = '' }) {
|
||||||
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
|
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim()
|
||||||
const greeting = fullName ? `Salut ${fullName},` : "Salut,";
|
const greeting = fullName ? `Salut ${fullName},` : 'Salut,'
|
||||||
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
|
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
@@ -104,8 +103,8 @@ function welcomeTemplate({ firstName = "", lastName = "" }) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</body></html>`;
|
</body></html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.basicTemplate = basicTemplate;
|
exports.basicTemplate = basicTemplate
|
||||||
exports.welcomeTemplate = welcomeTemplate;
|
exports.welcomeTemplate = welcomeTemplate
|
||||||
|
|||||||
@@ -1,88 +1,82 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { BATCH_TYPE } = require("../config/types");
|
const { BATCH_TYPE } = require('../config/types')
|
||||||
|
|
||||||
async function deleteFolder(path) {
|
async function deleteFolder(path) {
|
||||||
try {
|
try {
|
||||||
console.log(`Deleting folder: ${path}`);
|
console.log(`Deleting folder: ${path}`)
|
||||||
const bucket = admin.storage().bucket();
|
const bucket = admin.storage().bucket()
|
||||||
await bucket.deleteFiles({ prefix: path, force: true });
|
await bucket.deleteFiles({ prefix: path, force: true })
|
||||||
console.log(`Folder ${path} deleted successfully`);
|
console.log(`Folder ${path} deleted successfully`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function batchFirestore({
|
async function batchFirestore({
|
||||||
path = "", // ADD only
|
path = '', // ADD only
|
||||||
docs = [], // not for ADD
|
docs = [], // not for ADD
|
||||||
data = {}, // not for DELETE
|
data = {}, // not for DELETE
|
||||||
type = BATCH_TYPE.UPDATE,
|
type = BATCH_TYPE.UPDATE,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
if (docs?.length === 0 && type !== BATCH_TYPE.ADD) {
|
if (docs?.length === 0 && type !== BATCH_TYPE.ADD) {
|
||||||
console.log(`Aucun document trouvé dans la collection ${path}.`);
|
console.log(`Aucun document trouvé dans la collection ${path}.`)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Préparer des lots pour les opérations
|
// Préparer des lots pour les opérations
|
||||||
const batches = [];
|
const batches = []
|
||||||
let currentBatch = admin.firestore().batch();
|
let currentBatch = admin.firestore().batch()
|
||||||
let operationCounter = 0;
|
let operationCounter = 0
|
||||||
|
|
||||||
docs.forEach((doc) => {
|
docs.forEach((doc) => {
|
||||||
const ref =
|
const ref =
|
||||||
doc?.ref ||
|
doc?.ref || (typeof doc?.path === 'string' ? admin.firestore().doc(doc.path) : null)
|
||||||
(typeof doc?.path === "string"
|
|
||||||
? admin.firestore().doc(doc.path)
|
|
||||||
: null);
|
|
||||||
const payload =
|
const payload =
|
||||||
doc && typeof doc.data === "object" && doc.data !== null && !Array.isArray(doc.data)
|
doc && typeof doc.data === 'object' && doc.data !== null && !Array.isArray(doc.data)
|
||||||
? doc.data
|
? doc.data
|
||||||
: data;
|
: data
|
||||||
|
|
||||||
if (type === BATCH_TYPE.ADD) {
|
if (type === BATCH_TYPE.ADD) {
|
||||||
// Pour ADD, créer un nouveau document avec ID automatique
|
// Pour ADD, créer un nouveau document avec ID automatique
|
||||||
const newDocRef = admin.firestore().collection(path).doc();
|
const newDocRef = admin.firestore().collection(path).doc()
|
||||||
currentBatch.set(newDocRef, data);
|
currentBatch.set(newDocRef, data)
|
||||||
} else if (type === BATCH_TYPE.UPDATE) {
|
} else if (type === BATCH_TYPE.UPDATE) {
|
||||||
if (!ref) {
|
if (!ref) {
|
||||||
throw new Error("batchFirestore UPDATE nécessite une référence de document.");
|
throw new Error('batchFirestore UPDATE nécessite une référence de document.')
|
||||||
}
|
}
|
||||||
if (!payload || Object.keys(payload).length === 0) {
|
if (!payload || Object.keys(payload).length === 0) {
|
||||||
throw new Error("batchFirestore UPDATE nécessite des données à écrire.");
|
throw new Error('batchFirestore UPDATE nécessite des données à écrire.')
|
||||||
}
|
}
|
||||||
currentBatch.set(ref, payload, { merge: true });
|
currentBatch.set(ref, payload, { merge: true })
|
||||||
} else if (type === BATCH_TYPE.DELETE) {
|
} else if (type === BATCH_TYPE.DELETE) {
|
||||||
if (!ref) {
|
if (!ref) {
|
||||||
throw new Error("batchFirestore DELETE nécessite une référence de document.");
|
throw new Error('batchFirestore DELETE nécessite une référence de document.')
|
||||||
}
|
}
|
||||||
currentBatch.delete(ref);
|
currentBatch.delete(ref)
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Opération non supportée : ${type}`);
|
throw new Error(`Opération non supportée : ${type}`)
|
||||||
}
|
}
|
||||||
operationCounter++;
|
operationCounter++
|
||||||
// Si le lot atteint la limite de 500 opérations, le sauvegarder et en créer un nouveau
|
// Si le lot atteint la limite de 500 opérations, le sauvegarder et en créer un nouveau
|
||||||
if (operationCounter === 500) {
|
if (operationCounter === 500) {
|
||||||
batches.push(currentBatch);
|
batches.push(currentBatch)
|
||||||
currentBatch = admin.firestore().batch();
|
currentBatch = admin.firestore().batch()
|
||||||
operationCounter = 0;
|
operationCounter = 0
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
// Ajouter le dernier lot si des opérations y sont présentes
|
// Ajouter le dernier lot si des opérations y sont présentes
|
||||||
if (operationCounter > 0) {
|
if (operationCounter > 0) {
|
||||||
batches.push(currentBatch);
|
batches.push(currentBatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exécuter tous les lots en parallèle
|
// Exécuter tous les lots en parallèle
|
||||||
await Promise.all(batches.map((batch) => batch.commit()));
|
await Promise.all(batches.map((batch) => batch.commit()))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(`Erreur lors des opérations ${type} pour la collection ${path} :`, error)
|
||||||
`Erreur lors des opérations ${type} pour la collection ${path} :`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.batchFirestore = batchFirestore;
|
exports.batchFirestore = batchFirestore
|
||||||
exports.deleteFolder = deleteFolder;
|
exports.deleteFolder = deleteFolder
|
||||||
|
|||||||
+99
-114
@@ -1,44 +1,42 @@
|
|||||||
const { googleAI } = require("@genkit-ai/googleai");
|
const { googleAI } = require('@genkit-ai/googleai')
|
||||||
const { genkit, z } = require("genkit");
|
const { genkit, z } = require('genkit')
|
||||||
const { GEMINI_API_KEY } = require("../config/keys");
|
const { GEMINI_API_KEY } = require('../config/keys')
|
||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { Buffer } = require("buffer");
|
const { Buffer } = require('buffer')
|
||||||
const { setTimeout } = require("timers/promises");
|
const { setTimeout } = require('timers/promises')
|
||||||
|
|
||||||
// --- CONFIGURATION ---
|
// --- CONFIGURATION ---
|
||||||
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API.
|
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API.
|
||||||
const TEXT_MODEL_NAME = "gemini-3-pro-preview";
|
const TEXT_MODEL_NAME = 'gemini-3-pro-preview'
|
||||||
const IMAGE_MODEL_NAME = "gemini-3-pro-image-preview";
|
const IMAGE_MODEL_NAME = 'gemini-3-pro-image-preview'
|
||||||
|
|
||||||
// --- SINGLETON PATTERN (WARM START) ---
|
// --- SINGLETON PATTERN (WARM START) ---
|
||||||
// On stocke l'instance en dehors de la fonction pour la réutiliser
|
// On stocke l'instance en dehors de la fonction pour la réutiliser
|
||||||
// entre les invocations si le conteneur est "chaud".
|
// entre les invocations si le conteneur est "chaud".
|
||||||
let aiInstance = null;
|
let aiInstance = null
|
||||||
|
|
||||||
const getAiInstance = () => {
|
const getAiInstance = () => {
|
||||||
if (!aiInstance) {
|
if (!aiInstance) {
|
||||||
console.log("⚡ [Gemini] Initialisation froide (Cold Start)");
|
console.log('⚡ [Gemini] Initialisation froide (Cold Start)')
|
||||||
aiInstance = genkit({
|
aiInstance = genkit({
|
||||||
plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
|
plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
return aiInstance;
|
return aiInstance
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Génération de texte générique
|
* Génération de texte générique
|
||||||
*/
|
*/
|
||||||
exports.generateAI = async ({ system = "", prompt = "", schema }) => {
|
exports.generateAI = async ({ system = '', prompt = '', schema }) => {
|
||||||
const ai = getAiInstance(); // Récupère l'instance singleton
|
const ai = getAiInstance() // Récupère l'instance singleton
|
||||||
|
|
||||||
if (prompt?.length < 1) {
|
if (prompt?.length < 1) {
|
||||||
throw new Error(
|
throw new Error('Vous devez spécifier un prompt pour effectuer cette action.')
|
||||||
"Vous devez spécifier un prompt pour effectuer cette action.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`);
|
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`)
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { output } = await ai.generate({
|
const { output } = await ai.generate({
|
||||||
@@ -49,76 +47,68 @@ exports.generateAI = async ({ system = "", prompt = "", schema }) => {
|
|||||||
config: {
|
config: {
|
||||||
temperature: 0.7, // Créativité équilibrée
|
temperature: 0.7, // Créativité équilibrée
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
return output;
|
return output
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ [generateAI] Error:", error.message);
|
console.error('❌ [generateAI] Error:', error.message)
|
||||||
throw error;
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`);
|
console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analyse la toxicité des paroles.
|
* Analyse la toxicité des paroles.
|
||||||
* Utilise gemini-1.5-flash-002 avec des réglages permissifs pour l'analyse.
|
* Utilise gemini-1.5-flash-002 avec des réglages permissifs pour l'analyse.
|
||||||
*/
|
*/
|
||||||
exports.analyseLyrics = async ({ title = "", lyrics }) => {
|
exports.analyseLyrics = async ({ title = '', lyrics }) => {
|
||||||
const ai = getAiInstance();
|
const ai = getAiInstance()
|
||||||
|
|
||||||
// --- Normalisation ---
|
// --- Normalisation ---
|
||||||
const normalizeLyrics = (raw) => {
|
const normalizeLyrics = (raw) => {
|
||||||
if (!raw) return "";
|
if (!raw) return ''
|
||||||
if (typeof raw === "string") return raw;
|
if (typeof raw === 'string') return raw
|
||||||
if (Array.isArray(raw)) {
|
if (Array.isArray(raw)) {
|
||||||
return raw
|
return raw
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
if (!s) return "";
|
if (!s) return ''
|
||||||
const label = s.type ? String(s.type).toUpperCase() : "SECTION";
|
const label = s.type ? String(s.type).toUpperCase() : 'SECTION'
|
||||||
return `[${label}]\n${s.lyrics || ""}`;
|
return `[${label}]\n${s.lyrics || ''}`
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join('\n\n')
|
||||||
}
|
}
|
||||||
if (typeof raw === "object") {
|
if (typeof raw === 'object') {
|
||||||
const parts = [];
|
const parts = []
|
||||||
if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`);
|
if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`)
|
||||||
if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`);
|
if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`)
|
||||||
return parts.join("\n\n");
|
return parts.join('\n\n')
|
||||||
|
}
|
||||||
|
return String(raw || '')
|
||||||
}
|
}
|
||||||
return String(raw || "");
|
|
||||||
};
|
|
||||||
|
|
||||||
const lyricsText = normalizeLyrics(lyrics).trim();
|
const lyricsText = normalizeLyrics(lyrics).trim()
|
||||||
if (!lyricsText) throw new Error("analyseLyrics: paroles requises.");
|
if (!lyricsText) throw new Error('analyseLyrics: paroles requises.')
|
||||||
|
|
||||||
// --- Schéma ---
|
// --- Schéma ---
|
||||||
const moderationSchema = z.object({
|
const moderationSchema = z.object({
|
||||||
title: z.string().describe("Titre analysé"),
|
title: z.string().describe('Titre analysé'),
|
||||||
flagged: z
|
flagged: z.boolean().describe('Vrai si le contenu nécessite un avertissement.'),
|
||||||
.boolean()
|
blocked: z.boolean().describe('Vrai UNIQUEMENT si violation grave (Haine, Violence réelle).'),
|
||||||
.describe("Vrai si le contenu nécessite un avertissement."),
|
score: z.number().min(0).max(1).describe('Score de risque (0=Sûr, 1=Dangereux).'),
|
||||||
blocked: z
|
reasons: z.array(z.string()).describe('Liste concise des raisons.'),
|
||||||
.boolean()
|
|
||||||
.describe("Vrai UNIQUEMENT si violation grave (Haine, Violence réelle)."),
|
|
||||||
score: z
|
|
||||||
.number()
|
|
||||||
.min(0)
|
|
||||||
.max(1)
|
|
||||||
.describe("Score de risque (0=Sûr, 1=Dangereux)."),
|
|
||||||
reasons: z.array(z.string()).describe("Liste concise des raisons."),
|
|
||||||
excerpts: z
|
excerpts: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
quote: z.string(),
|
quote: z.string(),
|
||||||
category: z.string(),
|
category: z.string(),
|
||||||
severity: z.string(),
|
severity: z.string(),
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
.max(10),
|
.max(10),
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
});
|
})
|
||||||
|
|
||||||
// --- Prompt ---
|
// --- Prompt ---
|
||||||
const system = `Tu es un Expert en Modération de Contenu Musical (Trust & Safety).
|
const system = `Tu es un Expert en Modération de Contenu Musical (Trust & Safety).
|
||||||
@@ -127,20 +117,20 @@ TA MISSION : Distinguer l'expression artistique (même crue/vulgaire) du contenu
|
|||||||
1. "FLAGGED" (Avertissement) : Vulgarités, thèmes matures, drogue, sexe consensuel.
|
1. "FLAGGED" (Avertissement) : Vulgarités, thèmes matures, drogue, sexe consensuel.
|
||||||
2. "BLOCKED" (Interdit) : Discours de haine, harcèlement ciblé, pédopornographie, incitation explicite violence/suicide.
|
2. "BLOCKED" (Interdit) : Discours de haine, harcèlement ciblé, pédopornographie, incitation explicite violence/suicide.
|
||||||
|
|
||||||
Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.`;
|
Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.`
|
||||||
|
|
||||||
const userPrompt = `
|
const userPrompt = `
|
||||||
ANALYSE CETTE CHANSON :
|
ANALYSE CETTE CHANSON :
|
||||||
Titre : ${title || "Inconnu"}
|
Titre : ${title || 'Inconnu'}
|
||||||
|
|
||||||
PAROLES :
|
PAROLES :
|
||||||
"""
|
"""
|
||||||
${lyricsText}
|
${lyricsText}
|
||||||
"""
|
"""
|
||||||
`;
|
`
|
||||||
|
|
||||||
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`);
|
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`)
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { output } = await ai.generate({
|
const { output } = await ai.generate({
|
||||||
@@ -151,108 +141,103 @@ ${lyricsText}
|
|||||||
config: {
|
config: {
|
||||||
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
||||||
safetySettings: [
|
safetySettings: [
|
||||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" },
|
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
|
||||||
{
|
{
|
||||||
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||||
threshold: "BLOCK_NONE",
|
threshold: 'BLOCK_NONE',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||||
threshold: "BLOCK_NONE",
|
threshold: 'BLOCK_NONE',
|
||||||
},
|
},
|
||||||
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" },
|
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!output) throw new Error("Échec de l'analyse de modération.");
|
if (!output) throw new Error("Échec de l'analyse de modération.")
|
||||||
|
|
||||||
// Correction de cohérence
|
// Correction de cohérence
|
||||||
if (output.blocked) {
|
if (output.blocked) {
|
||||||
output.flagged = true;
|
output.flagged = true
|
||||||
if (output.score < 0.7) output.score = 0.85;
|
if (output.score < 0.7) output.score = 0.85
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
return output
|
||||||
} finally {
|
} finally {
|
||||||
console.log(`⏱️ [analyseLyrics] Durée: ${Date.now() - startedAt}ms`);
|
console.log(`⏱️ [analyseLyrics] Durée: ${Date.now() - startedAt}ms`)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Génération d'image via Imagen 3
|
* Génération d'image via Imagen 3
|
||||||
*/
|
*/
|
||||||
exports.generateImageV2 = async (prompt, size = 1024, path = "") => {
|
exports.generateImageV2 = async (prompt, size = 1024, path = '') => {
|
||||||
const ai = getAiInstance();
|
const ai = getAiInstance()
|
||||||
|
|
||||||
if (typeof prompt !== "string" || prompt.trim().length < 1) {
|
if (typeof prompt !== 'string' || prompt.trim().length < 1) {
|
||||||
throw new Error("Prompt requis.");
|
throw new Error('Prompt requis.')
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`);
|
console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`)
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now()
|
||||||
|
|
||||||
// Optimisation du prompt pour Imagen
|
// Optimisation du prompt pour Imagen
|
||||||
let enhancedPrompt = prompt.trim();
|
let enhancedPrompt = prompt.trim()
|
||||||
if (!enhancedPrompt.toLowerCase().includes("high quality")) {
|
if (!enhancedPrompt.toLowerCase().includes('high quality')) {
|
||||||
enhancedPrompt += ", high quality, detailed, 4k";
|
enhancedPrompt += ', high quality, detailed, 4k'
|
||||||
}
|
}
|
||||||
// Aspect ratio 1:1 pour les pochettes
|
// Aspect ratio 1:1 pour les pochettes
|
||||||
enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`;
|
enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`
|
||||||
|
|
||||||
const maxAttempts = 3;
|
const maxAttempts = 3
|
||||||
let lastError = null;
|
let lastError = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
console.log(`🔄 Tentative ${attempt}/${maxAttempts}`);
|
console.log(`🔄 Tentative ${attempt}/${maxAttempts}`)
|
||||||
|
|
||||||
const response = await ai.generate({
|
const response = await ai.generate({
|
||||||
model: googleAI.model(IMAGE_MODEL_NAME),
|
model: googleAI.model(IMAGE_MODEL_NAME),
|
||||||
prompt: enhancedPrompt,
|
prompt: enhancedPrompt,
|
||||||
});
|
})
|
||||||
|
|
||||||
const media = response.media;
|
const media = response.media
|
||||||
|
|
||||||
if (media && media.url) {
|
if (media && media.url) {
|
||||||
console.log("✅ Image générée.");
|
console.log('✅ Image générée.')
|
||||||
|
|
||||||
// --- Sauvegarde dans Firebase Storage ---
|
// --- Sauvegarde dans Firebase Storage ---
|
||||||
const dataUrl = String(media.url);
|
const dataUrl = String(media.url)
|
||||||
const commaIdx = dataUrl.indexOf(",");
|
const commaIdx = dataUrl.indexOf(',')
|
||||||
const b64 =
|
const b64 = commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl
|
||||||
commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl;
|
const buffer = Buffer.from(b64, 'base64')
|
||||||
const buffer = Buffer.from(b64, "base64");
|
|
||||||
|
|
||||||
const bucket = admin.storage().bucket();
|
const bucket = admin.storage().bucket()
|
||||||
const token = require("crypto").randomUUID();
|
const token = require('crypto').randomUUID()
|
||||||
const file = bucket.file(path);
|
const file = bucket.file(path)
|
||||||
|
|
||||||
await file.save(buffer, {
|
await file.save(buffer, {
|
||||||
resumable: false,
|
resumable: false,
|
||||||
metadata: {
|
metadata: {
|
||||||
contentType: media.contentType || "image/png",
|
contentType: media.contentType || 'image/png',
|
||||||
metadata: { firebaseStorageDownloadTokens: token },
|
metadata: { firebaseStorageDownloadTokens: token },
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`;
|
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Pas de média dans la réponse IA.");
|
throw new Error('Pas de média dans la réponse IA.')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message);
|
console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message)
|
||||||
lastError = err;
|
lastError = err
|
||||||
if (attempt < maxAttempts) await setTimeout(2000 * attempt);
|
if (attempt < maxAttempts) await setTimeout(2000 * attempt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`)
|
||||||
`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
console.log(
|
console.log(`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`)
|
||||||
`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,137 +1,115 @@
|
|||||||
exports.generatePicturePrompt = (project = {}) => {
|
exports.generatePicturePrompt = (project = {}) => {
|
||||||
const {
|
const {
|
||||||
title = "",
|
title = '',
|
||||||
lyrics: lyricsRaw,
|
lyrics: lyricsRaw,
|
||||||
musicConfig = {},
|
musicConfig = {},
|
||||||
coverStyle: coverStyleRaw = "",
|
coverStyle: coverStyleRaw = '',
|
||||||
artistName: artistNameRaw = "",
|
artistName: artistNameRaw = '',
|
||||||
} = project || {};
|
} = project || {}
|
||||||
|
|
||||||
// --- 1. Nettoyage et Normalisation ---
|
// --- 1. Nettoyage et Normalisation ---
|
||||||
const sanitizeInline = (value = "") => {
|
const sanitizeInline = (value = '') => {
|
||||||
if (typeof value !== "string") return "";
|
if (typeof value !== 'string') return ''
|
||||||
return value
|
return value
|
||||||
.replace(/[\r\n]+/g, " ")
|
.replace(/[\r\n]+/g, ' ')
|
||||||
.replace(/[<>]/g, "")
|
.replace(/[<>]/g, '')
|
||||||
.trim();
|
.trim()
|
||||||
};
|
}
|
||||||
|
|
||||||
const titleForPrompt = sanitizeInline(title) || "Sans titre";
|
const titleForPrompt = sanitizeInline(title) || 'Sans titre'
|
||||||
const artistName =
|
const artistName = sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || '')
|
||||||
sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || "");
|
const hasArtistName = artistName.length > 0
|
||||||
const hasArtistName = artistName.length > 0;
|
|
||||||
|
|
||||||
const {
|
const { genres = [], tempo = '', mood = '', instruments = [] } = musicConfig || {}
|
||||||
genres = [],
|
const userStyle = sanitizeInline(coverStyleRaw)
|
||||||
tempo = "",
|
|
||||||
mood = "",
|
|
||||||
instruments = [],
|
|
||||||
} = musicConfig || {};
|
|
||||||
const userStyle = sanitizeInline(coverStyleRaw);
|
|
||||||
|
|
||||||
// --- 2. Intelligence Visuelle (Mapping) ---
|
// --- 2. Intelligence Visuelle (Mapping) ---
|
||||||
|
|
||||||
// Détermination de l'énergie visuelle
|
// Détermination de l'énergie visuelle
|
||||||
const isEnergetic =
|
const isEnergetic =
|
||||||
tempo &&
|
tempo && /\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(String(tempo))
|
||||||
/\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(
|
|
||||||
String(tempo),
|
|
||||||
);
|
|
||||||
const isDark =
|
const isDark =
|
||||||
mood &&
|
mood && /\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(String(mood))
|
||||||
/\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(
|
|
||||||
String(mood),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Construction de la Palette & Lumière
|
// Construction de la Palette & Lumière
|
||||||
let visualAtmosphere = "";
|
let visualAtmosphere = ''
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
visualAtmosphere =
|
visualAtmosphere =
|
||||||
"Atmosphere: Cinematic atmosphere, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents.";
|
'Atmosphere: Cinematic atmosphere, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents.'
|
||||||
} else if (isEnergetic) {
|
} else if (isEnergetic) {
|
||||||
visualAtmosphere =
|
visualAtmosphere =
|
||||||
"Atmosphere: Dynamic atmosphere, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast.";
|
'Atmosphere: Dynamic atmosphere, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast.'
|
||||||
} else {
|
} else {
|
||||||
visualAtmosphere =
|
visualAtmosphere =
|
||||||
"Atmosphere: Soft atmosphere, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors.";
|
'Atmosphere: Soft atmosphere, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors.'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Définition du Style de Rendu (Si l'utilisateur est vague, on renforce)
|
// Définition du Style de Rendu (Si l'utilisateur est vague, on renforce)
|
||||||
let renderingStyle = userStyle
|
let renderingStyle = userStyle ? `Art Style: ${userStyle}` : 'Art Style: Digital Art, Mixed Media'
|
||||||
? `Art Style: ${userStyle}`
|
|
||||||
: "Art Style: Digital Art, Mixed Media";
|
|
||||||
|
|
||||||
if (
|
if (userStyle.toLowerCase().includes('realist') || userStyle.toLowerCase().includes('photo')) {
|
||||||
userStyle.toLowerCase().includes("realist") ||
|
|
||||||
userStyle.toLowerCase().includes("photo")
|
|
||||||
) {
|
|
||||||
renderingStyle +=
|
renderingStyle +=
|
||||||
", 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing.";
|
', 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing.'
|
||||||
} else if (
|
} else if (
|
||||||
userStyle.toLowerCase().includes("illu") ||
|
userStyle.toLowerCase().includes('illu') ||
|
||||||
userStyle.toLowerCase().includes("dessin")
|
userStyle.toLowerCase().includes('dessin')
|
||||||
) {
|
) {
|
||||||
renderingStyle +=
|
renderingStyle +=
|
||||||
", vector art, clean lines, professional illustration, flat design or detailed painting.";
|
', vector art, clean lines, professional illustration, flat design or detailed painting.'
|
||||||
} else {
|
} else {
|
||||||
// Style par défaut "Album Cover" qui marche bien
|
// Style par défaut "Album Cover" qui marche bien
|
||||||
renderingStyle +=
|
renderingStyle += ', abstract surrealism, conceptual album art, high fidelity, masterpiece.'
|
||||||
", abstract surrealism, conceptual album art, high fidelity, masterpiece.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 3. Extraction de l'Inspiration (Lyrics) ---
|
// --- 3. Extraction de l'Inspiration (Lyrics) ---
|
||||||
|
|
||||||
// On cherche le REFRAIN en priorité pour l'image, car c'est le cœur visuel
|
// On cherche le REFRAIN en priorité pour l'image, car c'est le cœur visuel
|
||||||
const sections = Array.isArray(lyricsRaw) ? lyricsRaw : [];
|
const sections = Array.isArray(lyricsRaw) ? lyricsRaw : []
|
||||||
const chorus = sections.find(
|
const chorus = sections.find((s) => s.type === 'refrain' || s.type === 'chorus')
|
||||||
(s) => s.type === "refrain" || s.type === "chorus",
|
const verse = sections.find((s) => s.type === 'couplet' || s.type === 'verse')
|
||||||
);
|
|
||||||
const verse = sections.find(
|
|
||||||
(s) => s.type === "couplet" || s.type === "verse",
|
|
||||||
);
|
|
||||||
|
|
||||||
// On prend 2 lignes max du refrain, ou du premier couplet
|
// On prend 2 lignes max du refrain, ou du premier couplet
|
||||||
const visualHook = (chorus?.lyrics || verse?.lyrics || "")
|
const visualHook = (chorus?.lyrics || verse?.lyrics || '')
|
||||||
.split("\n")
|
.split('\n')
|
||||||
.filter((l) => l.length > 10) // On évite les lignes trop courtes
|
.filter((l) => l.length > 10) // On évite les lignes trop courtes
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.join(". ");
|
.join('. ')
|
||||||
|
|
||||||
const imageryPrompt = visualHook
|
const imageryPrompt = visualHook
|
||||||
? `Visual Inspiration: An interpretation of these lyrics: "${visualHook}".`
|
? `Visual Inspiration: An interpretation of these lyrics: "${visualHook}".`
|
||||||
: "Visual Inspiration: Abstract visual representation of the song's mood.";
|
: "Visual Inspiration: Abstract visual representation of the song's mood."
|
||||||
|
|
||||||
// --- 4. Construction du Prompt Final (Structure Optimisée Imagen 3) ---
|
// --- 4. Construction du Prompt Final (Structure Optimisée Imagen 3) ---
|
||||||
|
|
||||||
const promptParts = [
|
const promptParts = [
|
||||||
// Rôle
|
// Rôle
|
||||||
"Design a professional, high-quality music album cover.",
|
'Design a professional, high-quality music album cover.',
|
||||||
|
|
||||||
// 1. Le Texte (Crucial pour Imagen 3 - Doit être au début ou très clair)
|
// 1. Le Texte (Crucial pour Imagen 3 - Doit être au début ou très clair)
|
||||||
`**Typography & Text:**`,
|
`**Typography & Text:**`,
|
||||||
`The song title "${titleForPrompt}" must be the CENTERPIECE. Write it in a distinct font that matches the mood.`,
|
`The song title "${titleForPrompt}" must be the CENTERPIECE. Write it in a distinct font that matches the mood.`,
|
||||||
hasArtistName
|
hasArtistName
|
||||||
? `The artist name "${artistName}" must appear smaller, elegant, and legible near the bottom or top.`
|
? `The artist name "${artistName}" must appear smaller, elegant, and legible near the bottom or top.`
|
||||||
: "",
|
: '',
|
||||||
"Ensure perfect spelling. The text should be integrated into the artwork (e.g., metallic texture, neon glow, or bold cut-out), not just pasted on top.",
|
'Ensure perfect spelling. The text should be integrated into the artwork (e.g., metallic texture, neon glow, or bold cut-out), not just pasted on top.',
|
||||||
|
|
||||||
// 2. Le Visuel
|
// 2. Le Visuel
|
||||||
`**Visuals:**`,
|
`**Visuals:**`,
|
||||||
renderingStyle,
|
renderingStyle,
|
||||||
imageryPrompt,
|
imageryPrompt,
|
||||||
`Subject: A central visual element that represents the song. ${isEnergetic ? "Dynamic composition." : "Balanced, centered composition."}`,
|
`Subject: A central visual element that represents the song. ${isEnergetic ? 'Dynamic composition.' : 'Balanced, centered composition.'}`,
|
||||||
|
|
||||||
// 3. L'Atmosphère (Context from music config)
|
// 3. L'Atmosphère (Context from music config)
|
||||||
`**Mood & Color:**`,
|
`**Mood & Color:**`,
|
||||||
visualAtmosphere,
|
visualAtmosphere,
|
||||||
genres.length > 0 ? `Musical Vibe Reference: ${genres.join(", ")}.` : "",
|
genres.length > 0 ? `Musical Vibe Reference: ${genres.join(', ')}.` : '',
|
||||||
|
|
||||||
// 4. Contraintes Négatives (Phrasées positivement pour l'IA)
|
// 4. Contraintes Négatives (Phrasées positivement pour l'IA)
|
||||||
"**Constraints:**",
|
'**Constraints:**',
|
||||||
"Use a square 1:1 aspect ratio.",
|
'Use a square 1:1 aspect ratio.',
|
||||||
"Do NOT depict literal musical instruments (like guitars or microphones) unless they are part of a surreal abstract composition.",
|
'Do NOT depict literal musical instruments (like guitars or microphones) unless they are part of a surreal abstract composition.',
|
||||||
"No blurry text. No messy borders. No watermarks.",
|
'No blurry text. No messy borders. No watermarks.',
|
||||||
];
|
]
|
||||||
|
|
||||||
return promptParts.filter(Boolean).join("\n\n");
|
return promptParts.filter(Boolean).join('\n\n')
|
||||||
};
|
}
|
||||||
|
|||||||
+19
-19
@@ -1,26 +1,26 @@
|
|||||||
const normalizeDate = (input) =>
|
const normalizeDate = (input) =>
|
||||||
typeof input?.toDate === "function" ? input.toDate() : new Date(input);
|
typeof input?.toDate === 'function' ? input.toDate() : new Date(input)
|
||||||
|
|
||||||
const buildMonthKey = (timestamp) => {
|
const buildMonthKey = (timestamp) => {
|
||||||
const date = normalizeDate(timestamp);
|
const date = normalizeDate(timestamp)
|
||||||
const year = date.getUTCFullYear();
|
const year = date.getUTCFullYear()
|
||||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||||
return `${year}-${month}`;
|
return `${year}-${month}`
|
||||||
};
|
}
|
||||||
|
|
||||||
const buildPreviousMonthContext = (referenceDate) => {
|
const buildPreviousMonthContext = (referenceDate) => {
|
||||||
const current = referenceDate ? new Date(referenceDate) : new Date();
|
const current = referenceDate ? new Date(referenceDate) : new Date()
|
||||||
current.setUTCHours(0, 0, 0, 0);
|
current.setUTCHours(0, 0, 0, 0)
|
||||||
current.setUTCDate(1);
|
current.setUTCDate(1)
|
||||||
|
|
||||||
const target = new Date(current);
|
const target = new Date(current)
|
||||||
target.setUTCMonth(target.getUTCMonth() - 1);
|
target.setUTCMonth(target.getUTCMonth() - 1)
|
||||||
|
|
||||||
const year = target.getUTCFullYear();
|
const year = target.getUTCFullYear()
|
||||||
const monthIndex = target.getUTCMonth();
|
const monthIndex = target.getUTCMonth()
|
||||||
const rangeStart = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0));
|
const rangeStart = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0))
|
||||||
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999));
|
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999))
|
||||||
const monthKey = buildMonthKey(rangeStart);
|
const monthKey = buildMonthKey(rangeStart)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
year,
|
year,
|
||||||
@@ -28,10 +28,10 @@ const buildPreviousMonthContext = (referenceDate) => {
|
|||||||
monthKey,
|
monthKey,
|
||||||
rangeStart,
|
rangeStart,
|
||||||
rangeEnd,
|
rangeEnd,
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
buildMonthKey,
|
buildMonthKey,
|
||||||
buildPreviousMonthContext,
|
buildPreviousMonthContext,
|
||||||
};
|
}
|
||||||
|
|||||||
+189
-230
@@ -1,281 +1,263 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { HttpsError } = require("firebase-functions/https");
|
const { HttpsError } = require('firebase-functions/https')
|
||||||
const Stripe = require("stripe");
|
const Stripe = require('stripe')
|
||||||
const { URL } = require("url");
|
const { URL } = require('url')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
STRIPE_SECRET_KEY = "",
|
STRIPE_SECRET_KEY = '',
|
||||||
STRIPE_RETURN_URL = "",
|
STRIPE_RETURN_URL = '',
|
||||||
STRIPE_PORTAL_CONFIGURATION = "",
|
STRIPE_PORTAL_CONFIGURATION = '',
|
||||||
STRIPE_MODE: CONFIG_STRIPE_MODE,
|
STRIPE_MODE: CONFIG_STRIPE_MODE,
|
||||||
} = require("../config/keys");
|
} = require('../config/keys')
|
||||||
|
|
||||||
const STRIPE_MODE =
|
const STRIPE_MODE =
|
||||||
typeof CONFIG_STRIPE_MODE === "string" && CONFIG_STRIPE_MODE.trim()
|
typeof CONFIG_STRIPE_MODE === 'string' && CONFIG_STRIPE_MODE.trim()
|
||||||
? CONFIG_STRIPE_MODE.trim()
|
? CONFIG_STRIPE_MODE.trim()
|
||||||
: "test";
|
: 'test'
|
||||||
|
|
||||||
const requireEnv = (key) => {
|
const requireEnv = (key) => {
|
||||||
const value = process.env?.[key];
|
const value = process.env?.[key]
|
||||||
if (typeof value === "string" && value.trim()) {
|
if (typeof value === 'string' && value.trim()) {
|
||||||
return value.trim();
|
return value.trim()
|
||||||
}
|
}
|
||||||
throw new Error(`${key} not configured`);
|
throw new Error(`${key} not configured`)
|
||||||
};
|
}
|
||||||
|
|
||||||
const STRIPE_API_VERSION = "2023-10-16";
|
const STRIPE_API_VERSION = '2023-10-16'
|
||||||
const DEFAULT_TEST_RETURN_URL = "http://localhost:8081";
|
const DEFAULT_TEST_RETURN_URL = 'http://localhost:8081'
|
||||||
const ALLOWED_RETURN_SCHEMES = ["http", "https", "minuit"];
|
const ALLOWED_RETURN_SCHEMES = ['http', 'https', 'minuit']
|
||||||
|
|
||||||
let cachedStripeClient = null;
|
let cachedStripeClient = null
|
||||||
let cachedPortalConfigurationId = null;
|
let cachedPortalConfigurationId = null
|
||||||
|
|
||||||
const resolveStripeSecretKey = () => {
|
const resolveStripeSecretKey = () => {
|
||||||
const inlineKey =
|
const inlineKey = typeof STRIPE_SECRET_KEY === 'string' ? STRIPE_SECRET_KEY.trim() : ''
|
||||||
typeof STRIPE_SECRET_KEY === "string" ? STRIPE_SECRET_KEY.trim() : "";
|
|
||||||
|
|
||||||
if (inlineKey) {
|
if (inlineKey) {
|
||||||
return inlineKey;
|
return inlineKey
|
||||||
}
|
}
|
||||||
|
|
||||||
const required = requireEnv("STRIPE_SECRET_KEY");
|
const required = requireEnv('STRIPE_SECRET_KEY')
|
||||||
if (typeof required === "string" && required.trim()) {
|
if (typeof required === 'string' && required.trim()) {
|
||||||
return required.trim();
|
return required.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("STRIPE_SECRET_KEY not configured");
|
throw new Error('STRIPE_SECRET_KEY not configured')
|
||||||
};
|
}
|
||||||
|
|
||||||
const getStripeClient = () => {
|
const getStripeClient = () => {
|
||||||
if (cachedStripeClient) {
|
if (cachedStripeClient) {
|
||||||
return cachedStripeClient;
|
return cachedStripeClient
|
||||||
}
|
}
|
||||||
|
|
||||||
let secretKey;
|
let secretKey
|
||||||
try {
|
try {
|
||||||
secretKey = resolveStripeSecretKey();
|
secretKey = resolveStripeSecretKey()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[getStripeClient] Missing STRIPE_SECRET_KEY", error);
|
console.error('[getStripeClient] Missing STRIPE_SECRET_KEY', error)
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"failed-precondition",
|
'failed-precondition',
|
||||||
"Stripe n’est pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.",
|
'Stripe n’est pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedStripeClient = new Stripe(secretKey, {
|
cachedStripeClient = new Stripe(secretKey, {
|
||||||
apiVersion: STRIPE_API_VERSION,
|
apiVersion: STRIPE_API_VERSION,
|
||||||
});
|
})
|
||||||
|
|
||||||
return cachedStripeClient;
|
return cachedStripeClient
|
||||||
};
|
}
|
||||||
|
|
||||||
const getReturnBaseUrl = () => {
|
const getReturnBaseUrl = () => {
|
||||||
const resolveBase = () => {
|
const resolveBase = () => {
|
||||||
if (STRIPE_RETURN_URL) {
|
if (STRIPE_RETURN_URL) {
|
||||||
return STRIPE_RETURN_URL;
|
return STRIPE_RETURN_URL
|
||||||
}
|
}
|
||||||
if (STRIPE_MODE !== "prod") {
|
if (STRIPE_MODE !== 'prod') {
|
||||||
return DEFAULT_TEST_RETURN_URL;
|
return DEFAULT_TEST_RETURN_URL
|
||||||
|
}
|
||||||
|
return requireEnv('STRIPE_RETURN_URL')
|
||||||
}
|
}
|
||||||
return requireEnv("STRIPE_RETURN_URL");
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawBase = resolveBase();
|
const rawBase = resolveBase()
|
||||||
const sanitizedBase = typeof rawBase === "string" ? rawBase.trim() : "";
|
const sanitizedBase = typeof rawBase === 'string' ? rawBase.trim() : ''
|
||||||
if (!sanitizedBase) {
|
if (!sanitizedBase) {
|
||||||
if (STRIPE_MODE !== "prod") {
|
if (STRIPE_MODE !== 'prod') {
|
||||||
return DEFAULT_TEST_RETURN_URL;
|
return DEFAULT_TEST_RETURN_URL
|
||||||
}
|
}
|
||||||
throw new Error("STRIPE_RETURN_URL not configured");
|
throw new Error('STRIPE_RETURN_URL not configured')
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitizedBase.endsWith("/")
|
return sanitizedBase.endsWith('/') ? sanitizedBase.slice(0, -1) : sanitizedBase
|
||||||
? sanitizedBase.slice(0, -1)
|
}
|
||||||
: sanitizedBase;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeReturnUrl = (value) => {
|
const sanitizeReturnUrl = (value) => {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== 'string') {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim()
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateScheme = (scheme) => {
|
const validateScheme = (scheme) => {
|
||||||
if (!ALLOWED_RETURN_SCHEMES.includes(scheme)) {
|
if (!ALLOWED_RETURN_SCHEMES.includes(scheme)) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
`Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`,
|
`Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`
|
||||||
);
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsedUrl = new URL(trimmed);
|
const parsedUrl = new URL(trimmed)
|
||||||
const scheme = parsedUrl.protocol.replace(":", "").toLowerCase();
|
const scheme = parsedUrl.protocol.replace(':', '').toLowerCase()
|
||||||
validateScheme(scheme);
|
validateScheme(scheme)
|
||||||
return trimmed;
|
return trimmed
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i);
|
const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i)
|
||||||
if (schemeMatch && schemeMatch[1]) {
|
if (schemeMatch && schemeMatch[1]) {
|
||||||
const scheme = schemeMatch[1].toLowerCase();
|
const scheme = schemeMatch[1].toLowerCase()
|
||||||
validateScheme(scheme);
|
validateScheme(scheme)
|
||||||
return trimmed;
|
return trimmed
|
||||||
}
|
}
|
||||||
throw new HttpsError(
|
throw new HttpsError('invalid-argument', `URL de retour Stripe invalide: ${trimmed}`)
|
||||||
"invalid-argument",
|
|
||||||
`URL de retour Stripe invalide: ${trimmed}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const getReturnUrls = (overrides) => {
|
const getReturnUrls = (overrides) => {
|
||||||
if (overrides && typeof overrides === "object") {
|
if (overrides && typeof overrides === 'object') {
|
||||||
const successOverride = sanitizeReturnUrl(overrides.successUrl);
|
const successOverride = sanitizeReturnUrl(overrides.successUrl)
|
||||||
const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl);
|
const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl)
|
||||||
|
|
||||||
if (!successOverride) {
|
if (!successOverride) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
"successUrl est requis pour configurer les retours Stripe.",
|
'successUrl est requis pour configurer les retours Stripe.'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
successUrl: successOverride,
|
successUrl: successOverride,
|
||||||
cancelUrl: cancelOverride || successOverride,
|
cancelUrl: cancelOverride || successOverride,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = getReturnBaseUrl();
|
const baseUrl = getReturnBaseUrl()
|
||||||
|
|
||||||
const joinPath = (url, path) => {
|
const joinPath = (url, path) => {
|
||||||
const trimmedUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
const trimmedUrl = url.endsWith('/') ? url.slice(0, -1) : url
|
||||||
const trimmedPath = path.startsWith("/") ? path.slice(1) : path;
|
const trimmedPath = path.startsWith('/') ? path.slice(1) : path
|
||||||
return `${trimmedUrl}/${trimmedPath}`;
|
return `${trimmedUrl}/${trimmedPath}`
|
||||||
};
|
}
|
||||||
|
|
||||||
const appendQuery = (url, query) =>
|
const appendQuery = (url, query) => (url.includes('?') ? `${url}&${query}` : `${url}?${query}`)
|
||||||
url.includes("?") ? `${url}&${query}` : `${url}?${query}`;
|
|
||||||
|
|
||||||
const successBase = joinPath(baseUrl, "payment-success");
|
const successBase = joinPath(baseUrl, 'payment-success')
|
||||||
const cancelBase = joinPath(baseUrl, "payment-error");
|
const cancelBase = joinPath(baseUrl, 'payment-error')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
successUrl: appendQuery(successBase, "session_id={CHECKOUT_SESSION_ID}"),
|
successUrl: appendQuery(successBase, 'session_id={CHECKOUT_SESSION_ID}'),
|
||||||
cancelUrl: appendQuery(cancelBase, "session_id={CHECKOUT_SESSION_ID}"),
|
cancelUrl: appendQuery(cancelBase, 'session_id={CHECKOUT_SESSION_ID}'),
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const normalizeBoolean = (value) => value === true;
|
const normalizeBoolean = (value) => value === true
|
||||||
|
|
||||||
const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
|
const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
|
||||||
if (!Array.isArray(productList) || productList.length === 0) {
|
if (!Array.isArray(productList) || productList.length === 0) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
"Au moins un produit est requis pour créer une session de paiement.",
|
'Au moins un produit est requis pour créer une session de paiement.'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineItems = [];
|
const lineItems = []
|
||||||
const summary = [];
|
const summary = []
|
||||||
let hasSubscription = false;
|
let hasSubscription = false
|
||||||
|
|
||||||
for (let index = 0; index < productList.length; index += 1) {
|
for (let index = 0; index < productList.length; index += 1) {
|
||||||
const rawItem = productList[index];
|
const rawItem = productList[index]
|
||||||
const item = rawItem && typeof rawItem === "object" ? rawItem : {};
|
const item = rawItem && typeof rawItem === 'object' ? rawItem : {}
|
||||||
|
|
||||||
const isRenewable = normalizeBoolean(item.isRenewable);
|
const isRenewable = normalizeBoolean(item.isRenewable)
|
||||||
const rawQuantity =
|
const rawQuantity =
|
||||||
typeof item.quantity === "number" && Number.isFinite(item.quantity)
|
typeof item.quantity === 'number' && Number.isFinite(item.quantity)
|
||||||
? item.quantity
|
? item.quantity
|
||||||
: parseInt(item.quantity, 10);
|
: parseInt(item.quantity, 10)
|
||||||
const quantity =
|
const quantity = Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1
|
||||||
Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1;
|
|
||||||
|
|
||||||
const priceId = typeof item.priceID === "string" ? item.priceID.trim() : "";
|
const priceId = typeof item.priceID === 'string' ? item.priceID.trim() : ''
|
||||||
|
|
||||||
if (isRenewable && !priceId) {
|
if (isRenewable && !priceId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
`Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`,
|
`Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (priceId) {
|
if (priceId) {
|
||||||
let stripePrice = null;
|
let stripePrice = null
|
||||||
if (stripe) {
|
if (stripe) {
|
||||||
try {
|
try {
|
||||||
stripePrice = await stripe.prices.retrieve(priceId);
|
stripePrice = await stripe.prices.retrieve(priceId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error('[buildCheckoutLineItems] Unable to retrieve price', priceId, error)
|
||||||
"[buildCheckoutLineItems] Unable to retrieve price",
|
|
||||||
priceId,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
`Le price ID "${priceId}" est introuvable (élément ${index + 1}).`,
|
`Le price ID "${priceId}" est introuvable (élément ${index + 1}).`
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceIsRecurring =
|
const priceIsRecurring = stripePrice?.type === 'recurring' || !!stripePrice?.recurring
|
||||||
stripePrice?.type === "recurring" || !!stripePrice?.recurring;
|
|
||||||
if (isRenewable && !priceIsRecurring) {
|
if (isRenewable && !priceIsRecurring) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
`Le price ID "${priceId}" n'est pas compatible avec un abonnement.`,
|
`Le price ID "${priceId}" n'est pas compatible avec un abonnement.`
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedIsRenewable = priceIsRecurring ? true : isRenewable;
|
const resolvedIsRenewable = priceIsRecurring ? true : isRenewable
|
||||||
|
|
||||||
if (resolvedIsRenewable) {
|
if (resolvedIsRenewable) {
|
||||||
hasSubscription = true;
|
hasSubscription = true
|
||||||
}
|
}
|
||||||
|
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
price: priceId,
|
price: priceId,
|
||||||
quantity,
|
quantity,
|
||||||
});
|
})
|
||||||
summary.push({
|
summary.push({
|
||||||
type: "price",
|
type: 'price',
|
||||||
priceID: priceId,
|
priceID: priceId,
|
||||||
quantity,
|
quantity,
|
||||||
isRenewable: resolvedIsRenewable,
|
isRenewable: resolvedIsRenewable,
|
||||||
});
|
})
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawUnitAmount = Number(item.unitAmount);
|
const rawUnitAmount = Number(item.unitAmount)
|
||||||
const unitAmount = Math.round(rawUnitAmount);
|
const unitAmount = Math.round(rawUnitAmount)
|
||||||
if (!Number.isFinite(unitAmount) || unitAmount <= 0) {
|
if (!Number.isFinite(unitAmount) || unitAmount <= 0) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
`Le montant indiqué pour l'élément ${index + 1} est invalide.`,
|
`Le montant indiqué pour l'élément ${index + 1} est invalide.`
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currency =
|
const currency = typeof item.currency === 'string' ? item.currency.trim().toLowerCase() : 'eur'
|
||||||
typeof item.currency === "string"
|
|
||||||
? item.currency.trim().toLowerCase()
|
|
||||||
: "eur";
|
|
||||||
|
|
||||||
if (!/^[a-z]{3}$/.test(currency)) {
|
if (!/^[a-z]{3}$/.test(currency)) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
`La devise indiquée pour l'élément ${index + 1} est invalide.`,
|
`La devise indiquée pour l'élément ${index + 1} est invalide.`
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
typeof item.label === "string" && item.label.trim()
|
typeof item.label === 'string' && item.label.trim() ? item.label.trim() : 'Paiement ponctuel'
|
||||||
? item.label.trim()
|
|
||||||
: "Paiement ponctuel";
|
|
||||||
|
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
price_data: {
|
price_data: {
|
||||||
@@ -286,95 +268,84 @@ const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
|
|||||||
unit_amount: unitAmount,
|
unit_amount: unitAmount,
|
||||||
},
|
},
|
||||||
quantity,
|
quantity,
|
||||||
});
|
})
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: "custom",
|
type: 'custom',
|
||||||
currency,
|
currency,
|
||||||
unitAmount,
|
unitAmount,
|
||||||
quantity,
|
quantity,
|
||||||
isRenewable: false,
|
isRenewable: false,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasSubscription) {
|
if (hasSubscription) {
|
||||||
const hasNonSubscription = summary.some((item) => !item.isRenewable);
|
const hasNonSubscription = summary.some((item) => !item.isRenewable)
|
||||||
if (hasNonSubscription) {
|
if (hasNonSubscription) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
"Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.",
|
'Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { lineItems, summary, hasSubscription };
|
return { lineItems, summary, hasSubscription }
|
||||||
};
|
}
|
||||||
|
|
||||||
const ensureStripeCustomer = async ({
|
const ensureStripeCustomer = async ({ uid, stripe, refsList, createIfMissing = true }) => {
|
||||||
uid,
|
|
||||||
stripe,
|
|
||||||
refsList,
|
|
||||||
createIfMissing = true,
|
|
||||||
}) => {
|
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
return { customerId: null, userData: null };
|
return { customerId: null, userData: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRef = refsList?.users?.doc(uid);
|
const userRef = refsList?.users?.doc(uid)
|
||||||
const snapshot = userRef ? await userRef.get() : null;
|
const snapshot = userRef ? await userRef.get() : null
|
||||||
const userData = snapshot?.exists ? snapshot.data() : null;
|
const userData = snapshot?.exists ? snapshot.data() : null
|
||||||
|
|
||||||
let customerId = userData?.stripeCustomerId;
|
let customerId = userData?.stripeCustomerId
|
||||||
if (customerId) {
|
if (customerId) {
|
||||||
return { customerId, userData };
|
return { customerId, userData }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!createIfMissing) {
|
if (!createIfMissing) {
|
||||||
return { customerId: null, userData };
|
return { customerId: null, userData }
|
||||||
}
|
}
|
||||||
|
|
||||||
let authRecord = null;
|
let authRecord = null
|
||||||
try {
|
try {
|
||||||
authRecord = await admin.auth().getUser(uid);
|
authRecord = await admin.auth().getUser(uid)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn('[ensureStripeCustomer] Impossible de récupérer auth user', error)
|
||||||
"[ensureStripeCustomer] Impossible de récupérer auth user",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = userData?.email || authRecord?.email || undefined;
|
const email = userData?.email || authRecord?.email || undefined
|
||||||
const nameFromProfile = [userData?.firstName, userData?.lastName]
|
const nameFromProfile = [userData?.firstName, userData?.lastName].filter(Boolean).join(' ').trim()
|
||||||
.filter(Boolean)
|
const name = nameFromProfile || authRecord?.displayName || undefined
|
||||||
.join(" ")
|
|
||||||
.trim();
|
|
||||||
const name = nameFromProfile || authRecord?.displayName || undefined;
|
|
||||||
|
|
||||||
const customer = await stripe.customers.create({
|
const customer = await stripe.customers.create({
|
||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
metadata: {
|
metadata: {
|
||||||
firebaseUID: uid,
|
firebaseUID: uid,
|
||||||
appMode: STRIPE_MODE || "test",
|
appMode: STRIPE_MODE || 'test',
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
customerId = customer.id;
|
customerId = customer.id
|
||||||
|
|
||||||
if (userRef) {
|
if (userRef) {
|
||||||
await userRef.set(
|
await userRef.set(
|
||||||
{
|
{
|
||||||
stripeCustomerId: customerId,
|
stripeCustomerId: customerId,
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
customerId,
|
customerId,
|
||||||
userData: { ...userData, stripeCustomerId: customerId },
|
userData: { ...userData, stripeCustomerId: customerId },
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const formatCheckoutSessionResponse = (session) => ({
|
const formatCheckoutSessionResponse = (session) => ({
|
||||||
id: session.id,
|
id: session.id,
|
||||||
@@ -391,84 +362,72 @@ const formatCheckoutSessionResponse = (session) => ({
|
|||||||
created: session.created,
|
created: session.created,
|
||||||
expires_at: session.expires_at,
|
expires_at: session.expires_at,
|
||||||
client_secret: session.client_secret || null,
|
client_secret: session.client_secret || null,
|
||||||
});
|
})
|
||||||
|
|
||||||
const getPortalConfigurationId = async (stripe) => {
|
const getPortalConfigurationId = async (stripe) => {
|
||||||
if (STRIPE_PORTAL_CONFIGURATION) {
|
if (STRIPE_PORTAL_CONFIGURATION) {
|
||||||
return STRIPE_PORTAL_CONFIGURATION;
|
return STRIPE_PORTAL_CONFIGURATION
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cachedPortalConfigurationId) {
|
if (cachedPortalConfigurationId) {
|
||||||
return cachedPortalConfigurationId;
|
return cachedPortalConfigurationId
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!stripe || typeof stripe.billingPortal?.configurations?.list !== 'function') {
|
||||||
!stripe ||
|
throw new HttpsError('internal', 'Client Stripe indisponible pour la configuration du portail.')
|
||||||
typeof stripe.billingPortal?.configurations?.list !== "function"
|
|
||||||
) {
|
|
||||||
throw new HttpsError(
|
|
||||||
"internal",
|
|
||||||
"Client Stripe indisponible pour la configuration du portail.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configurations = await stripe.billingPortal.configurations.list({
|
const configurations = await stripe.billingPortal.configurations.list({
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
})
|
||||||
|
|
||||||
const defaultConfiguration =
|
const defaultConfiguration =
|
||||||
configurations.data.find((config) => config.is_default) ||
|
configurations.data.find((config) => config.is_default) ||
|
||||||
configurations.data.find((config) => config.active);
|
configurations.data.find((config) => config.active)
|
||||||
|
|
||||||
if (defaultConfiguration?.id) {
|
if (defaultConfiguration?.id) {
|
||||||
cachedPortalConfigurationId = defaultConfiguration.id;
|
cachedPortalConfigurationId = defaultConfiguration.id
|
||||||
return cachedPortalConfigurationId;
|
return cachedPortalConfigurationId
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[getPortalConfigurationId] Impossible de lister les configurations de portail",
|
'[getPortalConfigurationId] Impossible de lister les configurations de portail',
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const defaultReturnUrl = getReturnBaseUrl();
|
const defaultReturnUrl = getReturnBaseUrl()
|
||||||
const createdConfiguration =
|
const createdConfiguration = await stripe.billingPortal.configurations.create({
|
||||||
await stripe.billingPortal.configurations.create({
|
|
||||||
default_return_url: defaultReturnUrl,
|
default_return_url: defaultReturnUrl,
|
||||||
business_profile: {
|
business_profile: {
|
||||||
headline: "Minuit Starter",
|
headline: 'Minuit Starter',
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
if (createdConfiguration?.id) {
|
if (createdConfiguration?.id) {
|
||||||
cachedPortalConfigurationId = createdConfiguration.id;
|
cachedPortalConfigurationId = createdConfiguration.id
|
||||||
return cachedPortalConfigurationId;
|
return cachedPortalConfigurationId
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut",
|
'[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut',
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|
||||||
const mapStripeErrorToHttps = (error, fallbackMessage) => {
|
const mapStripeErrorToHttps = (error, fallbackMessage) => {
|
||||||
const message =
|
const message = error?.raw?.message || error?.message || fallbackMessage || 'Erreur Stripe.'
|
||||||
error?.raw?.message ||
|
const statusCode = error?.statusCode || error?.raw?.statusCode
|
||||||
error?.message ||
|
const isClientError = typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500
|
||||||
fallbackMessage ||
|
|
||||||
"Erreur Stripe.";
|
|
||||||
const statusCode = error?.statusCode || error?.raw?.statusCode;
|
|
||||||
const isClientError =
|
|
||||||
typeof statusCode === "number" && statusCode >= 400 && statusCode < 500;
|
|
||||||
|
|
||||||
const code = isClientError ? "failed-precondition" : "internal";
|
const code = isClientError ? 'failed-precondition' : 'internal'
|
||||||
return new HttpsError(code, message);
|
return new HttpsError(code, message)
|
||||||
};
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getStripeClient,
|
getStripeClient,
|
||||||
@@ -479,4 +438,4 @@ module.exports = {
|
|||||||
formatCheckoutSessionResponse,
|
formatCheckoutSessionResponse,
|
||||||
getPortalConfigurationId,
|
getPortalConfigurationId,
|
||||||
mapStripeErrorToHttps,
|
mapStripeErrorToHttps,
|
||||||
};
|
}
|
||||||
|
|||||||
+41
-43
@@ -1,54 +1,52 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
|
|
||||||
// Use default credentials/environment provided by Cloud Functions.
|
// Use default credentials/environment provided by Cloud Functions.
|
||||||
// Avoid bundling a service account key and hardcoding project/bucket.
|
// Avoid bundling a service account key and hardcoding project/bucket.
|
||||||
admin.initializeApp();
|
admin.initializeApp()
|
||||||
|
|
||||||
const db = admin.firestore();
|
const db = admin.firestore()
|
||||||
const REGION = process.env.FIREBASE_REGION || "europe-west1";
|
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
|
||||||
|
|
||||||
exports.db = db;
|
exports.db = db
|
||||||
exports.REGION = REGION;
|
exports.REGION = REGION
|
||||||
|
|
||||||
exports.refList = {
|
exports.refList = {
|
||||||
projects: db.collection("projects"),
|
projects: db.collection('projects'),
|
||||||
playlists: db.collection("playlists"),
|
playlists: db.collection('playlists'),
|
||||||
tasks: db.collection("tasks"),
|
tasks: db.collection('tasks'),
|
||||||
notifications: db.collection("notifications"),
|
notifications: db.collection('notifications'),
|
||||||
users: db.collection("users"),
|
users: db.collection('users'),
|
||||||
projectStreamStats: db.collection("projectStreamStats"),
|
projectStreamStats: db.collection('projectStreamStats'),
|
||||||
projectStreamStatsMonthlyTotals: db.collection(
|
projectStreamStatsMonthlyTotals: db.collection('projectStreamStatsMonthlyTotals'),
|
||||||
"projectStreamStatsMonthlyTotals",
|
monthlyPayoutEntries: db.collection('monthlyPayoutEntries'),
|
||||||
),
|
monthlyPayouts: db.collection('monthlyPayouts'),
|
||||||
monthlyPayoutEntries: db.collection("monthlyPayoutEntries"),
|
}
|
||||||
monthlyPayouts: db.collection("monthlyPayouts"),
|
exports.refsList = exports.refList
|
||||||
};
|
|
||||||
exports.refsList = exports.refList;
|
|
||||||
|
|
||||||
exports.ALERT_TYPE = {
|
exports.ALERT_TYPE = {
|
||||||
NEW_LIKE: "NEW_LIKE",
|
NEW_LIKE: 'NEW_LIKE',
|
||||||
NEW_COMMENT: "NEW_COMMENT",
|
NEW_COMMENT: 'NEW_COMMENT',
|
||||||
NEW_FOLLOWER: "NEW_FOLLOWER",
|
NEW_FOLLOWER: 'NEW_FOLLOWER',
|
||||||
MUSIC_GENERATION_SUCCESS: "MUSIC_GENERATION_SUCCESS",
|
MUSIC_GENERATION_SUCCESS: 'MUSIC_GENERATION_SUCCESS',
|
||||||
MUSIC_GENERATION_FAILED: "MUSIC_GENERATION_FAILED",
|
MUSIC_GENERATION_FAILED: 'MUSIC_GENERATION_FAILED',
|
||||||
COVER_GENERATION_SUCCESS: "COVER_GENERATION_SUCCESS",
|
COVER_GENERATION_SUCCESS: 'COVER_GENERATION_SUCCESS',
|
||||||
COVER_GENERATION_FAILED: "COVER_GENERATION_FAILED",
|
COVER_GENERATION_FAILED: 'COVER_GENERATION_FAILED',
|
||||||
CREDITS_UPDATED: "CREDITS_UPDATED",
|
CREDITS_UPDATED: 'CREDITS_UPDATED',
|
||||||
PAYOUT_AVAILABLE: "PAYOUT_AVAILABLE",
|
PAYOUT_AVAILABLE: 'PAYOUT_AVAILABLE',
|
||||||
};
|
}
|
||||||
|
|
||||||
// Exporter toutes les fonctions
|
// Exporter toutes les fonctions
|
||||||
exports.users = require("./src/users");
|
exports.users = require('./src/users')
|
||||||
exports.music = require("./src/music");
|
exports.music = require('./src/music')
|
||||||
exports.lyrics = require("./src/lyrics");
|
exports.lyrics = require('./src/lyrics')
|
||||||
exports.cover = require("./src/cover");
|
exports.cover = require('./src/cover')
|
||||||
exports.projects = require("./src/project");
|
exports.projects = require('./src/project')
|
||||||
exports.thumbnail = require("./src/thumbnail");
|
exports.thumbnail = require('./src/thumbnail')
|
||||||
exports.upload = require("./src/upload");
|
exports.upload = require('./src/upload')
|
||||||
exports.algolia = require("./src/algolia");
|
exports.algolia = require('./src/algolia')
|
||||||
exports.notifications = require("./src/notifications");
|
exports.notifications = require('./src/notifications')
|
||||||
exports.rankings = require("./src/rankings");
|
exports.rankings = require('./src/rankings')
|
||||||
exports.payouts = require("./src/payouts");
|
exports.payouts = require('./src/payouts')
|
||||||
exports.subscription = require("./src/subscription");
|
exports.subscription = require('./src/subscription')
|
||||||
exports.youtube = require("./src/youtube");
|
exports.youtube = require('./src/youtube')
|
||||||
exports.orders = require("./src/orders");
|
exports.orders = require('./src/orders')
|
||||||
|
|||||||
+15
-18
@@ -1,26 +1,23 @@
|
|||||||
const { onRequest } = require("firebase-functions/v2/https");
|
const { onRequest } = require('firebase-functions/v2/https')
|
||||||
|
|
||||||
exports.algoliaTransformProjectData = onRequest(
|
exports.algoliaTransformProjectData = onRequest({ region: 'europe-west1' }, (req, res) => {
|
||||||
{ region: "europe-west1" },
|
const payload = req.body.data
|
||||||
(req, res) => {
|
const objectID = payload.objectID
|
||||||
const payload = req.body.data;
|
|
||||||
const objectID = payload.objectID;
|
|
||||||
try {
|
try {
|
||||||
const flat = { ...payload };
|
const flat = { ...payload }
|
||||||
delete flat["musicTimestamps"];
|
delete flat['musicTimestamps']
|
||||||
|
|
||||||
console.log(`Change in ${payload.objectID}`);
|
console.log(`Change in ${payload.objectID}`)
|
||||||
console.log(flat);
|
console.log(flat)
|
||||||
// Ton object final doit contenir "objectID"
|
// Ton object final doit contenir "objectID"
|
||||||
const result = {
|
const result = {
|
||||||
objectID,
|
objectID,
|
||||||
...flat,
|
...flat,
|
||||||
};
|
|
||||||
|
|
||||||
res.send({ result });
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`Error ${objectID}:`, e.message);
|
|
||||||
res.status(500).end();
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
);
|
res.send({ result })
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Error ${objectID}:`, e.message)
|
||||||
|
res.status(500).end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
+142
-159
@@ -1,98 +1,97 @@
|
|||||||
const { onDocumentCreated } = require("firebase-functions/v2/firestore");
|
const { onDocumentCreated } = require('firebase-functions/v2/firestore')
|
||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
const logger = require("firebase-functions/logger");
|
const logger = require('firebase-functions/logger')
|
||||||
const path = require("path");
|
const path = require('path')
|
||||||
const fs = require("fs");
|
const fs = require('fs')
|
||||||
const axios = require("axios");
|
const axios = require('axios')
|
||||||
const sharp = require("sharp");
|
const sharp = require('sharp')
|
||||||
const crypto = require("crypto");
|
const crypto = require('crypto')
|
||||||
|
|
||||||
// Imports internes
|
// Imports internes
|
||||||
const { generateImageV2 } = require("../helpers/gemini");
|
const { generateImageV2 } = require('../helpers/gemini')
|
||||||
const { generatePicturePrompt } = require("../helpers/prompts");
|
const { generatePicturePrompt } = require('../helpers/prompts')
|
||||||
const { ALERT_TYPE, refList } = require("../index");
|
const { ALERT_TYPE, refList } = require('../index')
|
||||||
const { sendNotification } = require("./notifications");
|
const { sendNotification } = require('./notifications')
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const bucket = admin.storage().bucket();
|
const bucket = admin.storage().bucket()
|
||||||
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png");
|
const LOGO_PATH = path.resolve(__dirname, '../assets/musicLandLogo.png')
|
||||||
|
|
||||||
// Cache mémoire pour le buffer du logo (évite les I/O disque à chaque appel sur instance chaude)
|
// Cache mémoire pour le buffer du logo (évite les I/O disque à chaque appel sur instance chaude)
|
||||||
let _cachedLogoBuffer = null;
|
let _cachedLogoBuffer = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère le buffer du logo depuis le cache ou le disque
|
* Récupère le buffer du logo depuis le cache ou le disque
|
||||||
*/
|
*/
|
||||||
const getLogoBuffer = async () => {
|
const getLogoBuffer = async () => {
|
||||||
if (_cachedLogoBuffer) return _cachedLogoBuffer;
|
if (_cachedLogoBuffer) return _cachedLogoBuffer
|
||||||
try {
|
try {
|
||||||
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH);
|
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH)
|
||||||
return _cachedLogoBuffer;
|
return _cachedLogoBuffer
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("❌ [Cover] Impossible de lire le fichier logo", error);
|
logger.error('❌ [Cover] Impossible de lire le fichier logo', error)
|
||||||
throw new Error("Asset Logo manquant sur le serveur");
|
throw new Error('Asset Logo manquant sur le serveur')
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utilitaires Strings
|
* Utilitaires Strings
|
||||||
*/
|
*/
|
||||||
const pickFirstNonEmpty = (...values) => {
|
const pickFirstNonEmpty = (...values) => {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (typeof value === "string" && value.trim().length > 0) {
|
if (typeof value === 'string' && value.trim().length > 0) {
|
||||||
return value.trim();
|
return value.trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "";
|
return ''
|
||||||
};
|
}
|
||||||
|
|
||||||
const combineNames = (...parts) =>
|
const combineNames = (...parts) =>
|
||||||
parts
|
parts
|
||||||
.map((part) => (typeof part === "string" ? part.trim() : ""))
|
.map((part) => (typeof part === 'string' ? part.trim() : ''))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(' ')
|
||||||
.trim();
|
.trim()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Résolution intelligente du nom d'artiste
|
* Résolution intelligente du nom d'artiste
|
||||||
*/
|
*/
|
||||||
async function resolveArtistName(project = {}) {
|
async function resolveArtistName(project = {}) {
|
||||||
// 1. Vérification directe sur le projet ou le snapshot "owner"
|
// 1. Vérification directe sur le projet ou le snapshot "owner"
|
||||||
const owner = project?.owner || {};
|
const owner = project?.owner || {}
|
||||||
const direct = pickFirstNonEmpty(
|
const direct = pickFirstNonEmpty(
|
||||||
project?.artistName,
|
project?.artistName,
|
||||||
project?.userName,
|
project?.userName,
|
||||||
owner?.artistName,
|
owner?.artistName,
|
||||||
owner?.userName,
|
owner?.userName,
|
||||||
owner?.displayName,
|
owner?.displayName
|
||||||
);
|
)
|
||||||
if (direct) return direct;
|
if (direct) return direct
|
||||||
|
|
||||||
// 2. Fallback : Récupération depuis la collection Users
|
// 2. Fallback : Récupération depuis la collection Users
|
||||||
const userId =
|
const userId = typeof project?.userId === 'string' ? project.userId.trim() : ''
|
||||||
typeof project?.userId === "string" ? project.userId.trim() : "";
|
if (!userId) return ''
|
||||||
if (!userId) return "";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userSnapshot = await refList.users.doc(userId).get();
|
const userSnapshot = await refList.users.doc(userId).get()
|
||||||
if (!userSnapshot?.exists) return "";
|
if (!userSnapshot?.exists) return ''
|
||||||
|
|
||||||
const userData = userSnapshot.data() || {};
|
const userData = userSnapshot.data() || {}
|
||||||
return (
|
return (
|
||||||
pickFirstNonEmpty(
|
pickFirstNonEmpty(
|
||||||
userData.artistName,
|
userData.artistName,
|
||||||
userData.userName,
|
userData.userName,
|
||||||
userData.displayName,
|
userData.displayName,
|
||||||
combineNames(userData.firstName, userData.lastName),
|
combineNames(userData.firstName, userData.lastName)
|
||||||
) || ""
|
) || ''
|
||||||
);
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn("⚠️ [Cover] Artist name resolution failed", {
|
logger.warn('⚠️ [Cover] Artist name resolution failed', {
|
||||||
projectId: project?.id,
|
projectId: project?.id,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
})
|
||||||
return "";
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,60 +99,57 @@ async function resolveArtistName(project = {}) {
|
|||||||
* Ajoute le logo en filigrane sur l'image générée
|
* Ajoute le logo en filigrane sur l'image générée
|
||||||
*/
|
*/
|
||||||
async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
||||||
logger.info("🖼️ [Cover] Compositing logo...");
|
logger.info('🖼️ [Cover] Compositing logo...')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Téléchargement background + Lecture Logo (parallèle)
|
// Téléchargement background + Lecture Logo (parallèle)
|
||||||
const [bgResponse, logoBuffer] = await Promise.all([
|
const [bgResponse, logoBuffer] = await Promise.all([
|
||||||
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
|
axios.get(backgroundUrl, { responseType: 'arraybuffer' }),
|
||||||
getLogoBuffer(),
|
getLogoBuffer(),
|
||||||
]);
|
])
|
||||||
|
|
||||||
const baseImage = sharp(bgResponse.data);
|
const baseImage = sharp(bgResponse.data)
|
||||||
const metadata = await baseImage.metadata();
|
const metadata = await baseImage.metadata()
|
||||||
const width = metadata.width || 1024;
|
const width = metadata.width || 1024
|
||||||
const height = metadata.height || 1024;
|
const height = metadata.height || 1024
|
||||||
|
|
||||||
// Calcul dynamique de la taille du logo (32% de la largeur)
|
// Calcul dynamique de la taille du logo (32% de la largeur)
|
||||||
const desiredWidth = Math.round(width * 0.32);
|
const desiredWidth = Math.round(width * 0.32)
|
||||||
const margin = Math.round(width * 0.04);
|
const margin = Math.round(width * 0.04)
|
||||||
|
|
||||||
// Redimensionnement du logo
|
// Redimensionnement du logo
|
||||||
const resizedLogo = await sharp(logoBuffer)
|
const resizedLogo = await sharp(logoBuffer).resize({ width: desiredWidth }).png().toBuffer()
|
||||||
.resize({ width: desiredWidth })
|
|
||||||
.png()
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
// Positionnement (Bas Droite)
|
// Positionnement (Bas Droite)
|
||||||
const logoMetadata = await sharp(resizedLogo).metadata();
|
const logoMetadata = await sharp(resizedLogo).metadata()
|
||||||
const left = Math.max(width - logoMetadata.width - margin, 0);
|
const left = Math.max(width - logoMetadata.width - margin, 0)
|
||||||
const top = Math.max(height - logoMetadata.height - margin, 0);
|
const top = Math.max(height - logoMetadata.height - margin, 0)
|
||||||
|
|
||||||
// Composition
|
// Composition
|
||||||
const stampedBuffer = await baseImage
|
const stampedBuffer = await baseImage
|
||||||
.ensureAlpha()
|
.ensureAlpha()
|
||||||
.composite([{ input: resizedLogo, left, top, blend: "over" }])
|
.composite([{ input: resizedLogo, left, top, blend: 'over' }])
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer()
|
||||||
|
|
||||||
// Upload vers Storage
|
// Upload vers Storage
|
||||||
const token = crypto.randomUUID();
|
const token = crypto.randomUUID()
|
||||||
const file = bucket.file(targetPath);
|
const file = bucket.file(targetPath)
|
||||||
|
|
||||||
await file.save(stampedBuffer, {
|
await file.save(stampedBuffer, {
|
||||||
resumable: false,
|
resumable: false,
|
||||||
metadata: {
|
metadata: {
|
||||||
contentType: "image/png",
|
contentType: 'image/png',
|
||||||
cacheControl: "public, max-age=31536000",
|
cacheControl: 'public, max-age=31536000',
|
||||||
metadata: { firebaseStorageDownloadTokens: token },
|
metadata: { firebaseStorageDownloadTokens: token },
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`;
|
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("❌ [Cover] buildCoverWithLogo failed", error);
|
logger.error('❌ [Cover] buildCoverWithLogo failed', error)
|
||||||
// En cas d'échec du logo, on renvoie l'URL originale pour ne pas tout perdre
|
// En cas d'échec du logo, on renvoie l'URL originale pour ne pas tout perdre
|
||||||
return backgroundUrl;
|
return backgroundUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,74 +157,69 @@ async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
|||||||
* Cœur de la logique de génération
|
* Cœur de la logique de génération
|
||||||
*/
|
*/
|
||||||
async function performCoverGeneration(project) {
|
async function performCoverGeneration(project) {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now()
|
||||||
const artistName = await resolveArtistName(project);
|
const artistName = await resolveArtistName(project)
|
||||||
|
|
||||||
// Génération du Prompt optimisé
|
// Génération du Prompt optimisé
|
||||||
const prompt = generatePicturePrompt({
|
const prompt = generatePicturePrompt({
|
||||||
...project,
|
...project,
|
||||||
artistName,
|
artistName,
|
||||||
});
|
})
|
||||||
|
|
||||||
logger.info("🎨 [Cover] Prompt generated", {
|
logger.info('🎨 [Cover] Prompt generated', {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
artistName,
|
artistName,
|
||||||
promptPreview: prompt.slice(0, 100) + "...",
|
promptPreview: prompt.slice(0, 100) + '...',
|
||||||
});
|
})
|
||||||
|
|
||||||
const baseTimestamp = Date.now();
|
const baseTimestamp = Date.now()
|
||||||
const GENERATION_COUNT = 2; // Nombre de variantes simultanées
|
const GENERATION_COUNT = 2 // Nombre de variantes simultanées
|
||||||
|
|
||||||
// Création d'un tableau de promesses pour exécuter les tâches en parallèle
|
// Création d'un tableau de promesses pour exécuter les tâches en parallèle
|
||||||
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(
|
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(async (_, index) => {
|
||||||
async (_, index) => {
|
const uniqueSuffix = `${baseTimestamp}-${index}`
|
||||||
const uniqueSuffix = `${baseTimestamp}-${index}`;
|
const storageBasePath = `users/${project.userId}/projects/${project.id}`
|
||||||
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
|
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`
|
||||||
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
|
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`
|
||||||
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
|
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
|
||||||
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
|
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath)
|
||||||
|
|
||||||
if (!generatedUrl) throw new Error("URL vide retournée par l'IA");
|
if (!generatedUrl) throw new Error("URL vide retournée par l'IA")
|
||||||
|
|
||||||
// 2. Ajout du Logo
|
// 2. Ajout du Logo
|
||||||
const finalCoverUrl = await buildCoverWithLogo(
|
const finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath)
|
||||||
generatedUrl,
|
|
||||||
stampedPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`);
|
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: uniqueSuffix,
|
id: uniqueSuffix,
|
||||||
generatedUrl,
|
generatedUrl,
|
||||||
finalUrl: finalCoverUrl,
|
finalUrl: finalCoverUrl,
|
||||||
promptUsed: prompt,
|
promptUsed: prompt,
|
||||||
};
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all
|
// On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all
|
||||||
logger.error(`❌ [Cover] Option ${index + 1} failed`, {
|
logger.error(`❌ [Cover] Option ${index + 1} failed`, {
|
||||||
error: e.message,
|
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
|
// Attente de la résolution de toutes les générations
|
||||||
const results = await Promise.all(generationPromises);
|
const results = await Promise.all(generationPromises)
|
||||||
|
|
||||||
// On garde uniquement les tentatives réussies (non null)
|
// On garde uniquement les tentatives réussies (non null)
|
||||||
const options = results.filter(Boolean);
|
const options = results.filter(Boolean)
|
||||||
|
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
throw new Error("Toutes les tentatives de génération ont échoué.");
|
throw new Error('Toutes les tentatives de génération ont échoué.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sauvegarde dans Firestore
|
// Sauvegarde dans Firestore
|
||||||
const [firstOption] = options;
|
const [firstOption] = options
|
||||||
|
|
||||||
await refList.projects.doc(project.id).set(
|
await refList.projects.doc(project.id).set(
|
||||||
{
|
{
|
||||||
@@ -238,19 +229,19 @@ async function performCoverGeneration(project) {
|
|||||||
// selectedOptionId: firstOption.id, // disable default selection
|
// selectedOptionId: firstOption.id, // disable default selection
|
||||||
options, // Sauvegarde de toutes les variantes réussies
|
options, // Sauvegarde de toutes les variantes réussies
|
||||||
},
|
},
|
||||||
coverStatus: "GENERATED",
|
coverStatus: 'GENERATED',
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
|
|
||||||
logger.info("🏁 [Cover] Process complete", {
|
logger.info('🏁 [Cover] Process complete', {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
successCount: options.length,
|
successCount: options.length,
|
||||||
duration: Date.now() - t0,
|
duration: Date.now() - t0,
|
||||||
});
|
})
|
||||||
|
|
||||||
return firstOption.finalUrl;
|
return firstOption.finalUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,77 +251,72 @@ async function performCoverGeneration(project) {
|
|||||||
exports.onTaskCreateGenerateCover = onDocumentCreated(
|
exports.onTaskCreateGenerateCover = onDocumentCreated(
|
||||||
{
|
{
|
||||||
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
|
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
|
||||||
memory: "1GiB",
|
memory: '1GiB',
|
||||||
document: "tasks/{taskId}",
|
document: 'tasks/{taskId}',
|
||||||
},
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
const data = event.data?.data() || {};
|
const data = event.data?.data() || {}
|
||||||
const { type, projectId } = data;
|
const { type, projectId } = data
|
||||||
const taskId = event.params.taskId;
|
const taskId = event.params.taskId
|
||||||
|
|
||||||
if (!projectId) return; // Ignorer les tâches mal formées
|
if (!projectId) return // Ignorer les tâches mal formées
|
||||||
if (!["cover", "combine"].includes(type)) return; // Ignorer les autres types de tâches
|
if (!['cover', 'combine'].includes(type)) return // Ignorer les autres types de tâches
|
||||||
|
|
||||||
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId });
|
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Validation & Setup
|
// 1. Validation & Setup
|
||||||
if (type === "combine") {
|
if (type === 'combine') {
|
||||||
// Feature désactivée pour le moment
|
// Feature désactivée pour le moment
|
||||||
await event.data.ref.update({
|
await event.data.ref.update({
|
||||||
status: "CANCELLED",
|
status: 'CANCELLED',
|
||||||
error: "La personnalisation photo n'est plus disponible.",
|
error: "La personnalisation photo n'est plus disponible.",
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
});
|
})
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mise à jour statut projet
|
// Mise à jour statut projet
|
||||||
await refList.projects.doc(projectId).update({
|
await refList.projects.doc(projectId).update({
|
||||||
coverStatus: "GENERATING",
|
coverStatus: 'GENERATING',
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
});
|
})
|
||||||
|
|
||||||
// 2. Chargement Projet
|
// 2. Chargement Projet
|
||||||
const projectSnap = await refList.projects.doc(projectId).get();
|
const projectSnap = await refList.projects.doc(projectId).get()
|
||||||
if (!projectSnap.exists) throw new Error("Projet introuvable");
|
if (!projectSnap.exists) throw new Error('Projet introuvable')
|
||||||
|
|
||||||
const project = { id: projectId, ...projectSnap.data() };
|
const project = { id: projectId, ...projectSnap.data() }
|
||||||
|
|
||||||
// Idempotency check (si déjà généré, on ne refait pas)
|
// Idempotency check (si déjà généré, on ne refait pas)
|
||||||
if (
|
if (Array.isArray(project?.cover?.options) && project.cover.options.length > 0) {
|
||||||
Array.isArray(project?.cover?.options) &&
|
logger.warn('⚠️ [Task] Cover already exists. Skipping.')
|
||||||
project.cover.options.length > 0
|
await refList.projects.doc(projectId).update({ coverStatus: 'GENERATED' })
|
||||||
) {
|
|
||||||
logger.warn("⚠️ [Task] Cover already exists. Skipping.");
|
|
||||||
await refList.projects
|
|
||||||
.doc(projectId)
|
|
||||||
.update({ coverStatus: "GENERATED" });
|
|
||||||
await event.data.ref.update({
|
await event.data.ref.update({
|
||||||
status: "DONE",
|
status: 'DONE',
|
||||||
info: "Already generated",
|
info: 'Already generated',
|
||||||
});
|
})
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Exécution Génération
|
// 3. Exécution Génération
|
||||||
const coverUrl = await performCoverGeneration(project);
|
const coverUrl = await performCoverGeneration(project)
|
||||||
|
|
||||||
// 4. Finalisation Tâche
|
// 4. Finalisation Tâche
|
||||||
await event.data.ref.update({
|
await event.data.ref.update({
|
||||||
status: "DONE",
|
status: 'DONE',
|
||||||
coverUrl,
|
coverUrl,
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
});
|
})
|
||||||
|
|
||||||
// 5. Notification
|
// 5. Notification
|
||||||
if (project.userId) {
|
if (project.userId) {
|
||||||
const projectTitle = project.title || "ton projet";
|
const projectTitle = project.title || 'ton projet'
|
||||||
await sendNotification({
|
await sendNotification({
|
||||||
sender: "SYSTEM",
|
sender: 'SYSTEM',
|
||||||
receiver: project.userId,
|
receiver: project.userId,
|
||||||
receiverCollection: "users",
|
receiverCollection: 'users',
|
||||||
title: "Pochette prête !",
|
title: 'Pochette prête !',
|
||||||
message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
|
message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
|
||||||
data: {
|
data: {
|
||||||
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
|
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
|
||||||
@@ -338,39 +324,36 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
|
|||||||
projectTitle,
|
projectTitle,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
},
|
},
|
||||||
}).catch((err) => logger.warn("Notification failed", err));
|
}).catch((err) => logger.warn('Notification failed', err))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`🔥 [Task ${taskId}] Failed`, error);
|
logger.error(`🔥 [Task ${taskId}] Failed`, error)
|
||||||
|
|
||||||
// Mise à jour erreur Tâche
|
// Mise à jour erreur Tâche
|
||||||
await event.data.ref.set(
|
await event.data.ref.set({ status: 'ERROR', error: error.message }, { merge: true })
|
||||||
{ status: "ERROR", error: error.message },
|
|
||||||
{ merge: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mise à jour erreur Projet
|
// Mise à jour erreur Projet
|
||||||
await refList.projects.doc(projectId).update({
|
await refList.projects.doc(projectId).update({
|
||||||
coverStatus: "ERROR",
|
coverStatus: 'ERROR',
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
});
|
})
|
||||||
|
|
||||||
// Notification Erreur
|
// Notification Erreur
|
||||||
const projectData = (await refList.projects.doc(projectId).get()).data();
|
const projectData = (await refList.projects.doc(projectId).get()).data()
|
||||||
if (projectData?.userId) {
|
if (projectData?.userId) {
|
||||||
await sendNotification({
|
await sendNotification({
|
||||||
sender: "SYSTEM",
|
sender: 'SYSTEM',
|
||||||
receiver: projectData.userId,
|
receiver: projectData.userId,
|
||||||
receiverCollection: "users",
|
receiverCollection: 'users',
|
||||||
title: "Échec pochette",
|
title: 'Échec pochette',
|
||||||
message: `Impossible de générer la pochette pour "${projectData.title || "ton projet"}".`,
|
message: `Impossible de générer la pochette pour "${projectData.title || 'ton projet'}".`,
|
||||||
data: {
|
data: {
|
||||||
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
|
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
|
||||||
projectId,
|
projectId,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
},
|
},
|
||||||
}).catch(() => { });
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
|
|
||||||
const ORDER_TYPES = {
|
const ORDER_TYPES = {
|
||||||
GIFT: "GIFT",
|
GIFT: 'GIFT',
|
||||||
SONG: "SONG",
|
SONG: 'SONG',
|
||||||
COINS: "COINS",
|
COINS: 'COINS',
|
||||||
SUBSCRIPTION: "SUBSCRIPTION",
|
SUBSCRIPTION: 'SUBSCRIPTION',
|
||||||
};
|
}
|
||||||
|
|
||||||
const ORDER_STATUS = {
|
const ORDER_STATUS = {
|
||||||
PENDING: "PENDING",
|
PENDING: 'PENDING',
|
||||||
APPLIED: "APPLIED",
|
APPLIED: 'APPLIED',
|
||||||
REJECTED: "REJECTED",
|
REJECTED: 'REJECTED',
|
||||||
};
|
}
|
||||||
|
|
||||||
const ORDERS_COLLECTION = "orders";
|
const ORDERS_COLLECTION = 'orders'
|
||||||
|
|
||||||
const isFiniteNumber = (value) => {
|
const isFiniteNumber = (value) => {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === 'string') {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value)
|
||||||
return Number.isFinite(parsed);
|
return Number.isFinite(parsed)
|
||||||
}
|
}
|
||||||
return false;
|
return false
|
||||||
};
|
}
|
||||||
|
|
||||||
const normalizeAmount = (amount) => {
|
const normalizeAmount = (amount) => {
|
||||||
if (!isFiniteNumber(amount)) {
|
if (!isFiniteNumber(amount)) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
return Number(amount);
|
return Number(amount)
|
||||||
};
|
}
|
||||||
|
|
||||||
const createOrderDocument = async ({
|
const createOrderDocument = async ({
|
||||||
userId,
|
userId,
|
||||||
type,
|
type,
|
||||||
amount,
|
amount,
|
||||||
songId = null,
|
songId = null,
|
||||||
createdBy = "system",
|
createdBy = 'system',
|
||||||
metadata = {},
|
metadata = {},
|
||||||
orderId = null,
|
orderId = null,
|
||||||
}) => {
|
}) => {
|
||||||
if (!userId || typeof userId !== "string") {
|
if (!userId || typeof userId !== 'string') {
|
||||||
throw new Error("[orders] Missing userId when creating order");
|
throw new Error('[orders] Missing userId when creating order')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.values(ORDER_TYPES).includes(type)) {
|
if (!Object.values(ORDER_TYPES).includes(type)) {
|
||||||
throw new Error(`[orders] Invalid order type "${type}"`);
|
throw new Error(`[orders] Invalid order type "${type}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedAmount = normalizeAmount(amount);
|
const normalizedAmount = normalizeAmount(amount)
|
||||||
|
|
||||||
if (normalizedAmount === null || normalizedAmount === 0) {
|
if (normalizedAmount === null || normalizedAmount === 0) {
|
||||||
throw new Error("[orders] Invalid order amount");
|
throw new Error('[orders] Invalid order amount')
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -63,25 +63,25 @@ const createOrderDocument = async ({
|
|||||||
amount: normalizedAmount,
|
amount: normalizedAmount,
|
||||||
songId: type === ORDER_TYPES.SONG ? songId || null : null,
|
songId: type === ORDER_TYPES.SONG ? songId || null : null,
|
||||||
createdAt: FieldValue.serverTimestamp(),
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
createdBy: createdBy || "system",
|
createdBy: createdBy || 'system',
|
||||||
status: ORDER_STATUS.PENDING,
|
status: ORDER_STATUS.PENDING,
|
||||||
metadata: metadata || {},
|
metadata: metadata || {},
|
||||||
};
|
}
|
||||||
|
|
||||||
const collectionRef = admin.firestore().collection(ORDERS_COLLECTION);
|
const collectionRef = admin.firestore().collection(ORDERS_COLLECTION)
|
||||||
const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc();
|
const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc()
|
||||||
|
|
||||||
if (orderId) {
|
if (orderId) {
|
||||||
const existingSnapshot = await orderRef.get();
|
const existingSnapshot = await orderRef.get()
|
||||||
if (existingSnapshot.exists) {
|
if (existingSnapshot.exists) {
|
||||||
return { orderRef, orderId: orderRef.id };
|
return { orderRef, orderId: orderRef.id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await orderRef.set(payload);
|
await orderRef.set(payload)
|
||||||
|
|
||||||
return { orderRef, orderId: orderRef.id };
|
return { orderRef, orderId: orderRef.id }
|
||||||
};
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ORDER_TYPES,
|
ORDER_TYPES,
|
||||||
@@ -89,4 +89,4 @@ module.exports = {
|
|||||||
ORDERS_COLLECTION,
|
ORDERS_COLLECTION,
|
||||||
createOrderDocument,
|
createOrderDocument,
|
||||||
normalizeAmount,
|
normalizeAmount,
|
||||||
};
|
}
|
||||||
|
|||||||
+191
-220
@@ -1,93 +1,86 @@
|
|||||||
const { onCall, HttpsError } = require("firebase-functions/v2/https");
|
const { onCall, HttpsError } = require('firebase-functions/v2/https')
|
||||||
const { z } = require("genkit");
|
const { z } = require('genkit')
|
||||||
const { generateAI, analyseLyrics } = require("../helpers/gemini");
|
const { generateAI, analyseLyrics } = require('../helpers/gemini')
|
||||||
const {
|
const { SUNO_API_BASE, SUNO_TIMESTAMPED_LYRICS_PATH } = require('../config/suno')
|
||||||
SUNO_API_BASE,
|
const { SUNO_API_KEY } = require('../config/keys')
|
||||||
SUNO_TIMESTAMPED_LYRICS_PATH,
|
const axios = require('axios')
|
||||||
} = require("../config/suno");
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
const { SUNO_API_KEY } = require("../config/keys");
|
const { refList } = require('../index')
|
||||||
const axios = require("axios");
|
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
|
||||||
const { refList } = require("../index");
|
|
||||||
|
|
||||||
// --- CONSTANTES DE STRUCTURE ---
|
// --- CONSTANTES DE STRUCTURE ---
|
||||||
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
|
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
|
||||||
const STRUCTURE_PROMPT_LABELS = {
|
const STRUCTURE_PROMPT_LABELS = {
|
||||||
couplet: "couplet",
|
couplet: 'couplet',
|
||||||
refrain: "refrain",
|
refrain: 'refrain',
|
||||||
short_intro: "introduction instrumentale courte",
|
short_intro: 'introduction instrumentale courte',
|
||||||
long_intro: "introduction instrumentale longue",
|
long_intro: 'introduction instrumentale longue',
|
||||||
pre_refrain_instrumental: "pré-refrain instrumental",
|
pre_refrain_instrumental: 'pré-refrain instrumental',
|
||||||
pont: "pont",
|
pont: 'pont',
|
||||||
solo_de_guitare: "solo de guitare",
|
solo_de_guitare: 'solo de guitare',
|
||||||
solo_de_guitare_electrique: "solo de guitare électrique",
|
solo_de_guitare_electrique: 'solo de guitare électrique',
|
||||||
solo_de_batterie: "solo de batterie",
|
solo_de_batterie: 'solo de batterie',
|
||||||
solo_de_saxophone: "solo de saxophone",
|
solo_de_saxophone: 'solo de saxophone',
|
||||||
solo_de_violon: "solo de violon",
|
solo_de_violon: 'solo de violon',
|
||||||
break: "break",
|
break: 'break',
|
||||||
interlude: "interlude",
|
interlude: 'interlude',
|
||||||
interlude_melodique: "interlude mélodique",
|
interlude_melodique: 'interlude mélodique',
|
||||||
final_apogee: "final apogée",
|
final_apogee: 'final apogée',
|
||||||
arret_net: "arrêt net",
|
arret_net: 'arrêt net',
|
||||||
fade_out: "fade out",
|
fade_out: 'fade out',
|
||||||
transition_douce: "transition douce vers le silence",
|
transition_douce: 'transition douce vers le silence',
|
||||||
};
|
}
|
||||||
|
|
||||||
const STRUCTURE_ALIASES = {
|
const STRUCTURE_ALIASES = {
|
||||||
"short intro": "short_intro",
|
'short intro': 'short_intro',
|
||||||
"introduction instrumentale courte": "short_intro",
|
'introduction instrumentale courte': 'short_intro',
|
||||||
"intro instrumentale courte": "short_intro",
|
'intro instrumentale courte': 'short_intro',
|
||||||
"long intro": "long_intro",
|
'long intro': 'long_intro',
|
||||||
"introduction instrumentale longue": "long_intro",
|
'introduction instrumentale longue': 'long_intro',
|
||||||
"intro instrumentale longue": "long_intro",
|
'intro instrumentale longue': 'long_intro',
|
||||||
"pré-refrain": "pre_refrain_instrumental",
|
'pré-refrain': 'pre_refrain_instrumental',
|
||||||
"pre-refrain": "pre_refrain_instrumental",
|
'pre-refrain': 'pre_refrain_instrumental',
|
||||||
pre_refrain: "pre_refrain_instrumental",
|
pre_refrain: 'pre_refrain_instrumental',
|
||||||
"pre chorus": "pre_refrain_instrumental",
|
'pre chorus': 'pre_refrain_instrumental',
|
||||||
"pre-chorus": "pre_refrain_instrumental",
|
'pre-chorus': 'pre_refrain_instrumental',
|
||||||
prechorus: "pre_refrain_instrumental",
|
prechorus: 'pre_refrain_instrumental',
|
||||||
"pré-refrain instrumental": "pre_refrain_instrumental",
|
'pré-refrain instrumental': 'pre_refrain_instrumental',
|
||||||
"pre-refrain instrumental": "pre_refrain_instrumental",
|
'pre-refrain instrumental': 'pre_refrain_instrumental',
|
||||||
"instrumental pre-chorus": "pre_refrain_instrumental",
|
'instrumental pre-chorus': 'pre_refrain_instrumental',
|
||||||
"instrumental pre chorus": "pre_refrain_instrumental",
|
'instrumental pre chorus': 'pre_refrain_instrumental',
|
||||||
bridge: "pont",
|
bridge: 'pont',
|
||||||
guitar_solo: "solo_de_guitare",
|
guitar_solo: 'solo_de_guitare',
|
||||||
electric_guitar_solo: "solo_de_guitare_electrique",
|
electric_guitar_solo: 'solo_de_guitare_electrique',
|
||||||
drum_solo: "solo_de_batterie",
|
drum_solo: 'solo_de_batterie',
|
||||||
sax_solo: "solo_de_saxophone",
|
sax_solo: 'solo_de_saxophone',
|
||||||
violin_solo: "solo_de_violon",
|
violin_solo: 'solo_de_violon',
|
||||||
melodic_interlude: "interlude_melodique",
|
melodic_interlude: 'interlude_melodique',
|
||||||
grand_finale: "final_apogee",
|
grand_finale: 'final_apogee',
|
||||||
sudden_stop: "arret_net",
|
sudden_stop: 'arret_net',
|
||||||
soft_transition: "transition_douce",
|
soft_transition: 'transition_douce',
|
||||||
};
|
}
|
||||||
|
|
||||||
// --- UTILITAIRES ---
|
// --- UTILITAIRES ---
|
||||||
|
|
||||||
const normalizeStructureValue = (value) => {
|
const normalizeStructureValue = (value) => {
|
||||||
const raw = String(value || "")
|
const raw = String(value || '')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase()
|
||||||
if (!raw) return "";
|
if (!raw) return ''
|
||||||
if (STRUCTURE_PROMPT_LABELS[raw]) return raw;
|
if (STRUCTURE_PROMPT_LABELS[raw]) return raw
|
||||||
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw];
|
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw]
|
||||||
const sanitized = raw.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
const sanitized = raw.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||||
return STRUCTURE_PROMPT_LABELS[sanitized]
|
return STRUCTURE_PROMPT_LABELS[sanitized] ? sanitized : STRUCTURE_ALIASES[sanitized] || sanitized
|
||||||
? sanitized
|
}
|
||||||
: STRUCTURE_ALIASES[sanitized] || sanitized;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Nettoyage simplifié : on laisse l'IA gérer la logique musicale plutôt que de supprimer brutalement.
|
// Nettoyage simplifié : on laisse l'IA gérer la logique musicale plutôt que de supprimer brutalement.
|
||||||
// On s'assure juste que les clés sont propres.
|
// On s'assure juste que les clés sont propres.
|
||||||
const sanitizeStructureEntries = (structure = []) => {
|
const sanitizeStructureEntries = (structure = []) => {
|
||||||
if (!Array.isArray(structure)) return [];
|
if (!Array.isArray(structure)) return []
|
||||||
return structure.map(normalizeStructureValue).filter(Boolean);
|
return structure.map(normalizeStructureValue).filter(Boolean)
|
||||||
};
|
}
|
||||||
|
|
||||||
const mapStructureToPrompt = (structure = []) =>
|
const mapStructureToPrompt = (structure = []) =>
|
||||||
sanitizeStructureEntries(structure).map(
|
sanitizeStructureEntries(structure).map((entry) => STRUCTURE_PROMPT_LABELS[entry] || entry)
|
||||||
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- MODERATION ---
|
// --- MODERATION ---
|
||||||
|
|
||||||
@@ -101,40 +94,37 @@ const buildModerationBrief = ({
|
|||||||
rhymes,
|
rhymes,
|
||||||
}) => {
|
}) => {
|
||||||
const sections = [
|
const sections = [
|
||||||
`<OBJECTIF>${objective || ""}</OBJECTIF>`,
|
`<OBJECTIF>${objective || ''}</OBJECTIF>`,
|
||||||
`<CONTEXTE>${context || ""}</CONTEXTE>`,
|
`<CONTEXTE>${context || ''}</CONTEXTE>`,
|
||||||
`<EMOTION>${emotion || ""}</EMOTION>`,
|
`<EMOTION>${emotion || ''}</EMOTION>`,
|
||||||
`<STYLE>${style || ""}</STYLE>`,
|
`<STYLE>${style || ''}</STYLE>`,
|
||||||
`<AUDIENCE>${audience || ""}</AUDIENCE>`,
|
`<AUDIENCE>${audience || ''}</AUDIENCE>`,
|
||||||
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`,
|
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(', ') : rhymes || ''}</RIMES>`,
|
||||||
];
|
]
|
||||||
const promptStructure = mapStructureToPrompt(structure);
|
const promptStructure = mapStructureToPrompt(structure)
|
||||||
if (promptStructure.length)
|
if (promptStructure.length) sections.push(`<STRUCTURE>${promptStructure.join(' | ')}</STRUCTURE>`)
|
||||||
sections.push(`<STRUCTURE>${promptStructure.join(" | ")}</STRUCTURE>`);
|
return `<BRIEF_UTILISATEUR>\n${sections.join('\n')}\n</BRIEF_UTILISATEUR>`
|
||||||
return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// --- GENERATION DE PAROLES (MAIN) ---
|
// --- GENERATION DE PAROLES (MAIN) ---
|
||||||
|
|
||||||
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
|
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
objective = "",
|
objective = '',
|
||||||
context = "",
|
context = '',
|
||||||
style = "",
|
style = '',
|
||||||
audience = "",
|
audience = '',
|
||||||
emotion = "",
|
emotion = '',
|
||||||
structure: rawStructure = ["couplet", "refrain", "couplet", "refrain"],
|
structure: rawStructure = ['couplet', 'refrain', 'couplet', 'refrain'],
|
||||||
rhymes = "",
|
rhymes = '',
|
||||||
} = data;
|
} = data
|
||||||
|
|
||||||
// 1. Préparation Structure
|
// 1. Préparation Structure
|
||||||
const sanitizedStructure = sanitizeStructureEntries(rawStructure);
|
const sanitizedStructure = sanitizeStructureEntries(rawStructure)
|
||||||
const promptStructure = sanitizedStructure.length
|
const promptStructure = sanitizedStructure.length
|
||||||
? sanitizedStructure.map(
|
? sanitizedStructure.map((entry) => STRUCTURE_PROMPT_LABELS[entry] || entry)
|
||||||
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
|
: []
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// 2. Modération "Pro" (Via Gemini 1.5 Pro)
|
// 2. Modération "Pro" (Via Gemini 1.5 Pro)
|
||||||
// On supprime les regex manuelles obsolètes.
|
// On supprime les regex manuelles obsolètes.
|
||||||
@@ -146,40 +136,34 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
|
|||||||
emotion,
|
emotion,
|
||||||
structure: promptStructure,
|
structure: promptStructure,
|
||||||
rhymes,
|
rhymes,
|
||||||
};
|
}
|
||||||
const moderationInput = buildModerationBrief(moderationPayload);
|
const moderationInput = buildModerationBrief(moderationPayload)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const moderation = await analyseLyrics({
|
const moderation = await analyseLyrics({
|
||||||
title: "Brief utilisateur (Pré-génération)",
|
title: 'Brief utilisateur (Pré-génération)',
|
||||||
lyrics: moderationInput,
|
lyrics: moderationInput,
|
||||||
});
|
})
|
||||||
|
|
||||||
// Si Gemini dit "Blocked", on bloque. C'est la seule autorité.
|
// Si Gemini dit "Blocked", on bloque. C'est la seule autorité.
|
||||||
if (moderation?.blocked === true) {
|
if (moderation?.blocked === true) {
|
||||||
console.warn("⛔ generateLyrics blocked by Gemini Pro", {
|
console.warn('⛔ generateLyrics blocked by Gemini Pro', {
|
||||||
reasons: moderation.reasons,
|
reasons: moderation.reasons,
|
||||||
});
|
})
|
||||||
const summary = Array.isArray(moderation.reasons)
|
const summary = Array.isArray(moderation.reasons)
|
||||||
? moderation.reasons.slice(0, 3).join(", ")
|
? moderation.reasons.slice(0, 3).join(', ')
|
||||||
: "Contenu non conforme";
|
: 'Contenu non conforme'
|
||||||
throw new HttpsError(
|
throw new HttpsError('invalid-argument', `Demande refusée par la modération : ${summary}.`)
|
||||||
"invalid-argument",
|
|
||||||
`Demande refusée par la modération : ${summary}.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (moderationError) {
|
} catch (moderationError) {
|
||||||
if (moderationError instanceof HttpsError) throw moderationError;
|
if (moderationError instanceof HttpsError) throw moderationError
|
||||||
console.error("⚠️ Moderation check error (fail open)", moderationError);
|
console.error('⚠️ Moderation check error (fail open)', moderationError)
|
||||||
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique.
|
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique.
|
||||||
// Ici je fail closed par sécurité pour une app publique.
|
// Ici je fail closed par sécurité pour une app publique.
|
||||||
throw new HttpsError(
|
throw new HttpsError('internal', 'Vérification de sécurité indisponible.')
|
||||||
"internal",
|
|
||||||
"Vérification de sécurité indisponible.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🎵 generateLyrics: Start generation", { style, emotion });
|
console.log('🎵 generateLyrics: Start generation', { style, emotion })
|
||||||
|
|
||||||
// 3. Le Prompt "Hit Maker"
|
// 3. Le Prompt "Hit Maker"
|
||||||
const system = `
|
const system = `
|
||||||
@@ -197,27 +181,25 @@ TES RÈGLES D'OR :
|
|||||||
5. **VOCABULAIRE** : Adapte le niveau de langue au style (Argot pour le Rap, Soutenu pour la Chanson Française, Simple pour la Pop).
|
5. **VOCABULAIRE** : Adapte le niveau de langue au style (Argot pour le Rap, Soutenu pour la Chanson Française, Simple pour la Pop).
|
||||||
|
|
||||||
Retourne UNIQUEMENT un JSON valide.
|
Retourne UNIQUEMENT un JSON valide.
|
||||||
`.trim();
|
`.trim()
|
||||||
|
|
||||||
const structureTags = (
|
const structureTags = (Array.isArray(promptStructure) ? promptStructure : [])
|
||||||
Array.isArray(promptStructure) ? promptStructure : []
|
|
||||||
)
|
|
||||||
.map((part, index) => ` <SECTION ordre="${index + 1}">${part}</SECTION>`)
|
.map((part, index) => ` <SECTION ordre="${index + 1}">${part}</SECTION>`)
|
||||||
.join("\n");
|
.join('\n')
|
||||||
|
|
||||||
const fallbackStructureTags = [
|
const fallbackStructureTags = [
|
||||||
' <SECTION ordre="1">couplet</SECTION>',
|
' <SECTION ordre="1">couplet</SECTION>',
|
||||||
' <SECTION ordre="2">refrain</SECTION>',
|
' <SECTION ordre="2">refrain</SECTION>',
|
||||||
].join("\n");
|
].join('\n')
|
||||||
|
|
||||||
const prompt = `
|
const prompt = `
|
||||||
<BRIEF_CREATIF>
|
<BRIEF_CREATIF>
|
||||||
<OBJECTIF>${objective || "Créer une chanson mémorable"}</OBJECTIF>
|
<OBJECTIF>${objective || 'Créer une chanson mémorable'}</OBJECTIF>
|
||||||
<CONTEXTE>${context || "Libre interprétation"}</CONTEXTE>
|
<CONTEXTE>${context || 'Libre interprétation'}</CONTEXTE>
|
||||||
<EMOTION_DOMINANTE>${emotion || "Intense"}</EMOTION_DOMINANTE>
|
<EMOTION_DOMINANTE>${emotion || 'Intense'}</EMOTION_DOMINANTE>
|
||||||
<STYLE_MUSICAL>${style || "Pop Moderne"}</STYLE_MUSICAL>
|
<STYLE_MUSICAL>${style || 'Pop Moderne'}</STYLE_MUSICAL>
|
||||||
<CIBLE>${audience || "Tout public"}</CIBLE>
|
<CIBLE>${audience || 'Tout public'}</CIBLE>
|
||||||
<TYPE_DE_RIMES>${rhymes || "Rimes croisées et riches"}</TYPE_DE_RIMES>
|
<TYPE_DE_RIMES>${rhymes || 'Rimes croisées et riches'}</TYPE_DE_RIMES>
|
||||||
</BRIEF_CREATIF>
|
</BRIEF_CREATIF>
|
||||||
|
|
||||||
<STRUCTURE_IMPOSEE>
|
<STRUCTURE_IMPOSEE>
|
||||||
@@ -231,106 +213,97 @@ ${structureTags || fallbackStructureTags}
|
|||||||
- Si c'est "Couplet/Refrain" : Écris 4 à 12 vers.
|
- Si c'est "Couplet/Refrain" : Écris 4 à 12 vers.
|
||||||
3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre.
|
3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre.
|
||||||
</CONSIGNES_GENERATION>
|
</CONSIGNES_GENERATION>
|
||||||
`.trim();
|
`.trim()
|
||||||
|
|
||||||
const lyricsSchema = z.object({
|
const lyricsSchema = z.object({
|
||||||
title: z.string().describe("Titre de la chanson, court et accrocheur"),
|
title: z.string().describe('Titre de la chanson, court et accrocheur'),
|
||||||
lyrics: z
|
lyrics: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
type: z
|
type: z.string().describe('Type de section (copier exactement la demande structure)'),
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Type de section (copier exactement la demande structure)",
|
|
||||||
),
|
|
||||||
lyrics: z
|
lyrics: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"Les paroles. Pour les sections instrumentales, laisser vide ou décrire l'ambiance.",
|
"Les paroles. Pour les sections instrumentales, laisser vide ou décrire l'ambiance."
|
||||||
),
|
),
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
.describe("La structure complète de la chanson"),
|
.describe('La structure complète de la chanson'),
|
||||||
lyricsDescription: z
|
lyricsDescription: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"Un pitch de 2 phrases décrivant l'ambiance et le thème de la chanson pour Suno.",
|
"Un pitch de 2 phrases décrivant l'ambiance et le thème de la chanson pour Suno."
|
||||||
),
|
),
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
});
|
})
|
||||||
|
|
||||||
// Appel Gemini avec le nouveau modèle Pro configuré dans helpers/gemini
|
// Appel Gemini avec le nouveau modèle Pro configuré dans helpers/gemini
|
||||||
return await generateAI({
|
return await generateAI({
|
||||||
system,
|
system,
|
||||||
prompt,
|
prompt,
|
||||||
schema: lyricsSchema,
|
schema: lyricsSchema,
|
||||||
});
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("❌ generateLyrics Error:", e);
|
console.error('❌ generateLyrics Error:', e)
|
||||||
// Remontée d'erreur propre
|
// Remontée d'erreur propre
|
||||||
if (e instanceof HttpsError) throw e;
|
if (e instanceof HttpsError) throw e
|
||||||
throw new HttpsError(
|
throw new HttpsError('internal', 'Erreur lors de la génération des paroles.')
|
||||||
"internal",
|
|
||||||
"Erreur lors de la génération des paroles.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
// --- ANALYSE TOXICITÉ (EXISTANTE, NETTOYÉE) ---
|
// --- ANALYSE TOXICITÉ (EXISTANTE, NETTOYÉE) ---
|
||||||
// Cette fonction reste utile pour l'analyse POST-création ou pour l'interface UI
|
// Cette fonction reste utile pour l'analyse POST-création ou pour l'interface UI
|
||||||
|
|
||||||
const severityScore = (severity = "") => {
|
const severityScore = (severity = '') => {
|
||||||
const normalized = String(severity || "").toLowerCase();
|
const normalized = String(severity || '').toLowerCase()
|
||||||
if (normalized === "critical") return 4;
|
if (normalized === 'critical') return 4
|
||||||
if (normalized === "high") return 3;
|
if (normalized === 'high') return 3
|
||||||
if (normalized === "medium") return 2;
|
if (normalized === 'medium') return 2
|
||||||
if (normalized === "low") return 1;
|
if (normalized === 'low') return 1
|
||||||
return 0;
|
return 0
|
||||||
};
|
}
|
||||||
|
|
||||||
const softenModerationDecision = (rawResult = {}) => {
|
const softenModerationDecision = (rawResult = {}) => {
|
||||||
const result = { ...rawResult };
|
const result = { ...rawResult }
|
||||||
const excerpts = Array.isArray(result.excerpts) ? result.excerpts : [];
|
const excerpts = Array.isArray(result.excerpts) ? result.excerpts : []
|
||||||
const reasons = Array.isArray(result.reasons) ? result.reasons : [];
|
const reasons = Array.isArray(result.reasons) ? result.reasons : []
|
||||||
|
|
||||||
const highestSeverity = excerpts.reduce(
|
const highestSeverity = excerpts.reduce(
|
||||||
(max, excerpt) => Math.max(max, severityScore(excerpt?.severity)),
|
(max, excerpt) => Math.max(max, severityScore(excerpt?.severity)),
|
||||||
0,
|
0
|
||||||
);
|
)
|
||||||
const score =
|
const score =
|
||||||
typeof result.score === "number" && !Number.isNaN(result.score)
|
typeof result.score === 'number' && !Number.isNaN(result.score)
|
||||||
? Math.min(Math.max(result.score, 0), 1)
|
? Math.min(Math.max(result.score, 0), 1)
|
||||||
: 0;
|
: 0
|
||||||
|
|
||||||
// Détection de contexte narratif pour être plus indulgent
|
// Détection de contexte narratif pour être plus indulgent
|
||||||
const narrativeHint = reasons.some((reason) =>
|
const narrativeHint = reasons.some((reason) =>
|
||||||
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(
|
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(reason || '')
|
||||||
reason || "",
|
)
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const adjustments = [];
|
const adjustments = []
|
||||||
|
|
||||||
// Logique d'adoucissement : Si c'est "High" severity mais narratif, on peut parfois débloquer (selon ta politique).
|
// Logique d'adoucissement : Si c'est "High" severity mais narratif, on peut parfois débloquer (selon ta politique).
|
||||||
// Ici on reste prudent sur le block, mais on adoucit le flag.
|
// Ici on reste prudent sur le block, mais on adoucit le flag.
|
||||||
if (result.blocked) {
|
if (result.blocked) {
|
||||||
// Débloque uniquement les erreurs manifestes (score bas mais blocked true par erreur)
|
// Débloque uniquement les erreurs manifestes (score bas mais blocked true par erreur)
|
||||||
if (highestSeverity <= 1 && score < 0.65) {
|
if (highestSeverity <= 1 && score < 0.65) {
|
||||||
result.blocked = false;
|
result.blocked = false
|
||||||
adjustments.push("auto-unblock-low-severity");
|
adjustments.push('auto-unblock-low-severity')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.blocked && result.flagged) {
|
if (!result.blocked && result.flagged) {
|
||||||
const lowSignal = highestSeverity <= 1 && score < 0.4;
|
const lowSignal = highestSeverity <= 1 && score < 0.4
|
||||||
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55;
|
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55
|
||||||
|
|
||||||
if (lowSignal) {
|
if (lowSignal) {
|
||||||
result.flagged = false;
|
result.flagged = false
|
||||||
adjustments.push("drop-flag-low-signal");
|
adjustments.push('drop-flag-low-signal')
|
||||||
} else if (contextual) {
|
} else if (contextual) {
|
||||||
result.flagged = false;
|
result.flagged = false
|
||||||
adjustments.push("drop-flag-contextual");
|
adjustments.push('drop-flag-contextual')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,8 +311,8 @@ const softenModerationDecision = (rawResult = {}) => {
|
|||||||
...result,
|
...result,
|
||||||
moderationAdjustments: adjustments,
|
moderationAdjustments: adjustments,
|
||||||
moderationCalibration: { highestSeverity, score },
|
moderationCalibration: { highestSeverity, score },
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
|
exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
|
||||||
try {
|
try {
|
||||||
@@ -356,93 +329,91 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
|
|||||||
z.object({
|
z.object({
|
||||||
type: z.string().optional(),
|
type: z.string().optional(),
|
||||||
lyrics: z.string().optional(),
|
lyrics: z.string().optional(),
|
||||||
}),
|
})
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
.describe("Paroles à analyser"),
|
.describe('Paroles à analyser'),
|
||||||
});
|
})
|
||||||
|
|
||||||
const parsed = requestSchema.parse(data || {});
|
const parsed = requestSchema.parse(data || {})
|
||||||
const aiResult = await analyseLyrics({
|
const aiResult = await analyseLyrics({
|
||||||
title: parsed.title || "",
|
title: parsed.title || '',
|
||||||
lyrics: parsed.lyrics,
|
lyrics: parsed.lyrics,
|
||||||
});
|
})
|
||||||
const result = softenModerationDecision(aiResult);
|
const result = softenModerationDecision(aiResult)
|
||||||
|
|
||||||
const highCats = Object.entries(result.categories || {}) // Note: categories n'est pas dans le schema Zod actuel de Gemini, à vérifier si besoin
|
const highCats = Object.entries(result.categories || {}) // Note: categories n'est pas dans le schema Zod actuel de Gemini, à vérifier si besoin
|
||||||
.filter(([, v]) => (typeof v === "number" ? v : 0) >= 0.5)
|
.filter(([, v]) => (typeof v === 'number' ? v : 0) >= 0.5)
|
||||||
.map(([k]) => k)
|
.map(([k]) => k)
|
||||||
.slice(0, 5);
|
.slice(0, 5)
|
||||||
|
|
||||||
let errorCode = "OK";
|
let errorCode = 'OK'
|
||||||
let message = "Analyse effectuée: aucun blocage.";
|
let message = 'Analyse effectuée: aucun blocage.'
|
||||||
|
|
||||||
if (result.blocked) {
|
if (result.blocked) {
|
||||||
errorCode = "TOXIC_CONTENT_BLOCKED";
|
errorCode = 'TOXIC_CONTENT_BLOCKED'
|
||||||
message = `Contenu bloqué par sécurité.`;
|
message = `Contenu bloqué par sécurité.`
|
||||||
} else if (result.flagged) {
|
} else if (result.flagged) {
|
||||||
errorCode = "TOXIC_CONTENT_FLAGGED";
|
errorCode = 'TOXIC_CONTENT_FLAGGED'
|
||||||
message = `Attention: contenu sensible détecté.`;
|
message = `Attention: contenu sensible détecté.`
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: !result.blocked, errorCode, message, result };
|
return { success: !result.blocked, errorCode, message, result }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("analyseLyricsToxicity failed", err?.message || err);
|
console.error('analyseLyricsToxicity failed', err?.message || err)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
errorCode: "ANALYSE_FAILED",
|
errorCode: 'ANALYSE_FAILED',
|
||||||
message: "Erreur analyse toxicité.",
|
message: 'Erreur analyse toxicité.',
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// --- SUNO TIMESTAMPS (EXISTANT) ---
|
// --- SUNO TIMESTAMPS (EXISTANT) ---
|
||||||
|
|
||||||
async function getSunoTimestamps(projectId) {
|
async function getSunoTimestamps(projectId) {
|
||||||
try {
|
try {
|
||||||
if (!projectId || typeof projectId !== "string")
|
if (!projectId || typeof projectId !== 'string') throw new Error('projectId invalide')
|
||||||
throw new Error("projectId invalide");
|
|
||||||
|
|
||||||
const docSnap = await refList.projects.doc(projectId).get();
|
const docSnap = await refList.projects.doc(projectId).get()
|
||||||
if (!docSnap.exists) throw new Error("Projet introuvable");
|
if (!docSnap.exists) throw new Error('Projet introuvable')
|
||||||
|
|
||||||
const { sunoTaskId, songIndex } = docSnap.data();
|
const { sunoTaskId, songIndex } = docSnap.data()
|
||||||
if (!sunoTaskId) throw new Error("TaskId manquant");
|
if (!sunoTaskId) throw new Error('TaskId manquant')
|
||||||
if (songIndex === undefined || songIndex < 0)
|
if (songIndex === undefined || songIndex < 0) throw new Error('musicIndex invalide')
|
||||||
throw new Error("musicIndex invalide");
|
|
||||||
|
|
||||||
console.log("🔎 getSunoTimestamps", { sunoTaskId, songIndex });
|
console.log('🔎 getSunoTimestamps', { sunoTaskId, songIndex })
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
|
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
|
||||||
{ taskId: sunoTaskId, musicIndex: songIndex },
|
{ taskId: sunoTaskId, musicIndex: songIndex },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${SUNO_API_KEY}`,
|
Authorization: `Bearer ${SUNO_API_KEY}`,
|
||||||
},
|
},
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
const dataToReturn = response.data?.data || response.data || {};
|
const dataToReturn = response.data?.data || response.data || {}
|
||||||
|
|
||||||
await refList.projects.doc(projectId).set(
|
await refList.projects.doc(projectId).set(
|
||||||
{
|
{
|
||||||
musicTimestamps: { [songIndex]: dataToReturn },
|
musicTimestamps: { [songIndex]: dataToReturn },
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
|
|
||||||
return { success: true };
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ getSunoTimestamps Error:", error.message);
|
console.error('❌ getSunoTimestamps Error:', error.message)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: { message: error.message, type: "INTERNAL_ERROR" },
|
error: { message: error.message, type: 'INTERNAL_ERROR' },
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.getSunoTimestamps = getSunoTimestamps;
|
exports.getSunoTimestamps = getSunoTimestamps
|
||||||
|
|||||||
+320
-367
@@ -1,28 +1,24 @@
|
|||||||
const {
|
const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https')
|
||||||
onCall,
|
const axios = require('axios')
|
||||||
onRequest,
|
const admin = require('firebase-admin')
|
||||||
HttpsError,
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
} = require("firebase-functions/v2/https");
|
const { logger } = require('firebase-functions/logger')
|
||||||
const axios = require("axios");
|
const { pipeline } = require('stream/promises')
|
||||||
const admin = require("firebase-admin");
|
const { randomUUID } = require('crypto')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { ALERT_TYPE, refList } = require('../index')
|
||||||
const { logger } = require("firebase-functions/logger");
|
const { sendNotification } = require('./notifications')
|
||||||
const { pipeline } = require("stream/promises");
|
const { createOrderDocument, ORDER_TYPES } = require('./helpers/orders')
|
||||||
const { randomUUID } = require("crypto");
|
const { SUNO_API_KEY } = require('../config/keys')
|
||||||
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 {
|
const {
|
||||||
SUNO_MODEL,
|
SUNO_MODEL,
|
||||||
SUNO_CALLBACK_URL,
|
SUNO_CALLBACK_URL,
|
||||||
SUNO_API_BASE,
|
SUNO_API_BASE,
|
||||||
SUNO_API_PATH,
|
SUNO_API_PATH,
|
||||||
SUNO_STATUS_PATH,
|
SUNO_STATUS_PATH,
|
||||||
} = require("../config/suno");
|
} = require('../config/suno')
|
||||||
|
|
||||||
const MUSIC_GENERATION_CREDIT_COST = 8;
|
const MUSIC_GENERATION_CREDIT_COST = 8
|
||||||
const MUSIC_REFUND_SOURCE = "music_generation_refund";
|
const MUSIC_REFUND_SOURCE = 'music_generation_refund'
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO)
|
// 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO)
|
||||||
@@ -30,98 +26,98 @@ const MUSIC_REFUND_SOURCE = "music_generation_refund";
|
|||||||
|
|
||||||
const STYLE_MAP = {
|
const STYLE_MAP = {
|
||||||
// Mapping du SONG_STYLE
|
// Mapping du SONG_STYLE
|
||||||
Upbeat: "Upbeat, Happy, Feel-good",
|
Upbeat: 'Upbeat, Happy, Feel-good',
|
||||||
"Grandios(e)": "Grand, Cinematic, Orchestral",
|
'Grandios(e)': 'Grand, Cinematic, Orchestral',
|
||||||
Chill: "Chill, Relaxed, Downtempo",
|
Chill: 'Chill, Relaxed, Downtempo',
|
||||||
Epic: "Epic, Heroic, Trailer Music",
|
Epic: 'Epic, Heroic, Trailer Music',
|
||||||
Dramatic: "Dramatic, Intense, Theatrical",
|
Dramatic: 'Dramatic, Intense, Theatrical',
|
||||||
Comedic: "Comedy, Novelty, Funny",
|
Comedic: 'Comedy, Novelty, Funny',
|
||||||
Theatrical: "Musical Theater, Broadway, Storytelling",
|
Theatrical: 'Musical Theater, Broadway, Storytelling',
|
||||||
Flamboyant: "Flamboyant, Glam, Exuberant",
|
Flamboyant: 'Flamboyant, Glam, Exuberant',
|
||||||
Mélancolique: "Melancholic, Sad, Emotional",
|
Mélancolique: 'Melancholic, Sad, Emotional',
|
||||||
Introspective: "Introspective, Deep, Thoughtful",
|
Introspective: 'Introspective, Deep, Thoughtful',
|
||||||
"Urban Tragedy": "Urban, Dark, Grit, Cinematic",
|
'Urban Tragedy': 'Urban, Dark, Grit, Cinematic',
|
||||||
Eerie: "Eerie, Haunting, Spooky",
|
Eerie: 'Eerie, Haunting, Spooky',
|
||||||
Mysterious: "Mysterious, Enigmatic, Suspenseful",
|
Mysterious: 'Mysterious, Enigmatic, Suspenseful',
|
||||||
};
|
}
|
||||||
|
|
||||||
const GENRE_MAP = {
|
const GENRE_MAP = {
|
||||||
// Mapping du CHOOSE_GENRE
|
// Mapping du CHOOSE_GENRE
|
||||||
Pop: "Pop",
|
Pop: 'Pop',
|
||||||
"Hip Hop/Rap": "Hip Hop, Rap",
|
'Hip Hop/Rap': 'Hip Hop, Rap',
|
||||||
Soul: "Soul, Neo-Soul",
|
Soul: 'Soul, Neo-Soul',
|
||||||
Blues: "Blues, Delta Blues",
|
Blues: 'Blues, Delta Blues',
|
||||||
Folk: "Folk, Acoustic",
|
Folk: 'Folk, Acoustic',
|
||||||
Punk: "Punk Rock, High Energy",
|
Punk: 'Punk Rock, High Energy',
|
||||||
Dance: "Dance, Club",
|
Dance: 'Dance, Club',
|
||||||
Grunge: "Grunge, Distorted",
|
Grunge: 'Grunge, Distorted',
|
||||||
EDM: "EDM, Electronic",
|
EDM: 'EDM, Electronic',
|
||||||
Trap: "Trap, 808s",
|
Trap: 'Trap, 808s',
|
||||||
Latino: "Latin, Reggaeton",
|
Latino: 'Latin, Reggaeton',
|
||||||
Dancehall: "Dancehall, Island",
|
Dancehall: 'Dancehall, Island',
|
||||||
"Latin Pop": "Latin Pop",
|
'Latin Pop': 'Latin Pop',
|
||||||
Raggaeton: "Reggaeton, Urbano",
|
Raggaeton: 'Reggaeton, Urbano',
|
||||||
Rock: "Rock",
|
Rock: 'Rock',
|
||||||
"Rock progressif": "Prog Rock, Complex",
|
'Rock progressif': 'Prog Rock, Complex',
|
||||||
"Hard Rock": "Hard Rock",
|
'Hard Rock': 'Hard Rock',
|
||||||
Metal: "Heavy Metal",
|
Metal: 'Heavy Metal',
|
||||||
"R&B": "R&B, Contemporary R&B",
|
'R&B': 'R&B, Contemporary R&B',
|
||||||
Phonk: "Phonk, Memphis Rap, Drift",
|
Phonk: 'Phonk, Memphis Rap, Drift',
|
||||||
House: "House, Deep House",
|
House: 'House, Deep House',
|
||||||
Alternative: "Alternative Rock, Indie",
|
Alternative: 'Alternative Rock, Indie',
|
||||||
Indie: "Indie Pop",
|
Indie: 'Indie Pop',
|
||||||
Country: "Country, Americana",
|
Country: 'Country, Americana',
|
||||||
Synthwave: "Synthwave, Retrowave, 80s",
|
Synthwave: 'Synthwave, Retrowave, 80s',
|
||||||
Afrobeat: "Afrobeat, African Rhythms",
|
Afrobeat: 'Afrobeat, African Rhythms',
|
||||||
"K-Pop": "K-Pop, Idol",
|
'K-Pop': 'K-Pop, Idol',
|
||||||
Techno: "Techno, Minimal",
|
Techno: 'Techno, Minimal',
|
||||||
Funk: "Funk, Groove",
|
Funk: 'Funk, Groove',
|
||||||
Disco: "Disco, Nu-Disco",
|
Disco: 'Disco, Nu-Disco',
|
||||||
"New wave": "New Wave, Post-Punk",
|
'New wave': 'New Wave, Post-Punk',
|
||||||
Jazz: "Jazz, Smooth Jazz",
|
Jazz: 'Jazz, Smooth Jazz',
|
||||||
"Lo-Fi": "Lo-Fi, Chillhop",
|
'Lo-Fi': 'Lo-Fi, Chillhop',
|
||||||
"Bedroom Pop": "Bedroom Pop, Dreamy",
|
'Bedroom Pop': 'Bedroom Pop, Dreamy',
|
||||||
Ambient: "Ambient, Atmospheric",
|
Ambient: 'Ambient, Atmospheric',
|
||||||
"Dream Pop": "Dream Pop, Shoegaze",
|
'Dream Pop': 'Dream Pop, Shoegaze',
|
||||||
Grime: "Grime, UK Rap",
|
Grime: 'Grime, UK Rap',
|
||||||
Hyperpop: "Hyperpop, Glitch",
|
Hyperpop: 'Hyperpop, Glitch',
|
||||||
Gospel: "Gospel, Spiritual",
|
Gospel: 'Gospel, Spiritual',
|
||||||
};
|
}
|
||||||
|
|
||||||
const INSTRUMENT_MAP = {
|
const INSTRUMENT_MAP = {
|
||||||
"Piano classique": "Grand Piano",
|
'Piano classique': 'Grand Piano',
|
||||||
"Piano éléctrique": "Electric Piano, Rhodes",
|
'Piano éléctrique': 'Electric Piano, Rhodes',
|
||||||
Synthétiseur: "Synthesizer",
|
Synthétiseur: 'Synthesizer',
|
||||||
"Guitare acoustique": "Acoustic Guitar",
|
'Guitare acoustique': 'Acoustic Guitar',
|
||||||
"Guitare électrique": "Electric Guitar",
|
'Guitare électrique': 'Electric Guitar',
|
||||||
Batterie: "Drums",
|
Batterie: 'Drums',
|
||||||
Banjo: "Banjo",
|
Banjo: 'Banjo',
|
||||||
Violon: "Violin, Strings",
|
Violon: 'Violin, Strings',
|
||||||
Saxophone: "Saxophone",
|
Saxophone: 'Saxophone',
|
||||||
"Saxophone alto": "Alto Sax",
|
'Saxophone alto': 'Alto Sax',
|
||||||
Trompette: "Trumpet",
|
Trompette: 'Trumpet',
|
||||||
Flûte: "Flute",
|
Flûte: 'Flute',
|
||||||
Clarinette: "Clarinet",
|
Clarinette: 'Clarinet',
|
||||||
Djembe: "Percussion, Djembe",
|
Djembe: 'Percussion, Djembe',
|
||||||
Bongos: "Bongos",
|
Bongos: 'Bongos',
|
||||||
Congas: "Congas",
|
Congas: 'Congas',
|
||||||
Harmonica: "Harmonica",
|
Harmonica: 'Harmonica',
|
||||||
Handpan: "Handpan",
|
Handpan: 'Handpan',
|
||||||
Harpe: "Harp",
|
Harpe: 'Harp',
|
||||||
Xylophone: "Xylophone, Mallets",
|
Xylophone: 'Xylophone, Mallets',
|
||||||
Mandoline: "Mandolin",
|
Mandoline: 'Mandolin',
|
||||||
Accordéon: "Accordion",
|
Accordéon: 'Accordion',
|
||||||
Orgue: "Organ",
|
Orgue: 'Organ',
|
||||||
Electronique: "Electronic Fx",
|
Electronique: 'Electronic Fx',
|
||||||
};
|
}
|
||||||
|
|
||||||
const RHYTHM_MAP = {
|
const RHYTHM_MAP = {
|
||||||
"Très rapide": "Very Fast Tempo, High BPM",
|
'Très rapide': 'Very Fast Tempo, High BPM',
|
||||||
Rapide: "Fast Tempo",
|
Rapide: 'Fast Tempo',
|
||||||
Normal: "Mid-tempo",
|
Normal: 'Mid-tempo',
|
||||||
Lent: "Slow Tempo, Downtempo",
|
Lent: 'Slow Tempo, Downtempo',
|
||||||
"Très lent": "Very Slow, Ballad",
|
'Très lent': 'Very Slow, Ballad',
|
||||||
};
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 2. FONCTIONS DE PARSING (VOIX & STRUCTURE)
|
// 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
|
* Analyse les phrases longues du frontend pour extraire les tags vocaux Suno
|
||||||
*/
|
*/
|
||||||
function parseVoiceTags(voiceSelections = []) {
|
function parseVoiceTags(voiceSelections = []) {
|
||||||
if (!Array.isArray(voiceSelections)) return [];
|
if (!Array.isArray(voiceSelections)) return []
|
||||||
|
|
||||||
// Concatène tout pour recherche regex (Base + Sensibilité + Technique)
|
// Concatène tout pour recherche regex (Base + Sensibilité + Technique)
|
||||||
const fullText = voiceSelections.join(" ").toLowerCase();
|
const fullText = voiceSelections.join(' ').toLowerCase()
|
||||||
const tags = [];
|
const tags = []
|
||||||
|
|
||||||
// Genre
|
// Genre
|
||||||
if (fullText.includes("féminine") || fullText.includes("femme"))
|
if (fullText.includes('féminine') || fullText.includes('femme')) tags.push('Female Vocals')
|
||||||
tags.push("Female Vocals");
|
if (fullText.includes('masculine') || fullText.includes('homme')) tags.push('Male Vocals')
|
||||||
if (fullText.includes("masculine") || fullText.includes("homme"))
|
if (fullText.includes('deux voix') || fullText.includes('duo')) tags.push('Duet')
|
||||||
tags.push("Male Vocals");
|
if (fullText.includes('chœur') || fullText.includes('gospel')) tags.push('Choir, Backing Vocals')
|
||||||
if (fullText.includes("deux voix") || fullText.includes("duo"))
|
if (fullText.includes('enfant')) tags.push('Youthful Vocals')
|
||||||
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
|
// Style / Technique
|
||||||
if (fullText.includes("rap") || fullText.includes("slam"))
|
if (fullText.includes('rap') || fullText.includes('slam')) tags.push('Rapping, Flow')
|
||||||
tags.push("Rapping, Flow");
|
if (fullText.includes('parlé') || fullText.includes('raconte'))
|
||||||
if (fullText.includes("parlé") || fullText.includes("raconte"))
|
tags.push('Spoken Word, Narration')
|
||||||
tags.push("Spoken Word, Narration");
|
if (fullText.includes('cri') || fullText.includes('scream')) tags.push('Screaming, Aggressive')
|
||||||
if (fullText.includes("cri") || fullText.includes("scream"))
|
if (fullText.includes('murmure') || fullText.includes('chuchote'))
|
||||||
tags.push("Screaming, Aggressive");
|
tags.push('Whispering, Intimate')
|
||||||
if (fullText.includes("murmure") || fullText.includes("chuchote"))
|
if (fullText.includes('robot') || fullText.includes('synthétique')) tags.push('Autotune, Robotic')
|
||||||
tags.push("Whispering, Intimate");
|
if (fullText.includes('puissant')) tags.push('Powerful, Belting')
|
||||||
if (fullText.includes("robot") || fullText.includes("synthétique"))
|
if (fullText.includes('aérienne') || fullText.includes('légère')) tags.push('Airy, Ethereal')
|
||||||
tags.push("Autotune, Robotic");
|
if (fullText.includes('rauque') || fullText.includes('granuleuse')) tags.push('Raspy, Gritty')
|
||||||
if (fullText.includes("puissant")) tags.push("Powerful, Belting");
|
if (fullText.includes('opéra') || fullText.includes('soprano')) tags.push('Operatic')
|
||||||
if (fullText.includes("aérienne") || fullText.includes("légère"))
|
if (fullText.includes('sensuelle') || fullText.includes('séduisante'))
|
||||||
tags.push("Airy, Ethereal");
|
tags.push('Seductive, Breathless')
|
||||||
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
|
* Convertit les phrases de structure custom en Balises Suno
|
||||||
*/
|
*/
|
||||||
function mapStructureToTag(description) {
|
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') && d.includes('court')) return '[Short Intro]'
|
||||||
if (d.includes("intro")) return "[Intro]";
|
if (d.includes('intro')) return '[Intro]'
|
||||||
if (d.includes("pré-refrain")) return "[Pre-Chorus]";
|
if (d.includes('pré-refrain')) return '[Pre-Chorus]'
|
||||||
if (d.includes("solo guitare électrique")) return "[Electric Guitar Solo]";
|
if (d.includes('solo guitare électrique')) return '[Electric Guitar Solo]'
|
||||||
if (d.includes("solo guitare")) return "[Guitar Solo]";
|
if (d.includes('solo guitare')) return '[Guitar Solo]'
|
||||||
if (d.includes("solo batterie")) return "[Drum Solo]";
|
if (d.includes('solo batterie')) return '[Drum Solo]'
|
||||||
if (d.includes("solo saxo")) return "[Saxophone Solo]";
|
if (d.includes('solo saxo')) return '[Saxophone Solo]'
|
||||||
if (d.includes("solo violon")) return "[Violin Solo]";
|
if (d.includes('solo violon')) return '[Violin Solo]'
|
||||||
if (d.includes("interlude")) return "[Instrumental Interlude]";
|
if (d.includes('interlude')) return '[Instrumental Interlude]'
|
||||||
if (d.includes("apogée") || d.includes("finition")) return "[Big Finish]";
|
if (d.includes('apogée') || d.includes('finition')) return '[Big Finish]'
|
||||||
if (d.includes("arrêt net")) return "[Sudden End]";
|
if (d.includes('arrêt net')) return '[Sudden End]'
|
||||||
if (d.includes("baissant le volume") || d.includes("fade"))
|
if (d.includes('baissant le volume') || d.includes('fade')) return '[Fade Out]'
|
||||||
return "[Fade Out]";
|
if (d.includes('silence')) return '[Fade to Silence]'
|
||||||
if (d.includes("silence")) return "[Fade to Silence]";
|
if (d.includes('break') || d.includes('pause')) return '[Break]'
|
||||||
if (d.includes("break") || d.includes("pause")) return "[Break]";
|
|
||||||
|
|
||||||
// Mapping standard des types lyrics
|
// Mapping standard des types lyrics
|
||||||
if (d === "couplet" || d === "verse") return "[Verse]";
|
if (d === 'couplet' || d === 'verse') return '[Verse]'
|
||||||
if (d === "refrain" || d === "chorus") return "[Chorus]";
|
if (d === 'refrain' || d === 'chorus') return '[Chorus]'
|
||||||
if (d === "pont" || d === "bridge") return "[Bridge]";
|
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)
|
* Génère la chaîne de style optimisée (max ~200 chars)
|
||||||
*/
|
*/
|
||||||
function buildSunoStyle({ genres, songStyle, voiceData, instruments, rhythm }) {
|
function buildSunoStyle({ genres, songStyle, voiceData, instruments, rhythm }) {
|
||||||
const parts = [];
|
const parts = []
|
||||||
|
|
||||||
// 1. Genres (Priorité 1)
|
// 1. Genres (Priorité 1)
|
||||||
if (Array.isArray(genres)) {
|
if (Array.isArray(genres)) {
|
||||||
genres.forEach((g) => {
|
genres.forEach((g) => {
|
||||||
if (GENRE_MAP[g]) parts.push(GENRE_MAP[g]);
|
if (GENRE_MAP[g]) parts.push(GENRE_MAP[g])
|
||||||
else parts.push(g);
|
else parts.push(g)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Song Style / Vibe (Priorité 2)
|
// 2. Song Style / Vibe (Priorité 2)
|
||||||
if (songStyle && STYLE_MAP[songStyle]) {
|
if (songStyle && STYLE_MAP[songStyle]) {
|
||||||
parts.push(STYLE_MAP[songStyle]);
|
parts.push(STYLE_MAP[songStyle])
|
||||||
} else if (songStyle && songStyle !== "Autre") {
|
} else if (songStyle && songStyle !== 'Autre') {
|
||||||
parts.push(songStyle);
|
parts.push(songStyle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Rhythm (Priorité 3)
|
// 3. Rhythm (Priorité 3)
|
||||||
if (rhythm && RHYTHM_MAP[rhythm]) {
|
if (rhythm && RHYTHM_MAP[rhythm]) {
|
||||||
parts.push(RHYTHM_MAP[rhythm]);
|
parts.push(RHYTHM_MAP[rhythm])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Instruments
|
// 4. Instruments
|
||||||
if (Array.isArray(instruments)) {
|
if (Array.isArray(instruments)) {
|
||||||
instruments.slice(0, 3).forEach((i) => {
|
instruments.slice(0, 3).forEach((i) => {
|
||||||
// Max 3 instruments pour ne pas diluer
|
// 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
|
// 5. Vocals
|
||||||
const vocalTags = parseVoiceTags(voiceData);
|
const vocalTags = parseVoiceTags(voiceData)
|
||||||
parts.push(...vocalTags);
|
parts.push(...vocalTags)
|
||||||
|
|
||||||
// Tags de qualité technique (toujours ajoutés)
|
// Tags de qualité technique (toujours ajoutés)
|
||||||
parts.push("High Fidelity", "Stereo");
|
parts.push('High Fidelity', 'Stereo')
|
||||||
|
|
||||||
// Déduplication et join
|
// Déduplication et join
|
||||||
const uniqueStyle = [...new Set(parts)];
|
const uniqueStyle = [...new Set(parts)]
|
||||||
return clampLen(uniqueStyle.join(", "), 250);
|
return clampLen(uniqueStyle.join(', '), 250)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit le texte final des paroles avec les balises de structure
|
* Construit le texte final des paroles avec les balises de structure
|
||||||
*/
|
*/
|
||||||
function buildFormattedLyrics(lyricsArray) {
|
function buildFormattedLyrics(lyricsArray) {
|
||||||
if (!Array.isArray(lyricsArray) || lyricsArray.length === 0) return "";
|
if (!Array.isArray(lyricsArray) || lyricsArray.length === 0) return ''
|
||||||
|
|
||||||
const formattedLines = lyricsArray.map((section) => {
|
const formattedLines = lyricsArray.map((section) => {
|
||||||
// Le frontend peut envoyer soit { type: "...", lyrics: "..." } soit juste une string description pour les instrumentaux
|
// Le frontend peut envoyer soit { type: "...", lyrics: "..." } soit juste une string description pour les instrumentaux
|
||||||
const typeOrDescription = section.type || section.description || "";
|
const typeOrDescription = section.type || section.description || ''
|
||||||
const textContent = section.lyrics || "";
|
const textContent = section.lyrics || ''
|
||||||
|
|
||||||
// Essayer de trouver un tag spécial (ex: "Introduction instrumentale")
|
// 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
|
// Fallback si pas de tag spécial trouvé mais type standard
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
if (typeOrDescription.toLowerCase().includes("couplet")) tag = "[Verse]";
|
if (typeOrDescription.toLowerCase().includes('couplet')) tag = '[Verse]'
|
||||||
else if (typeOrDescription.toLowerCase().includes("refrain"))
|
else if (typeOrDescription.toLowerCase().includes('refrain')) tag = '[Chorus]'
|
||||||
tag = "[Chorus]";
|
else tag = `[${typeOrDescription}]` // Fallback générique
|
||||||
else tag = `[${typeOrDescription}]`; // Fallback générique
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si c'est une section instrumentale (pas de lyrics)
|
// Si c'est une section instrumentale (pas de lyrics)
|
||||||
if (!textContent.trim()) {
|
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)
|
// Sécurité: Ajouter Intro et Outro si absents (Suno best practice)
|
||||||
const fullText = formattedLines.join("\n");
|
const fullText = formattedLines.join('\n')
|
||||||
let finalPrompt = fullText;
|
let finalPrompt = fullText
|
||||||
|
|
||||||
if (!fullText.includes("[Intro]")) {
|
if (!fullText.includes('[Intro]')) {
|
||||||
finalPrompt = "[Intro]\n" + finalPrompt;
|
finalPrompt = '[Intro]\n' + finalPrompt
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!fullText.includes("[Outro]") &&
|
!fullText.includes('[Outro]') &&
|
||||||
!fullText.includes("[Fade Out]") &&
|
!fullText.includes('[Fade Out]') &&
|
||||||
!fullText.includes("[Sudden End]")
|
!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)
|
// 4. FONCTIONS UTILITAIRES DE BASE (GARDÉES)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
function clampLen(str = "", max) {
|
function clampLen(str = '', max) {
|
||||||
if (!max) return str || "";
|
if (!max) return str || ''
|
||||||
if (!str) return "";
|
if (!str) return ''
|
||||||
return str.length <= max ? str : str.slice(0, max);
|
return str.length <= max ? str : str.slice(0, max)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizeField = (value, fallback = null) => {
|
const sanitizeField = (value, fallback = null) => {
|
||||||
const cleaned = typeof value === "string" ? value.trim() : value;
|
const cleaned = typeof value === 'string' ? value.trim() : value
|
||||||
return typeof cleaned !== "string" || !cleaned ? fallback : cleaned;
|
return typeof cleaned !== 'string' || !cleaned ? fallback : cleaned
|
||||||
};
|
}
|
||||||
|
|
||||||
const sanitizeMusicUrls = (urls = []) =>
|
const sanitizeMusicUrls = (urls = []) =>
|
||||||
(Array.isArray(urls) ? urls : [])
|
(Array.isArray(urls) ? urls : [])
|
||||||
.filter((url) => typeof url === "string" && url.trim())
|
.filter((url) => typeof url === 'string' && url.trim())
|
||||||
.map((url) => url.trim());
|
.map((url) => url.trim())
|
||||||
|
|
||||||
const formatProjectMeta = (projectData = {}) => {
|
const formatProjectMeta = (projectData = {}) => {
|
||||||
const userId = sanitizeField(projectData?.userId);
|
const userId = sanitizeField(projectData?.userId)
|
||||||
const projectTitle = sanitizeField(projectData?.title, "ton projet");
|
const projectTitle = sanitizeField(projectData?.title, 'ton projet')
|
||||||
return { userId, projectTitle };
|
return { userId, projectTitle }
|
||||||
};
|
}
|
||||||
|
|
||||||
const parseSunoCallbackPayload = (rawBody = {}) => {
|
const parseSunoCallbackPayload = (rawBody = {}) => {
|
||||||
const body = rawBody || {};
|
const body = rawBody || {}
|
||||||
const code = body.code ?? body.statusCode ?? null;
|
const code = body.code ?? body.statusCode ?? null
|
||||||
const callbackType = (body?.data?.callbackType || "")
|
const callbackType = (body?.data?.callbackType || '').toString().toLowerCase()
|
||||||
.toString()
|
const status = (body.status || body.state || callbackType || '').toString().toLowerCase()
|
||||||
.toLowerCase();
|
const taskId = sanitizeField(body?.data?.task_id)
|
||||||
const status = (body.status || body.state || callbackType || "")
|
|
||||||
.toString()
|
|
||||||
.toLowerCase();
|
|
||||||
const taskId = sanitizeField(body?.data?.task_id);
|
|
||||||
const tracks = Array.isArray(body?.data?.data)
|
const tracks = Array.isArray(body?.data?.data)
|
||||||
? body.data.data
|
? body.data.data
|
||||||
: Array.isArray(body.data)
|
: Array.isArray(body.data)
|
||||||
? body.data
|
? body.data
|
||||||
: [];
|
: []
|
||||||
return { code, status, taskId, tracks };
|
return { code, status, taskId, tracks }
|
||||||
};
|
}
|
||||||
|
|
||||||
const extractAudioUrlsFromTracks = (tracks = []) =>
|
const extractAudioUrlsFromTracks = (tracks = []) =>
|
||||||
tracks
|
tracks
|
||||||
.map(
|
.map((t) => t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl)
|
||||||
(t) =>
|
|
||||||
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl,
|
|
||||||
)
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 2);
|
.slice(0, 2)
|
||||||
|
|
||||||
const fetchProjectByTaskId = async (taskId) => {
|
const fetchProjectByTaskId = async (taskId) => {
|
||||||
const snapshot = await refList.projects
|
const snapshot = await refList.projects.where('sunoTaskId', '==', taskId).limit(1).get()
|
||||||
.where("sunoTaskId", "==", taskId)
|
if (snapshot.empty) throw new Error('PROJECT_NOT_FOUND_FOR_TASK')
|
||||||
.limit(1)
|
const doc = snapshot.docs[0]
|
||||||
.get();
|
|
||||||
if (snapshot.empty) throw new Error("PROJECT_NOT_FOUND_FOR_TASK");
|
|
||||||
const doc = snapshot.docs[0];
|
|
||||||
return {
|
return {
|
||||||
projectId: doc.id,
|
projectId: doc.id,
|
||||||
projectData: doc.data() || {},
|
projectData: doc.data() || {},
|
||||||
projectRef: doc.ref,
|
projectRef: doc.ref,
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// ... (Garde refundMusicCredits, markProjectMusicFailure, downloadTrackToStorage, saveTracksToStorage, mergeMusicUrls inchangés) ...
|
// ... (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
|
// 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 ({
|
const refundMusicCredits = async ({
|
||||||
projectId,
|
projectId,
|
||||||
userId,
|
userId,
|
||||||
reason = "music_generation_failed",
|
reason = 'music_generation_failed',
|
||||||
context = {},
|
context = {},
|
||||||
}) => {
|
}) => {
|
||||||
if (!projectId || !userId || MUSIC_GENERATION_CREDIT_COST <= 0) return null;
|
if (!projectId || !userId || MUSIC_GENERATION_CREDIT_COST <= 0) return null
|
||||||
try {
|
try {
|
||||||
const metadata = {
|
const metadata = {
|
||||||
source: MUSIC_REFUND_SOURCE,
|
source: MUSIC_REFUND_SOURCE,
|
||||||
reason,
|
reason,
|
||||||
projectId,
|
projectId,
|
||||||
...context,
|
...context,
|
||||||
};
|
}
|
||||||
const { orderId } = await createOrderDocument({
|
const { orderId } = await createOrderDocument({
|
||||||
userId,
|
userId,
|
||||||
type: ORDER_TYPES.SONG,
|
type: ORDER_TYPES.SONG,
|
||||||
amount: MUSIC_GENERATION_CREDIT_COST,
|
amount: MUSIC_GENERATION_CREDIT_COST,
|
||||||
songId: projectId,
|
songId: projectId,
|
||||||
createdBy: "system",
|
createdBy: 'system',
|
||||||
metadata,
|
metadata,
|
||||||
});
|
})
|
||||||
logger.log("💸 [Music] Crédits remboursés", { projectId, userId, orderId });
|
logger.log('💸 [Music] Crédits remboursés', { projectId, userId, orderId })
|
||||||
return { orderId };
|
return { orderId }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("❌ [Music] Échec remboursement", {
|
logger.error('❌ [Music] Échec remboursement', {
|
||||||
projectId,
|
projectId,
|
||||||
userId,
|
userId,
|
||||||
error: error?.message,
|
error: error?.message,
|
||||||
});
|
})
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
async function markProjectMusicFailure(projectId, error) {
|
async function markProjectMusicFailure(projectId, error) {
|
||||||
if (!projectId) return;
|
if (!projectId) return
|
||||||
try {
|
try {
|
||||||
const docRef = refList.projects.doc(projectId);
|
const docRef = refList.projects.doc(projectId)
|
||||||
const projectSnap = await docRef.get();
|
const projectSnap = await docRef.get()
|
||||||
const projectData = projectSnap?.data() || {};
|
const projectData = projectSnap?.data() || {}
|
||||||
const status = error?.response?.status || error?.status || null;
|
const status = error?.response?.status || error?.status || null
|
||||||
const sunoMessage =
|
const sunoMessage =
|
||||||
error?.response?.data?.msg ||
|
error?.response?.data?.msg || error?.response?.data?.message || error?.message || 'Erreur'
|
||||||
error?.response?.data?.message ||
|
const errorPayload = { source: 'SUNO_API', message: sunoMessage }
|
||||||
error?.message ||
|
|
||||||
"Erreur";
|
|
||||||
const errorPayload = { source: "SUNO_API", message: sunoMessage };
|
|
||||||
|
|
||||||
const receiverId = sanitizeField(projectData?.userId);
|
const receiverId = sanitizeField(projectData?.userId)
|
||||||
const alreadyRefunded = projectData?.musicCreditsRefunded === true;
|
const alreadyRefunded = projectData?.musicCreditsRefunded === true
|
||||||
let refundResult = null;
|
let refundResult = null
|
||||||
if (receiverId && !alreadyRefunded) {
|
if (receiverId && !alreadyRefunded) {
|
||||||
refundResult = await refundMusicCredits({
|
refundResult = await refundMusicCredits({
|
||||||
projectId,
|
projectId,
|
||||||
userId: receiverId,
|
userId: receiverId,
|
||||||
reason: sunoMessage,
|
reason: sunoMessage,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
await docRef.set(
|
await docRef.set(
|
||||||
{
|
{
|
||||||
musicStatus: "FAILED",
|
musicStatus: 'FAILED',
|
||||||
musicError: errorPayload,
|
musicError: errorPayload,
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
// (Notification logic here...)
|
// (Notification logic here...)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error marking failure", err);
|
console.error('Error marking failure', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadTrackToStorage = async (
|
const downloadTrackToStorage = async (url, { userId, projectId, taskId, bucket, index }) => {
|
||||||
url,
|
if (!url) return null
|
||||||
{ userId, projectId, taskId, bucket, index },
|
|
||||||
) => {
|
|
||||||
if (!url) return null;
|
|
||||||
try {
|
try {
|
||||||
const resp = await axios.get(url, { responseType: "stream" });
|
const resp = await axios.get(url, { responseType: 'stream' })
|
||||||
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`;
|
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`
|
||||||
const token = randomUUID();
|
const token = randomUUID()
|
||||||
const file = bucket.file(path);
|
const file = bucket.file(path)
|
||||||
const writeStream = file.createWriteStream({
|
const writeStream = file.createWriteStream({
|
||||||
resumable: false,
|
resumable: false,
|
||||||
metadata: {
|
metadata: {
|
||||||
contentType: "audio/mpeg",
|
contentType: 'audio/mpeg',
|
||||||
metadata: { firebaseStorageDownloadTokens: token },
|
metadata: { firebaseStorageDownloadTokens: token },
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
await pipeline(resp.data, writeStream);
|
await pipeline(resp.data, writeStream)
|
||||||
return {
|
return {
|
||||||
path,
|
path,
|
||||||
url: `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`,
|
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) => {
|
const saveTracksToStorage = async (audioUrls, meta) => {
|
||||||
if (!audioUrls?.length) return [];
|
if (!audioUrls?.length) return []
|
||||||
const bucket = admin.storage().bucket();
|
const bucket = admin.storage().bucket()
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
audioUrls.map((url, index) =>
|
audioUrls.map((url, index) => downloadTrackToStorage(url, { ...meta, bucket, index }))
|
||||||
downloadTrackToStorage(url, { ...meta, bucket, index }),
|
)
|
||||||
),
|
return results.filter(Boolean).map((entry) => entry.url)
|
||||||
);
|
}
|
||||||
return results.filter(Boolean).map((entry) => entry.url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const mergeMusicUrls = async (projectRef, newUrls) => {
|
const mergeMusicUrls = async (projectRef, newUrls) => {
|
||||||
const sanitized = sanitizeMusicUrls(newUrls);
|
const sanitized = sanitizeMusicUrls(newUrls)
|
||||||
let existing = [];
|
let existing = []
|
||||||
try {
|
try {
|
||||||
existing = sanitizeMusicUrls((await projectRef.get())?.data()?.musicUrls);
|
existing = sanitizeMusicUrls((await projectRef.get())?.data()?.musicUrls)
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
const final = [...new Set([...existing, ...sanitized])];
|
const final = [...new Set([...existing, ...sanitized])]
|
||||||
await projectRef.set(
|
await projectRef.set(
|
||||||
{
|
{
|
||||||
musicStatus: "GENERATED",
|
musicStatus: 'GENERATED',
|
||||||
musicUrls: final,
|
musicUrls: final,
|
||||||
musicError: FieldValue.delete(),
|
musicError: FieldValue.delete(),
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
return final;
|
return final
|
||||||
};
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 5. FONCTIONS PRINCIPALES (CLOUD FUNCTIONS)
|
// 5. FONCTIONS PRINCIPALES (CLOUD FUNCTIONS)
|
||||||
@@ -506,24 +472,23 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
|
|||||||
try {
|
try {
|
||||||
// Extraction des données du Frontend
|
// Extraction des données du Frontend
|
||||||
const {
|
const {
|
||||||
title = "",
|
title = '',
|
||||||
lyrics = [], // Tableau d'objets {type, lyrics} ou strings
|
lyrics = [], // Tableau d'objets {type, lyrics} ou strings
|
||||||
genres = [], // ["Pop", "Rock"]
|
genres = [], // ["Pop", "Rock"]
|
||||||
songStyle = "", // "Upbeat"
|
songStyle = '', // "Upbeat"
|
||||||
voice = [], // ["Une voix féminine...", "Un cri brut..."] (array ou objet)
|
voice = [], // ["Une voix féminine...", "Un cri brut..."] (array ou objet)
|
||||||
instruments = [], // ["Piano", "Violon"]
|
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,
|
// 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).
|
// mais ont déjà servi à générer les paroles (lyrics).
|
||||||
// audience, context, objective, etc.
|
// audience, context, objective, etc.
|
||||||
} = data;
|
} = data
|
||||||
|
|
||||||
// Normalisation input Voix (peut être array, string ou objet selon ta spec)
|
// Normalisation input Voix (peut être array, string ou objet selon ta spec)
|
||||||
let voiceData = [];
|
let voiceData = []
|
||||||
if (Array.isArray(voice)) voiceData = voice;
|
if (Array.isArray(voice)) voiceData = voice
|
||||||
else if (typeof voice === "object" && voice !== null)
|
else if (typeof voice === 'object' && voice !== null) voiceData = Object.values(voice).flat()
|
||||||
voiceData = Object.values(voice).flat();
|
else if (typeof voice === 'string') voiceData = [voice]
|
||||||
else if (typeof voice === "string") voiceData = [voice];
|
|
||||||
|
|
||||||
// 1. Construction du STYLE MUSICAL (Tags)
|
// 1. Construction du STYLE MUSICAL (Tags)
|
||||||
const optimizedStyle = buildSunoStyle({
|
const optimizedStyle = buildSunoStyle({
|
||||||
@@ -532,50 +497,46 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
|
|||||||
voiceData,
|
voiceData,
|
||||||
instruments,
|
instruments,
|
||||||
rhythm,
|
rhythm,
|
||||||
});
|
})
|
||||||
|
|
||||||
// 2. Construction du PROMPT (Paroles + Structure)
|
// 2. Construction du PROMPT (Paroles + Structure)
|
||||||
const formattedPrompt = buildFormattedLyrics(lyrics);
|
const formattedPrompt = buildFormattedLyrics(lyrics)
|
||||||
const safeTitle = clampLen(title, 80);
|
const safeTitle = clampLen(title, 80)
|
||||||
|
|
||||||
// 3. Détection du genre vocal pour le param vocalGender (optimisation v3.5)
|
// 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
|
// On regarde si on trouve 'male' ou 'female' dans les tags générés
|
||||||
let vGender = null;
|
let vGender = null
|
||||||
if (optimizedStyle.includes("Female")) vGender = "female";
|
if (optimizedStyle.includes('Female')) vGender = 'female'
|
||||||
else if (optimizedStyle.includes("Male")) vGender = "male";
|
else if (optimizedStyle.includes('Male')) vGender = 'male'
|
||||||
|
|
||||||
console.log("🎵 [Suno Optimisation] Result:", {
|
console.log('🎵 [Suno Optimisation] Result:', {
|
||||||
style: optimizedStyle,
|
style: optimizedStyle,
|
||||||
gender: vGender,
|
gender: vGender,
|
||||||
title: safeTitle,
|
title: safeTitle,
|
||||||
promptStructure: formattedPrompt.substring(0, 150) + "...", // Aperçu
|
promptStructure: formattedPrompt.substring(0, 150) + '...', // Aperçu
|
||||||
});
|
})
|
||||||
|
|
||||||
// 4. Appel API
|
// 4. Appel API
|
||||||
const payload = {
|
const payload = {
|
||||||
customMode: true,
|
customMode: true,
|
||||||
instrumental: false,
|
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),
|
prompt: clampLen(formattedPrompt, 3000),
|
||||||
title: safeTitle,
|
title: safeTitle,
|
||||||
style: optimizedStyle,
|
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(
|
const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, {
|
||||||
`${SUNO_API_BASE}${SUNO_API_PATH}`,
|
|
||||||
payload,
|
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${SUNO_API_KEY}`,
|
Authorization: `Bearer ${SUNO_API_KEY}`,
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
);
|
|
||||||
|
|
||||||
const parsed = response.data;
|
const parsed = response.data
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: !!parsed?.data?.taskId,
|
success: !!parsed?.data?.taskId,
|
||||||
@@ -583,80 +544,73 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
|
|||||||
model: payload.model,
|
model: payload.model,
|
||||||
title: safeTitle,
|
title: safeTitle,
|
||||||
style: optimizedStyle,
|
style: optimizedStyle,
|
||||||
promptPreview: formattedPrompt.substring(0, 50) + "...",
|
promptPreview: formattedPrompt.substring(0, 50) + '...',
|
||||||
},
|
},
|
||||||
response: parsed || {},
|
response: parsed || {},
|
||||||
};
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Erreur generateMusic:", error);
|
console.error('❌ Erreur generateMusic:', error)
|
||||||
try {
|
try {
|
||||||
await markProjectMusicFailure(data?.projectId, error);
|
await markProjectMusicFailure(data?.projectId, error)
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const status = error?.response?.status || "INTERNAL_ERROR";
|
const status = error?.response?.status || 'INTERNAL_ERROR'
|
||||||
const message =
|
const message = error?.response?.data?.msg || error?.message || 'Erreur Suno'
|
||||||
error?.response?.data?.msg || error?.message || "Erreur Suno";
|
throw new HttpsError('internal', message, { status, source: 'SUNO_API' })
|
||||||
throw new HttpsError("internal", message, { status, source: "SUNO_API" });
|
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
// GET STATUS (inchangé mais inclus pour complétude)
|
// GET STATUS (inchangé mais inclus pour complétude)
|
||||||
exports.getSunoStatus = onCall(async ({ data = {} }) => {
|
exports.getSunoStatus = onCall(async ({ data = {} }) => {
|
||||||
const { taskId } = data;
|
const { taskId } = data
|
||||||
if (!taskId) throw new Error("TaskId manquant");
|
if (!taskId) throw new Error('TaskId manquant')
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(
|
const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, {
|
||||||
`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`,
|
|
||||||
{
|
|
||||||
headers: { Authorization: `Bearer ${SUNO_API_KEY}` },
|
headers: { Authorization: `Bearer ${SUNO_API_KEY}` },
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
},
|
})
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
taskId,
|
taskId,
|
||||||
data: response.data?.data || response.data,
|
data: response.data?.data || response.data,
|
||||||
};
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (taskId.includes("test"))
|
if (taskId.includes('test'))
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
taskId,
|
taskId,
|
||||||
data: { status: "not_found", isTestId: true },
|
data: { status: 'not_found', isTestId: true },
|
||||||
};
|
|
||||||
return { success: false, taskId, error: { message: error.message } };
|
|
||||||
}
|
}
|
||||||
});
|
return { success: false, taskId, error: { message: error.message } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// CALLBACK (Standard)
|
// CALLBACK (Standard)
|
||||||
exports.sunoCallback = onRequest(
|
exports.sunoCallback = onRequest({ methods: ['POST'], memory: '1GiB' }, async (req, res) => {
|
||||||
{ methods: ["POST"], memory: "1GiB" },
|
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })
|
||||||
async (req, res) => {
|
const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body)
|
||||||
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)
|
if (code !== 200 || status !== 'complete' || !taskId)
|
||||||
return res.status(200).json({ success: true, ignored: true });
|
return res.status(200).json({ success: true, ignored: true })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { projectId, projectData, projectRef } =
|
const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId)
|
||||||
await fetchProjectByTaskId(taskId);
|
const { userId, projectTitle } = formatProjectMeta(projectData)
|
||||||
const { userId, projectTitle } = formatProjectMeta(projectData);
|
|
||||||
|
|
||||||
const storedUrls = await saveTracksToStorage(
|
const storedUrls = await saveTracksToStorage(extractAudioUrlsFromTracks(tracks), {
|
||||||
extractAudioUrlsFromTracks(tracks),
|
userId,
|
||||||
{ userId, projectId, taskId },
|
projectId,
|
||||||
);
|
taskId,
|
||||||
const musicUrls = await mergeMusicUrls(projectRef, storedUrls);
|
})
|
||||||
|
const musicUrls = await mergeMusicUrls(projectRef, storedUrls)
|
||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
await sendNotification({
|
await sendNotification({
|
||||||
sender: "SYSTEM",
|
sender: 'SYSTEM',
|
||||||
receiver: userId,
|
receiver: userId,
|
||||||
receiverCollection: "users",
|
receiverCollection: 'users',
|
||||||
title: "Musique prête",
|
title: 'Musique prête',
|
||||||
message: `Ta musique pour "${projectTitle}" est prête.`,
|
message: `Ta musique pour "${projectTitle}" est prête.`,
|
||||||
data: {
|
data: {
|
||||||
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
||||||
@@ -665,15 +619,14 @@ exports.sunoCallback = onRequest(
|
|||||||
musicUrls,
|
musicUrls,
|
||||||
taskId,
|
taskId,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
} catch (e) {
|
} 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) {
|
} catch (error) {
|
||||||
// Gestion erreur silencieuse pour le webhook
|
// 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 {
|
const { onDocumentCreated, onDocumentWritten } = require('firebase-functions/v2/firestore')
|
||||||
onDocumentCreated,
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
onDocumentWritten,
|
const { refList, ALERT_TYPE } = require('../index')
|
||||||
} = require("firebase-functions/v2/firestore");
|
const { Expo } = require('expo-server-sdk')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { Resend } = require('resend')
|
||||||
const { refList, ALERT_TYPE } = require("../index");
|
const { basicTemplate } = require('../helpers/email')
|
||||||
const { Expo } = require("expo-server-sdk");
|
const { RESEND_API_KEY } = require('../config/keys')
|
||||||
const { Resend } = require("resend");
|
|
||||||
const { basicTemplate } = require("../helpers/email");
|
|
||||||
const { RESEND_API_KEY } = require("../config/keys");
|
|
||||||
|
|
||||||
const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null;
|
const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null
|
||||||
|
|
||||||
// Initialisation de Expo SDK
|
// Initialisation de Expo SDK
|
||||||
let expo = new Expo();
|
let expo = new Expo()
|
||||||
const EMAIL_FROM = "MusicLand <musicland@musicland.ai>";
|
const EMAIL_FROM = 'MusicLand <musicland@musicland.ai>'
|
||||||
const DEFAULT_EMAIL_TITLE = "MusicLand";
|
const DEFAULT_EMAIL_TITLE = 'MusicLand'
|
||||||
|
|
||||||
function getCollectionRef(collectionName = "") {
|
function getCollectionRef(collectionName = '') {
|
||||||
const ref = refList?.[collectionName];
|
const ref = refList?.[collectionName]
|
||||||
if (!ref) {
|
if (!ref) {
|
||||||
throw new Error(`Unknown collection "${collectionName}"`);
|
throw new Error(`Unknown collection "${collectionName}"`)
|
||||||
}
|
}
|
||||||
return ref;
|
return ref
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanString(value) {
|
function cleanString(value) {
|
||||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNotificationEmailPayload({
|
function buildNotificationEmailPayload({ title = '', message = '', template = {} } = {}) {
|
||||||
title = "",
|
const fallbackTitle = cleanString(title) || DEFAULT_EMAIL_TITLE
|
||||||
message = "",
|
const fallbackContent = cleanString(message) || ''
|
||||||
template = {},
|
const overrides = template && typeof template === 'object' ? template : {}
|
||||||
} = {}) {
|
|
||||||
const fallbackTitle = cleanString(title) || DEFAULT_EMAIL_TITLE;
|
|
||||||
const fallbackContent = cleanString(message) || "";
|
|
||||||
const overrides = template && typeof template === "object" ? template : {};
|
|
||||||
|
|
||||||
const subject = cleanString(overrides.subject) || fallbackTitle;
|
const subject = cleanString(overrides.subject) || fallbackTitle
|
||||||
const emailTitle = cleanString(overrides.title) || fallbackTitle;
|
const emailTitle = cleanString(overrides.title) || fallbackTitle
|
||||||
const content = cleanString(overrides.content) || fallbackContent;
|
const content = cleanString(overrides.content) || fallbackContent
|
||||||
|
|
||||||
let button = null;
|
let button = null
|
||||||
if (overrides.button && typeof overrides.button === "object") {
|
if (overrides.button && typeof overrides.button === 'object') {
|
||||||
const buttonUrl =
|
const buttonUrl = cleanString(overrides.button.url) || cleanString(overrides.button.href)
|
||||||
cleanString(overrides.button.url) || cleanString(overrides.button.href);
|
|
||||||
if (buttonUrl) {
|
if (buttonUrl) {
|
||||||
button = {
|
button = {
|
||||||
url: buttonUrl,
|
url: buttonUrl,
|
||||||
label:
|
label:
|
||||||
cleanString(overrides.button.label) ||
|
cleanString(overrides.button.label) || cleanString(overrides.button.text) || undefined,
|
||||||
cleanString(overrides.button.text) ||
|
}
|
||||||
undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const templatePayload = { title: emailTitle, content };
|
const templatePayload = { title: emailTitle, content }
|
||||||
if (button) {
|
if (button) {
|
||||||
templatePayload.button = button;
|
templatePayload.button = button
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subject,
|
subject,
|
||||||
html: basicTemplate(templatePayload),
|
html: basicTemplate(templatePayload),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
||||||
{ region: "europe-west1", document: "notifications/{notificationId}" },
|
{ region: 'europe-west1', document: 'notifications/{notificationId}' },
|
||||||
async (event) => {
|
async (event) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
receiver = null,
|
receiver = null,
|
||||||
title = "MusicLand",
|
title = 'MusicLand',
|
||||||
receiverCollection = "users",
|
receiverCollection = 'users',
|
||||||
message = "",
|
message = '',
|
||||||
data: notifData = {},
|
data: notifData = {},
|
||||||
mailOnly = false,
|
mailOnly = false,
|
||||||
} = event.data.data();
|
} = event.data.data()
|
||||||
|
|
||||||
if (!receiver || !message) {
|
if (!receiver || !message) {
|
||||||
throw new Error("Receiver and message are required");
|
throw new Error('Receiver and message are required')
|
||||||
}
|
}
|
||||||
|
|
||||||
const receiverSnap = await getCollectionRef(receiverCollection)
|
const receiverSnap = await getCollectionRef(receiverCollection).doc(receiver).get()
|
||||||
.doc(receiver)
|
const receiverData = receiverSnap.exists ? receiverSnap.data() : {}
|
||||||
.get();
|
|
||||||
const receiverData = receiverSnap.exists ? receiverSnap.data() : {};
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pushToken = null,
|
pushToken = null,
|
||||||
pushTokens = [],
|
pushTokens = [],
|
||||||
email: receiverEmail = "",
|
email: receiverEmail = '',
|
||||||
emailNotifications = false,
|
emailNotifications = false,
|
||||||
} = receiverData;
|
} = receiverData
|
||||||
|
|
||||||
if (!mailOnly) {
|
if (!mailOnly) {
|
||||||
try {
|
try {
|
||||||
@@ -102,129 +90,124 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
|||||||
[]
|
[]
|
||||||
.concat(Array.isArray(pushTokens) ? pushTokens : [])
|
.concat(Array.isArray(pushTokens) ? pushTokens : [])
|
||||||
.concat(pushToken ? [pushToken] : [])
|
.concat(pushToken ? [pushToken] : [])
|
||||||
.filter(Boolean),
|
.filter(Boolean)
|
||||||
);
|
)
|
||||||
const tokens = Array.from(tokensSet);
|
const tokens = Array.from(tokensSet)
|
||||||
|
|
||||||
if (!tokens?.length) {
|
if (!tokens?.length) {
|
||||||
await sendExpoNotification({
|
await sendExpoNotification({
|
||||||
tokens,
|
tokens,
|
||||||
receiverId: receiver,
|
receiverId: receiver,
|
||||||
receiverCollection,
|
receiverCollection,
|
||||||
title: title || "MusicLand",
|
title: title || 'MusicLand',
|
||||||
message: message,
|
message: message,
|
||||||
data: notifData || {},
|
data: notifData || {},
|
||||||
});
|
})
|
||||||
} else {
|
} else {
|
||||||
console.log("User push token not found");
|
console.log('User push token not found')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Error sending notif:", e);
|
console.log('Error sending notif:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((emailNotifications || mailOnly) && !!receiverEmail) {
|
if ((emailNotifications || mailOnly) && !!receiverEmail) {
|
||||||
if (!resendInstance) {
|
if (!resendInstance) {
|
||||||
console.warn(
|
console.warn('Resend client not configured; unable to send notification email.')
|
||||||
"Resend client not configured; unable to send notification email.",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const { subject, html } = buildNotificationEmailPayload({
|
const { subject, html } = buildNotificationEmailPayload({
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
template: notifData?.email,
|
template: notifData?.email,
|
||||||
});
|
})
|
||||||
await resendInstance.emails.send({
|
await resendInstance.emails.send({
|
||||||
from: EMAIL_FROM,
|
from: EMAIL_FROM,
|
||||||
to: [receiverEmail],
|
to: [receiverEmail],
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
});
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Error sending email:", e);
|
console.log('Error sending email:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
`[sendNotificationWhenDocIsCreated] Email not sent (${receiverEmail ? "user preference" : "missing email"}) for user ${receiver} and type ${notifData?.type || "UNKNOWN"}`,
|
`[sendNotificationWhenDocIsCreated] Email not sent (${receiverEmail ? 'user preference' : 'missing email'}) for user ${receiver} and type ${notifData?.type || 'UNKNOWN'}`
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e)
|
||||||
return e;
|
return e
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
// Fonction pour envoyer la notification via Expo SDK
|
// Fonction pour envoyer la notification via Expo SDK
|
||||||
async function sendExpoNotification({
|
async function sendExpoNotification({
|
||||||
tokens = [],
|
tokens = [],
|
||||||
title = "",
|
title = '',
|
||||||
message = "",
|
message = '',
|
||||||
data = {},
|
data = {},
|
||||||
receiverId = null,
|
receiverId = null,
|
||||||
receiverCollection = "users",
|
receiverCollection = 'users',
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const candidateTokens = Array.isArray(tokens) ? tokens : [tokens];
|
const candidateTokens = Array.isArray(tokens) ? tokens : [tokens]
|
||||||
const validTokens = [];
|
const validTokens = []
|
||||||
const invalidTokens = [];
|
const invalidTokens = []
|
||||||
|
|
||||||
candidateTokens.forEach((token) => {
|
candidateTokens.forEach((token) => {
|
||||||
if (Expo.isExpoPushToken(token)) {
|
if (Expo.isExpoPushToken(token)) {
|
||||||
validTokens.push(token);
|
validTokens.push(token)
|
||||||
} else if (token) {
|
} else if (token) {
|
||||||
invalidTokens.push(token);
|
invalidTokens.push(token)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
if (invalidTokens.length && receiverId) {
|
if (invalidTokens.length && receiverId) {
|
||||||
await removeInvalidTokens({
|
await removeInvalidTokens({
|
||||||
tokens: invalidTokens,
|
tokens: invalidTokens,
|
||||||
receiverId,
|
receiverId,
|
||||||
receiverCollection,
|
receiverCollection,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validTokens.length) {
|
if (!validTokens.length) {
|
||||||
console.warn("No valid Expo push tokens to send notification");
|
console.warn('No valid Expo push tokens to send notification')
|
||||||
return { sent: false };
|
return { sent: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = validTokens.map((token) => ({
|
const messages = validTokens.map((token) => ({
|
||||||
to: token,
|
to: token,
|
||||||
sound: "default",
|
sound: 'default',
|
||||||
title: title,
|
title: title,
|
||||||
body: message,
|
body: message,
|
||||||
data: data || {},
|
data: data || {},
|
||||||
priority: "high",
|
priority: 'high',
|
||||||
badge: 1,
|
badge: 1,
|
||||||
channelId: "default",
|
channelId: 'default',
|
||||||
}));
|
}))
|
||||||
|
|
||||||
const chunks = expo.chunkPushNotifications(messages);
|
const chunks = expo.chunkPushNotifications(messages)
|
||||||
const receipts = [];
|
const receipts = []
|
||||||
const tokensToPrune = new Set();
|
const tokensToPrune = new Set()
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const chunkReceipts = await expo.sendPushNotificationsAsync(chunk);
|
const chunkReceipts = await expo.sendPushNotificationsAsync(chunk)
|
||||||
chunkReceipts.forEach((receipt, index) => {
|
chunkReceipts.forEach((receipt, index) => {
|
||||||
if (receipt?.status === "error") {
|
if (receipt?.status === 'error') {
|
||||||
const errorCode = receipt?.details?.error || receipt?.details?.code;
|
const errorCode = receipt?.details?.error || receipt?.details?.code
|
||||||
console.log("Error sending notification:", receipt);
|
console.log('Error sending notification:', receipt)
|
||||||
if (
|
if (errorCode === 'DeviceNotRegistered' || errorCode === 'PushTokenNotRegistered') {
|
||||||
errorCode === "DeviceNotRegistered" ||
|
const token = chunk[index]?.to
|
||||||
errorCode === "PushTokenNotRegistered"
|
|
||||||
) {
|
|
||||||
const token = chunk[index]?.to;
|
|
||||||
if (token) {
|
if (token) {
|
||||||
tokensToPrune.add(token);
|
tokensToPrune.add(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
receipts.push(...chunkReceipts);
|
receipts.push(...chunkReceipts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokensToPrune.size && receiverId) {
|
if (tokensToPrune.size && receiverId) {
|
||||||
@@ -232,68 +215,64 @@ async function sendExpoNotification({
|
|||||||
tokens: Array.from(tokensToPrune),
|
tokens: Array.from(tokensToPrune),
|
||||||
receiverId,
|
receiverId,
|
||||||
receiverCollection,
|
receiverCollection,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Sent push notifications:", receipts);
|
console.log('Sent push notifications:', receipts)
|
||||||
|
|
||||||
return { sent: true };
|
return { sent: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Error sending notification:", e);
|
console.log('Error sending notification:', e)
|
||||||
throw e;
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeInvalidTokens({
|
async function removeInvalidTokens({ tokens = [], receiverId, receiverCollection }) {
|
||||||
tokens = [],
|
|
||||||
receiverId,
|
|
||||||
receiverCollection,
|
|
||||||
}) {
|
|
||||||
try {
|
try {
|
||||||
if (!receiverId || !tokens.length) {
|
if (!receiverId || !tokens.length) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueTokens = Array.from(new Set(tokens.filter(Boolean)));
|
const uniqueTokens = Array.from(new Set(tokens.filter(Boolean)))
|
||||||
if (!uniqueTokens.length) {
|
if (!uniqueTokens.length) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const docRef = getCollectionRef(receiverCollection).doc(receiverId);
|
const docRef = getCollectionRef(receiverCollection).doc(receiverId)
|
||||||
const userSnap = await docRef.get();
|
const userSnap = await docRef.get()
|
||||||
const userData = userSnap?.data() || {};
|
const userData = userSnap?.data() || {}
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
pushTokens: FieldValue.arrayRemove(...uniqueTokens),
|
pushTokens: FieldValue.arrayRemove(...uniqueTokens),
|
||||||
};
|
|
||||||
|
|
||||||
if (uniqueTokens.includes(userData?.pushToken)) {
|
|
||||||
updates.pushToken = FieldValue.delete();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await docRef.set(updates, { merge: true });
|
if (uniqueTokens.includes(userData?.pushToken)) {
|
||||||
|
updates.pushToken = FieldValue.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
await docRef.set(updates, { merge: true })
|
||||||
console.log(
|
console.log(
|
||||||
"Pruned invalid push tokens",
|
'Pruned invalid push tokens',
|
||||||
JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2),
|
JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2)
|
||||||
);
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Failed to prune invalid push tokens:", error);
|
console.log('Failed to prune invalid push tokens:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fonction pour ajouter une notification à la base de données
|
// Fonction pour ajouter une notification à la base de données
|
||||||
const sendNotification = async ({
|
const sendNotification = async ({
|
||||||
sender = "SYSTEM",
|
sender = 'SYSTEM',
|
||||||
receiver = null,
|
receiver = null,
|
||||||
receiverCollection = "users",
|
receiverCollection = 'users',
|
||||||
title = "",
|
title = '',
|
||||||
message = null,
|
message = null,
|
||||||
mailOnly = false,
|
mailOnly = false,
|
||||||
data = {},
|
data = {},
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
if (!receiver || !message) {
|
if (!receiver || !message) {
|
||||||
throw new Error("Receiver and message are required");
|
throw new Error('Receiver and message are required')
|
||||||
}
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
sender,
|
sender,
|
||||||
@@ -306,81 +285,78 @@ const sendNotification = async ({
|
|||||||
readAt: null,
|
readAt: null,
|
||||||
mailOnly,
|
mailOnly,
|
||||||
data,
|
data,
|
||||||
};
|
|
||||||
const { id } = await refList.notifications.add(payload);
|
|
||||||
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(
|
exports.createProjectCommentNotification = onDocumentCreated(
|
||||||
{
|
{
|
||||||
region: "europe-west1",
|
region: 'europe-west1',
|
||||||
document: "projects/{projectId}/comments/{commentId}",
|
document: 'projects/{projectId}/comments/{commentId}',
|
||||||
},
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
try {
|
try {
|
||||||
console.log(
|
console.log(
|
||||||
"[createProjectCommentNotification] Trigger received",
|
'[createProjectCommentNotification] Trigger received',
|
||||||
JSON.stringify(event.params || {}, null, 2),
|
JSON.stringify(event.params || {}, null, 2)
|
||||||
);
|
)
|
||||||
const { data: snap } = event;
|
const { data: snap } = event
|
||||||
const { projectId, commentId } = event.params || {};
|
const { projectId, commentId } = event.params || {}
|
||||||
const comment = snap?.data();
|
const comment = snap?.data()
|
||||||
|
|
||||||
if (!projectId || !comment) {
|
if (!projectId || !comment) {
|
||||||
console.log(
|
console.log('[createProjectCommentNotification] Missing project/comment data', {
|
||||||
"[createProjectCommentNotification] Missing project/comment data",
|
hasProjectId: !!projectId,
|
||||||
{ hasProjectId: !!projectId, hasComment: !!comment },
|
hasComment: !!comment,
|
||||||
);
|
})
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"[createProjectCommentNotification] Comment payload",
|
'[createProjectCommentNotification] Comment payload',
|
||||||
JSON.stringify(comment, null, 2),
|
JSON.stringify(comment, null, 2)
|
||||||
);
|
)
|
||||||
|
|
||||||
const projectSnap = await refList.projects.doc(projectId).get();
|
const projectSnap = await refList.projects.doc(projectId).get()
|
||||||
if (!projectSnap.exists) {
|
if (!projectSnap.exists) {
|
||||||
console.log(
|
console.log('[createProjectCommentNotification] Project not found', projectId)
|
||||||
"[createProjectCommentNotification] Project not found",
|
return null
|
||||||
projectId,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = projectSnap.data() || {};
|
const project = projectSnap.data() || {}
|
||||||
const receiver = project.userId || null;
|
const receiver = project.userId || null
|
||||||
|
|
||||||
if (!receiver || receiver === comment.userId) {
|
if (!receiver || receiver === comment.userId) {
|
||||||
console.log(
|
console.log(
|
||||||
"[createProjectCommentNotification] Invalid receiver",
|
'[createProjectCommentNotification] Invalid receiver',
|
||||||
JSON.stringify({ receiver, commentUserId: comment.userId }),
|
JSON.stringify({ receiver, commentUserId: comment.userId })
|
||||||
);
|
)
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const commenterName =
|
const commenterName =
|
||||||
typeof comment?.userName === "string" && comment.userName.trim()
|
typeof comment?.userName === 'string' && comment.userName.trim()
|
||||||
? comment.userName.trim()
|
? comment.userName.trim()
|
||||||
: "Un utilisateur";
|
: 'Un utilisateur'
|
||||||
const projectTitle =
|
const projectTitle =
|
||||||
typeof project.title === "string" && project.title.trim()
|
typeof project.title === 'string' && project.title.trim()
|
||||||
? project.title.trim()
|
? project.title.trim()
|
||||||
: "ton projet";
|
: 'ton projet'
|
||||||
const message = `${commenterName} a commenté ton projet "${projectTitle}"`;
|
const message = `${commenterName} a commenté ton projet "${projectTitle}"`
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"[createProjectCommentNotification] Creating notification",
|
'[createProjectCommentNotification] Creating notification',
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
receiver,
|
receiver,
|
||||||
@@ -389,15 +365,15 @@ exports.createProjectCommentNotification = onDocumentCreated(
|
|||||||
projectId,
|
projectId,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2
|
||||||
),
|
)
|
||||||
);
|
)
|
||||||
|
|
||||||
await sendNotification({
|
await sendNotification({
|
||||||
sender: comment.userId || "SYSTEM",
|
sender: comment.userId || 'SYSTEM',
|
||||||
receiver,
|
receiver,
|
||||||
receiverCollection: "users",
|
receiverCollection: 'users',
|
||||||
title: "Nouveau commentaire",
|
title: 'Nouveau commentaire',
|
||||||
message,
|
message,
|
||||||
data: {
|
data: {
|
||||||
type: ALERT_TYPE?.NEW_COMMENT,
|
type: ALERT_TYPE?.NEW_COMMENT,
|
||||||
@@ -405,109 +381,94 @@ exports.createProjectCommentNotification = onDocumentCreated(
|
|||||||
commentId,
|
commentId,
|
||||||
commenterId: comment.userId || null,
|
commenterId: comment.userId || null,
|
||||||
commenterName: commenterName,
|
commenterName: commenterName,
|
||||||
commenterProfilePicture: comment?.profilePicture || "",
|
commenterProfilePicture: comment?.profilePicture || '',
|
||||||
text:
|
text: typeof comment?.text === 'string' && comment.text.trim() ? comment.text.trim() : '',
|
||||||
typeof comment?.text === "string" && comment.text.trim()
|
|
||||||
? comment.text.trim()
|
|
||||||
: "",
|
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
console.log(
|
console.log('[createProjectCommentNotification] Notification creation complete')
|
||||||
"[createProjectCommentNotification] Notification creation complete",
|
|
||||||
);
|
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("createProjectCommentNotification error:", error);
|
console.log('createProjectCommentNotification error:', error)
|
||||||
return error;
|
return error
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
exports.createProjectLikeNotification = onDocumentWritten(
|
exports.createProjectLikeNotification = onDocumentWritten(
|
||||||
{
|
{
|
||||||
region: "europe-west1",
|
region: 'europe-west1',
|
||||||
document: "projects/{projectId}",
|
document: 'projects/{projectId}',
|
||||||
},
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
try {
|
try {
|
||||||
const { projectId } = event.params || {};
|
const { projectId } = event.params || {}
|
||||||
const before = event?.data?.before?.data() || {};
|
const before = event?.data?.before?.data() || {}
|
||||||
const after = event?.data?.after?.data() || {};
|
const after = event?.data?.after?.data() || {}
|
||||||
|
|
||||||
if (!projectId || !after) {
|
if (!projectId || !after) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerId = after.userId || null;
|
const ownerId = after.userId || null
|
||||||
if (!ownerId) {
|
if (!ownerId) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const beforeSongLikes = Array.isArray(before?.likes?.song)
|
const beforeSongLikes = Array.isArray(before?.likes?.song) ? before.likes.song : []
|
||||||
? before.likes.song
|
const afterSongLikes = Array.isArray(after?.likes?.song) ? after.likes.song : []
|
||||||
: [];
|
|
||||||
const afterSongLikes = Array.isArray(after?.likes?.song)
|
|
||||||
? after.likes.song
|
|
||||||
: [];
|
|
||||||
const beforePlaybackLikes = Array.isArray(before?.likes?.playback)
|
const beforePlaybackLikes = Array.isArray(before?.likes?.playback)
|
||||||
? before.likes.playback
|
? before.likes.playback
|
||||||
: [];
|
: []
|
||||||
const afterPlaybackLikes = Array.isArray(after?.likes?.playback)
|
const afterPlaybackLikes = Array.isArray(after?.likes?.playback) ? after.likes.playback : []
|
||||||
? after.likes.playback
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const beforeSongSet = new Set(beforeSongLikes);
|
const beforeSongSet = new Set(beforeSongLikes)
|
||||||
const beforePlaybackSet = new Set(beforePlaybackLikes);
|
const beforePlaybackSet = new Set(beforePlaybackLikes)
|
||||||
|
|
||||||
const newSongLikers = afterSongLikes.filter(
|
const newSongLikers = afterSongLikes.filter((uid) => uid && !beforeSongSet.has(uid))
|
||||||
(uid) => uid && !beforeSongSet.has(uid),
|
|
||||||
);
|
|
||||||
const newPlaybackLikers = afterPlaybackLikes.filter(
|
const newPlaybackLikers = afterPlaybackLikes.filter(
|
||||||
(uid) => uid && !beforePlaybackSet.has(uid),
|
(uid) => uid && !beforePlaybackSet.has(uid)
|
||||||
);
|
)
|
||||||
|
|
||||||
const newLikers = [];
|
const newLikers = []
|
||||||
|
|
||||||
newSongLikers.forEach((uid) => {
|
newSongLikers.forEach((uid) => {
|
||||||
newLikers.push({ likerId: uid, likeType: "song" });
|
newLikers.push({ likerId: uid, likeType: 'song' })
|
||||||
});
|
})
|
||||||
newPlaybackLikers.forEach((uid) => {
|
newPlaybackLikers.forEach((uid) => {
|
||||||
newLikers.push({ likerId: uid, likeType: "playback" });
|
newLikers.push({ likerId: uid, likeType: 'playback' })
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!newLikers.length) {
|
if (!newLikers.length) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectTitle =
|
const projectTitle =
|
||||||
typeof after.title === "string" && after.title.trim()
|
typeof after.title === 'string' && after.title.trim() ? after.title.trim() : 'ton projet'
|
||||||
? after.title.trim()
|
|
||||||
: "ton projet";
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
newLikers.map(async ({ likerId, likeType }) => {
|
newLikers.map(async ({ likerId, likeType }) => {
|
||||||
if (!likerId || likerId === ownerId) {
|
if (!likerId || likerId === ownerId) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const likerSnap = await refList.users.doc(likerId).get();
|
const likerSnap = await refList.users.doc(likerId).get()
|
||||||
const liker = likerSnap?.data() || {};
|
const liker = likerSnap?.data() || {}
|
||||||
const likerName =
|
const likerName =
|
||||||
typeof liker?.userName === "string" && liker.userName.trim()
|
typeof liker?.userName === 'string' && liker.userName.trim()
|
||||||
? liker.userName.trim()
|
? liker.userName.trim()
|
||||||
: "Un utilisateur";
|
: 'Un utilisateur'
|
||||||
|
|
||||||
const isPlaybackLike = likeType === "playback";
|
const isPlaybackLike = likeType === 'playback'
|
||||||
const assetLabel = isPlaybackLike ? "ton playback" : "ta musique";
|
const assetLabel = isPlaybackLike ? 'ton playback' : 'ta musique'
|
||||||
const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"`;
|
const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"`
|
||||||
|
|
||||||
await sendNotification({
|
await sendNotification({
|
||||||
sender: likerId,
|
sender: likerId,
|
||||||
receiver: ownerId,
|
receiver: ownerId,
|
||||||
receiverCollection: "users",
|
receiverCollection: 'users',
|
||||||
title: "Nouveau like",
|
title: 'Nouveau like',
|
||||||
message,
|
message,
|
||||||
data: {
|
data: {
|
||||||
type: ALERT_TYPE?.NEW_LIKE,
|
type: ALERT_TYPE?.NEW_LIKE,
|
||||||
@@ -516,92 +477,86 @@ exports.createProjectLikeNotification = onDocumentWritten(
|
|||||||
likerName,
|
likerName,
|
||||||
likeType,
|
likeType,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("[createProjectLikeNotification] error:", error);
|
console.log('[createProjectLikeNotification] error:', error)
|
||||||
return error;
|
return error
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
exports.createNewFollowerNotification = onDocumentWritten(
|
exports.createNewFollowerNotification = onDocumentWritten(
|
||||||
{
|
{
|
||||||
region: "europe-west1",
|
region: 'europe-west1',
|
||||||
document: "users/{userId}",
|
document: 'users/{userId}',
|
||||||
},
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
try {
|
try {
|
||||||
const { userId } = event.params || {};
|
const { userId } = event.params || {}
|
||||||
const before = event?.data?.before?.data() || {};
|
const before = event?.data?.before?.data() || {}
|
||||||
const after = event?.data?.after?.data() || {};
|
const after = event?.data?.after?.data() || {}
|
||||||
|
|
||||||
if (!userId || !after) {
|
if (!userId || !after) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const beforeFollowers = Array.isArray(before?.followedBy)
|
const beforeFollowers = Array.isArray(before?.followedBy) ? before.followedBy : []
|
||||||
? before.followedBy
|
const afterFollowers = Array.isArray(after?.followedBy) ? after.followedBy : []
|
||||||
: [];
|
|
||||||
const afterFollowers = Array.isArray(after?.followedBy)
|
|
||||||
? after.followedBy
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (afterFollowers.length <= beforeFollowers.length) {
|
if (afterFollowers.length <= beforeFollowers.length) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousSet = new Set(beforeFollowers);
|
const previousSet = new Set(beforeFollowers)
|
||||||
const newFollowers = afterFollowers.filter(
|
const newFollowers = afterFollowers.filter((uid) => !previousSet.has(uid))
|
||||||
(uid) => !previousSet.has(uid),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!newFollowers.length) {
|
if (!newFollowers.length) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
newFollowers.map(async (followerId) => {
|
newFollowers.map(async (followerId) => {
|
||||||
if (!followerId || followerId === userId) {
|
if (!followerId || followerId === userId) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const followerSnap = await refList.users.doc(followerId).get();
|
const followerSnap = await refList.users.doc(followerId).get()
|
||||||
const follower = followerSnap?.data() || {};
|
const follower = followerSnap?.data() || {}
|
||||||
const followerName =
|
const followerName =
|
||||||
typeof follower?.userName === "string" && follower.userName.trim()
|
typeof follower?.userName === 'string' && follower.userName.trim()
|
||||||
? follower.userName.trim()
|
? follower.userName.trim()
|
||||||
: "Un utilisateur";
|
: 'Un utilisateur'
|
||||||
|
|
||||||
const message = `${followerName} te suit maintenant`;
|
const message = `${followerName} te suit maintenant`
|
||||||
|
|
||||||
await sendNotification({
|
await sendNotification({
|
||||||
sender: followerId,
|
sender: followerId,
|
||||||
receiver: userId,
|
receiver: userId,
|
||||||
receiverCollection: "users",
|
receiverCollection: 'users',
|
||||||
title: "Nouvel abonné",
|
title: 'Nouvel abonné',
|
||||||
message,
|
message,
|
||||||
data: {
|
data: {
|
||||||
type: ALERT_TYPE?.NEW_FOLLOWER,
|
type: ALERT_TYPE?.NEW_FOLLOWER,
|
||||||
followerId,
|
followerId,
|
||||||
followerName,
|
followerName,
|
||||||
followerProfilePicture: follower?.profilePicture || "",
|
followerProfilePicture: follower?.profilePicture || '',
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("[createNewFollowerNotification] error:", error);
|
console.log('[createNewFollowerNotification] error:', error)
|
||||||
return error;
|
return error
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|||||||
+107
-138
@@ -1,86 +1,81 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
const { onDocumentCreated } = require("firebase-functions/firestore");
|
const { onDocumentCreated } = require('firebase-functions/firestore')
|
||||||
const { HttpsError, onCall } = require("firebase-functions/https");
|
const { HttpsError, onCall } = require('firebase-functions/https')
|
||||||
|
|
||||||
const { REGION, ALERT_TYPE } = require("../index");
|
const { REGION, ALERT_TYPE } = require('../index')
|
||||||
const { sendNotification } = require("./notifications");
|
const { sendNotification } = require('./notifications')
|
||||||
const {
|
const {
|
||||||
ORDER_TYPES,
|
ORDER_TYPES,
|
||||||
ORDER_STATUS,
|
ORDER_STATUS,
|
||||||
ORDERS_COLLECTION,
|
ORDERS_COLLECTION,
|
||||||
createOrderDocument,
|
createOrderDocument,
|
||||||
normalizeAmount,
|
normalizeAmount,
|
||||||
} = require("./helpers/orders");
|
} = require('./helpers/orders')
|
||||||
|
|
||||||
const USERS_COLLECTION = "users";
|
const USERS_COLLECTION = 'users'
|
||||||
|
|
||||||
const formatCoinsText = (value) => {
|
const formatCoinsText = (value) => {
|
||||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const absoluteValue = Math.abs(value);
|
const absoluteValue = Math.abs(value)
|
||||||
if (!Number.isFinite(absoluteValue)) {
|
if (!Number.isFinite(absoluteValue)) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatted = Number.isInteger(absoluteValue)
|
const formatted = Number.isInteger(absoluteValue) ? `${absoluteValue}` : absoluteValue.toFixed(2)
|
||||||
? `${absoluteValue}`
|
const suffix = absoluteValue === 1 ? 'crédit' : 'crédits'
|
||||||
: absoluteValue.toFixed(2);
|
|
||||||
const suffix = absoluteValue === 1 ? "crédit" : "crédits";
|
|
||||||
|
|
||||||
return `${formatted} ${suffix}`;
|
return `${formatted} ${suffix}`
|
||||||
};
|
}
|
||||||
|
|
||||||
const buildOrderNotificationContent = ({ amount, orderType, balanceAfter }) => {
|
const buildOrderNotificationContent = ({ amount, orderType, balanceAfter }) => {
|
||||||
if (typeof amount !== "number" || Number.isNaN(amount) || amount === 0) {
|
if (typeof amount !== 'number' || Number.isNaN(amount) || amount === 0) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const coinsText = formatCoinsText(amount);
|
const coinsText = formatCoinsText(amount)
|
||||||
if (!coinsText) {
|
if (!coinsText) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const balanceText = formatCoinsText(balanceAfter);
|
const balanceText = formatCoinsText(balanceAfter)
|
||||||
const balanceSentence = balanceText
|
const balanceSentence = balanceText ? ` Ton solde est maintenant de ${balanceText}.` : ''
|
||||||
? ` Ton solde est maintenant de ${balanceText}.`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
if (orderType === ORDER_TYPES.COINS) {
|
if (orderType === ORDER_TYPES.COINS) {
|
||||||
return {
|
return {
|
||||||
title: "Crédits achetés",
|
title: 'Crédits achetés',
|
||||||
message: `Ton achat de ${coinsText} est confirmé.${balanceSentence}`,
|
message: `Ton achat de ${coinsText} est confirmé.${balanceSentence}`,
|
||||||
action: "PURCHASED",
|
action: 'PURCHASED',
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orderType === ORDER_TYPES.GIFT) {
|
if (orderType === ORDER_TYPES.GIFT) {
|
||||||
return {
|
return {
|
||||||
title: "Crédits reçus",
|
title: 'Crédits reçus',
|
||||||
message: `Tu as reçu ${coinsText}.${balanceSentence}`,
|
message: `Tu as reçu ${coinsText}.${balanceSentence}`,
|
||||||
action: "EARNED",
|
action: 'EARNED',
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: "Crédits ajoutés",
|
title: 'Crédits ajoutés',
|
||||||
message: `Ton solde augmente de ${coinsText}.${balanceSentence}`,
|
message: `Ton solde augmente de ${coinsText}.${balanceSentence}`,
|
||||||
action: "CREDITED",
|
action: 'CREDITED',
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reason =
|
const reason = orderType === ORDER_TYPES.SONG ? ' pour générer un nouveau son' : ''
|
||||||
orderType === ORDER_TYPES.SONG ? " pour générer un nouveau son" : "";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: "Crédits dépensés",
|
title: 'Crédits dépensés',
|
||||||
message: `Tu as dépensé ${coinsText}${reason}.${balanceSentence}`,
|
message: `Tu as dépensé ${coinsText}${reason}.${balanceSentence}`,
|
||||||
action: "SPENT",
|
action: 'SPENT',
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const notifyOrderApplied = async ({
|
const notifyOrderApplied = async ({
|
||||||
userId,
|
userId,
|
||||||
@@ -95,69 +90,61 @@ const notifyOrderApplied = async ({
|
|||||||
amount,
|
amount,
|
||||||
orderType,
|
orderType,
|
||||||
balanceAfter,
|
balanceAfter,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!content || !userId) {
|
if (!content || !userId) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendNotification({
|
await sendNotification({
|
||||||
sender: "SYSTEM",
|
sender: 'SYSTEM',
|
||||||
receiver: userId,
|
receiver: userId,
|
||||||
receiverCollection: USERS_COLLECTION,
|
receiverCollection: USERS_COLLECTION,
|
||||||
title: content.title,
|
title: content.title,
|
||||||
message: content.message,
|
message: content.message,
|
||||||
data: {
|
data: {
|
||||||
type: ALERT_TYPE?.CREDITS_UPDATED || "CREDITS_UPDATED",
|
type: ALERT_TYPE?.CREDITS_UPDATED || 'CREDITS_UPDATED',
|
||||||
orderId,
|
orderId,
|
||||||
orderType: orderType || null,
|
orderType: orderType || null,
|
||||||
amount,
|
amount,
|
||||||
balanceBefore,
|
balanceBefore,
|
||||||
balanceAfter,
|
balanceAfter,
|
||||||
action: content.action,
|
action: content.action,
|
||||||
source:
|
source: typeof metadata?.source === 'string' ? metadata.source : null,
|
||||||
typeof metadata?.source === "string" ? metadata.source : null,
|
|
||||||
metadata: metadata || {},
|
metadata: metadata || {},
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error('[orders-onOrderCreated] Failed to send notification', orderId, error)
|
||||||
"[orders-onOrderCreated] Failed to send notification",
|
|
||||||
orderId,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const onOrderCreated = onDocumentCreated(
|
const onOrderCreated = onDocumentCreated(`${ORDERS_COLLECTION}/{orderId}`, async (event) => {
|
||||||
`${ORDERS_COLLECTION}/{orderId}`,
|
const orderRef = event?.data?.ref
|
||||||
async (event) => {
|
const orderData = event?.data?.data()
|
||||||
const orderRef = event?.data?.ref;
|
|
||||||
const orderData = event?.data?.data();
|
|
||||||
|
|
||||||
if (!orderRef || !orderData) {
|
if (!orderRef || !orderData) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orderData?.processedAt) {
|
if (orderData?.processedAt) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId =
|
const userId = typeof orderData.userId === 'string' ? orderData.userId.trim() : ''
|
||||||
typeof orderData.userId === "string" ? orderData.userId.trim() : "";
|
const amount = normalizeAmount(orderData.amount)
|
||||||
const amount = normalizeAmount(orderData.amount);
|
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
await orderRef.set(
|
await orderRef.set(
|
||||||
{
|
{
|
||||||
status: ORDER_STATUS.REJECTED,
|
status: ORDER_STATUS.REJECTED,
|
||||||
processedAt: FieldValue.serverTimestamp(),
|
processedAt: FieldValue.serverTimestamp(),
|
||||||
failureReason: "USER_NOT_FOUND",
|
failureReason: 'USER_NOT_FOUND',
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amount === null || amount === 0) {
|
if (amount === null || amount === 0) {
|
||||||
@@ -165,51 +152,46 @@ const onOrderCreated = onDocumentCreated(
|
|||||||
{
|
{
|
||||||
status: ORDER_STATUS.REJECTED,
|
status: ORDER_STATUS.REJECTED,
|
||||||
processedAt: FieldValue.serverTimestamp(),
|
processedAt: FieldValue.serverTimestamp(),
|
||||||
failureReason: "INVALID_AMOUNT",
|
failureReason: 'INVALID_AMOUNT',
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId);
|
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId)
|
||||||
|
|
||||||
let notificationContext = null;
|
let notificationContext = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await admin.firestore().runTransaction(async (transaction) => {
|
await admin.firestore().runTransaction(async (transaction) => {
|
||||||
const userSnapshot = await transaction.get(userRef);
|
const userSnapshot = await transaction.get(userRef)
|
||||||
const userData = userSnapshot?.data() || {};
|
const userData = userSnapshot?.data() || {}
|
||||||
const currentBalanceValue = normalizeAmount(userData?.coins);
|
const currentBalanceValue = normalizeAmount(userData?.coins)
|
||||||
const currentBalance =
|
const currentBalance = currentBalanceValue !== null ? currentBalanceValue : 0
|
||||||
currentBalanceValue !== null ? currentBalanceValue : 0;
|
|
||||||
|
|
||||||
const nextBalance = currentBalance + amount;
|
const nextBalance = currentBalance + amount
|
||||||
|
|
||||||
if (
|
if (amount < 0 && nextBalance < 0 && orderData?.type === ORDER_TYPES.SONG) {
|
||||||
amount < 0 &&
|
|
||||||
nextBalance < 0 &&
|
|
||||||
orderData?.type === ORDER_TYPES.SONG
|
|
||||||
) {
|
|
||||||
transaction.set(
|
transaction.set(
|
||||||
orderRef,
|
orderRef,
|
||||||
{
|
{
|
||||||
status: ORDER_STATUS.REJECTED,
|
status: ORDER_STATUS.REJECTED,
|
||||||
processedAt: FieldValue.serverTimestamp(),
|
processedAt: FieldValue.serverTimestamp(),
|
||||||
failureReason: "INSUFFICIENT_FUNDS",
|
failureReason: 'INSUFFICIENT_FUNDS',
|
||||||
balanceBefore: currentBalance,
|
balanceBefore: currentBalance,
|
||||||
balanceAfter: currentBalance,
|
balanceAfter: currentBalance,
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userSnapshot?.exists) {
|
if (userSnapshot?.exists) {
|
||||||
transaction.update(userRef, {
|
transaction.update(userRef, {
|
||||||
coins: nextBalance,
|
coins: nextBalance,
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
});
|
})
|
||||||
} else {
|
} else {
|
||||||
transaction.set(
|
transaction.set(
|
||||||
userRef,
|
userRef,
|
||||||
@@ -218,8 +200,8 @@ const onOrderCreated = onDocumentCreated(
|
|||||||
createdAt: FieldValue.serverTimestamp(),
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
transaction.set(
|
transaction.set(
|
||||||
@@ -230,31 +212,27 @@ const onOrderCreated = onDocumentCreated(
|
|||||||
balanceBefore: currentBalance,
|
balanceBefore: currentBalance,
|
||||||
balanceAfter: nextBalance,
|
balanceAfter: nextBalance,
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
|
|
||||||
notificationContext = {
|
notificationContext = {
|
||||||
balanceBefore: currentBalance,
|
balanceBefore: currentBalance,
|
||||||
balanceAfter: nextBalance,
|
balanceAfter: nextBalance,
|
||||||
};
|
}
|
||||||
});
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error('[orders-onOrderCreated] Failed to process order', orderRef.id, error)
|
||||||
"[orders-onOrderCreated] Failed to process order",
|
|
||||||
orderRef.id,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
|
|
||||||
await orderRef.set(
|
await orderRef.set(
|
||||||
{
|
{
|
||||||
status: ORDER_STATUS.REJECTED,
|
status: ORDER_STATUS.REJECTED,
|
||||||
processedAt: FieldValue.serverTimestamp(),
|
processedAt: FieldValue.serverTimestamp(),
|
||||||
failureReason: "PROCESSING_ERROR",
|
failureReason: 'PROCESSING_ERROR',
|
||||||
errorMessage: error?.message || String(error),
|
errorMessage: error?.message || String(error),
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notificationContext) {
|
if (notificationContext) {
|
||||||
@@ -262,59 +240,50 @@ const onOrderCreated = onDocumentCreated(
|
|||||||
userId,
|
userId,
|
||||||
orderId: orderRef.id,
|
orderId: orderRef.id,
|
||||||
amount,
|
amount,
|
||||||
orderType:
|
orderType: typeof orderData?.type === 'string' ? orderData.type : null,
|
||||||
typeof orderData?.type === "string" ? orderData.type : null,
|
|
||||||
balanceBefore: notificationContext.balanceBefore,
|
balanceBefore: notificationContext.balanceBefore,
|
||||||
balanceAfter: notificationContext.balanceAfter,
|
balanceAfter: notificationContext.balanceAfter,
|
||||||
metadata: orderData?.metadata || {},
|
metadata: orderData?.metadata || {},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
);
|
|
||||||
|
|
||||||
const createSongOrder = onCall({ region: REGION }, async (request) => {
|
const createSongOrder = onCall({ region: REGION }, async (request) => {
|
||||||
const { auth, data } = request || {};
|
const { auth, data } = request || {}
|
||||||
|
|
||||||
if (!auth?.uid) {
|
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) {
|
if (amount === null || amount >= 0) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
"Le montant doit être négatif pour un achat de musique.",
|
'Le montant doit être négatif pour un achat de musique.'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const songId =
|
const songId = typeof data?.songId === 'string' && data.songId.trim() ? data.songId.trim() : null
|
||||||
typeof data?.songId === "string" && data.songId.trim()
|
|
||||||
? data.songId.trim()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid);
|
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid)
|
||||||
const userSnapshot = await userRef.get();
|
const userSnapshot = await userRef.get()
|
||||||
const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins);
|
const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins)
|
||||||
const currentCoins =
|
const currentCoins = currentCoinsValue !== null ? currentCoinsValue : 0
|
||||||
currentCoinsValue !== null ? currentCoinsValue : 0;
|
|
||||||
|
|
||||||
if (currentCoins + amount < 0) {
|
if (currentCoins + amount < 0) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('failed-precondition', "Crédits insuffisants pour finaliser l'opération.")
|
||||||
"failed-precondition",
|
|
||||||
"Crédits insuffisants pour finaliser l'opération.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
source:
|
source:
|
||||||
typeof data?.source === "string" && data.source.trim()
|
typeof data?.source === 'string' && data.source.trim()
|
||||||
? data.source.trim()
|
? data.source.trim()
|
||||||
: "music_generation",
|
: 'music_generation',
|
||||||
};
|
}
|
||||||
|
|
||||||
if (typeof data?.requestId === "string" && data.requestId.trim()) {
|
if (typeof data?.requestId === 'string' && data.requestId.trim()) {
|
||||||
metadata.requestId = data.requestId.trim();
|
metadata.requestId = data.requestId.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { orderId } = await createOrderDocument({
|
const { orderId } = await createOrderDocument({
|
||||||
@@ -324,12 +293,12 @@ const createSongOrder = onCall({ region: REGION }, async (request) => {
|
|||||||
songId,
|
songId,
|
||||||
createdBy: auth.uid,
|
createdBy: auth.uid,
|
||||||
metadata,
|
metadata,
|
||||||
});
|
})
|
||||||
|
|
||||||
return { orderId };
|
return { orderId }
|
||||||
});
|
})
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
onOrderCreated,
|
onOrderCreated,
|
||||||
createSongOrder,
|
createSongOrder,
|
||||||
};
|
}
|
||||||
|
|||||||
+109
-135
@@ -1,151 +1,134 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
const { onSchedule } = require('firebase-functions/v2/scheduler')
|
||||||
const _ = require("lodash");
|
const _ = require('lodash')
|
||||||
const { refList, db, ALERT_TYPE } = require("../index");
|
const { refList, db, ALERT_TYPE } = require('../index')
|
||||||
const { batchFirestore } = require("../helpers/firebase");
|
const { batchFirestore } = require('../helpers/firebase')
|
||||||
const { BATCH_TYPE } = require("../config/types");
|
const { BATCH_TYPE } = require('../config/types')
|
||||||
const {
|
const { buildMonthKey, buildPreviousMonthContext } = require('../helpers/stats')
|
||||||
buildMonthKey,
|
const { sendNotification } = require('./notifications')
|
||||||
buildPreviousMonthContext,
|
|
||||||
} = require("../helpers/stats");
|
|
||||||
const { sendNotification } = require("./notifications");
|
|
||||||
|
|
||||||
const DISTRIBUTION_REVENUE_BASELINE = 1000;
|
const DISTRIBUTION_REVENUE_BASELINE = 1000
|
||||||
const DISTRIBUTION_RATIO = 0.3;
|
const DISTRIBUTION_RATIO = 0.3
|
||||||
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(["active"]);
|
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['active'])
|
||||||
|
|
||||||
const hasActiveSubscription = (userData) => {
|
const hasActiveSubscription = (userData) => {
|
||||||
if (!userData || typeof userData !== "object") {
|
if (!userData || typeof userData !== 'object') {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
const isPremium = userData.isPremium === true;
|
const isPremium = userData.isPremium === true
|
||||||
if (!isPremium) {
|
if (!isPremium) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
const status =
|
const status =
|
||||||
typeof userData.stripeSubscriptionStatus === "string"
|
typeof userData.stripeSubscriptionStatus === 'string'
|
||||||
? userData.stripeSubscriptionStatus.trim().toLowerCase()
|
? userData.stripeSubscriptionStatus.trim().toLowerCase()
|
||||||
: null;
|
: null
|
||||||
if (status && ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
|
if (status && ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
const billingPeriod =
|
const billingPeriod =
|
||||||
typeof userData.premiumBillingPeriod === "string"
|
typeof userData.premiumBillingPeriod === 'string'
|
||||||
? userData.premiumBillingPeriod.trim().toLowerCase()
|
? userData.premiumBillingPeriod.trim().toLowerCase()
|
||||||
: null;
|
: null
|
||||||
if (billingPeriod === "monthly" || billingPeriod === "annual") {
|
if (billingPeriod === 'monthly' || billingPeriod === 'annual') {
|
||||||
// Fallback: billing period is set only for active subscribers.
|
// Fallback: billing period is set only for active subscribers.
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
return false;
|
return false
|
||||||
};
|
}
|
||||||
|
|
||||||
exports.distributeMonthlyPayouts = onSchedule(
|
exports.distributeMonthlyPayouts = onSchedule(
|
||||||
{
|
{
|
||||||
schedule: "0 1 1 * *",
|
schedule: '0 1 1 * *',
|
||||||
timeZone: "Europe/Paris",
|
timeZone: 'Europe/Paris',
|
||||||
},
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
const { scheduleTime } = event;
|
const { scheduleTime } = event
|
||||||
const context = buildPreviousMonthContext(
|
const context = buildPreviousMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
|
||||||
scheduleTime ? new Date(scheduleTime) : new Date(),
|
const monthKey = buildMonthKey(context.rangeStart)
|
||||||
);
|
const now = admin.firestore.Timestamp.now()
|
||||||
const monthKey = buildMonthKey(context.rangeStart);
|
|
||||||
const now = admin.firestore.Timestamp.now();
|
|
||||||
|
|
||||||
const statsSnapshot = await db
|
const statsSnapshot = await db
|
||||||
.collectionGroup("monthlyListens")
|
.collectionGroup('monthlyListens')
|
||||||
.where("monthKey", "==", monthKey)
|
.where('monthKey', '==', monthKey)
|
||||||
.orderBy("streams", "desc")
|
.orderBy('streams', 'desc')
|
||||||
.get();
|
.get()
|
||||||
|
|
||||||
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey);
|
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey)
|
||||||
const totalsSnapshot = await totalsDocRef.get();
|
const totalsSnapshot = await totalsDocRef.get()
|
||||||
|
|
||||||
const entries = _.chain(statsSnapshot.docs)
|
const entries = _.chain(statsSnapshot.docs)
|
||||||
.map((doc) => {
|
.map((doc) => {
|
||||||
const data = doc.data() || {};
|
const data = doc.data() || {}
|
||||||
return {
|
return {
|
||||||
projectId:
|
projectId: _.get(data, 'projectId') || doc.ref.parent.parent?.id || null,
|
||||||
_.get(data, "projectId") || doc.ref.parent.parent?.id || null,
|
userId: _.get(data, 'userId', null),
|
||||||
userId: _.get(data, "userId", null),
|
streams: _.toFinite(_.get(data, 'streams', 0)),
|
||||||
streams: _.toFinite(_.get(data, "streams", 0)),
|
|
||||||
statsDocPath: doc.ref.path,
|
statsDocPath: doc.ref.path,
|
||||||
};
|
}
|
||||||
})
|
})
|
||||||
.filter((entry) => entry.projectId && entry.streams > 0)
|
.filter((entry) => entry.projectId && entry.streams > 0)
|
||||||
.orderBy(["streams"], ["desc"])
|
.orderBy(['streams'], ['desc'])
|
||||||
.value();
|
.value()
|
||||||
|
|
||||||
const userEligibilityMap = {};
|
const userEligibilityMap = {}
|
||||||
const userIds = _.uniq(
|
const userIds = _.uniq(entries.map((entry) => entry.userId).filter((userId) => !!userId))
|
||||||
entries.map((entry) => entry.userId).filter((userId) => !!userId),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (userIds.length) {
|
if (userIds.length) {
|
||||||
const chunkSize = 300;
|
const chunkSize = 300
|
||||||
for (let index = 0; index < userIds.length; index += chunkSize) {
|
for (let index = 0; index < userIds.length; index += chunkSize) {
|
||||||
const chunk = userIds.slice(index, index + chunkSize);
|
const chunk = userIds.slice(index, index + chunkSize)
|
||||||
const snapshots = await Promise.all(
|
const snapshots = await Promise.all(
|
||||||
chunk.map(async (userId) => {
|
chunk.map(async (userId) => {
|
||||||
try {
|
try {
|
||||||
return await refList.users.doc(userId).get();
|
return await refList.users.doc(userId).get()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn('[distributeMonthlyPayouts] Unable to load user profile', {
|
||||||
"[distributeMonthlyPayouts] Unable to load user profile",
|
|
||||||
{
|
|
||||||
userId,
|
userId,
|
||||||
error: error?.message || String(error),
|
error: error?.message || String(error),
|
||||||
},
|
})
|
||||||
);
|
return null
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
snapshots.forEach((snapshot, snapshotIndex) => {
|
snapshots.forEach((snapshot, snapshotIndex) => {
|
||||||
const userId = chunk[snapshotIndex];
|
const userId = chunk[snapshotIndex]
|
||||||
if (snapshot?.exists) {
|
if (snapshot?.exists) {
|
||||||
userEligibilityMap[userId] = hasActiveSubscription(
|
userEligibilityMap[userId] = hasActiveSubscription(snapshot.data())
|
||||||
snapshot.data(),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
userEligibilityMap[userId] = false;
|
userEligibilityMap[userId] = false
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const eligibleEntries = entries.filter(
|
const eligibleEntries = entries.filter(
|
||||||
(entry) =>
|
(entry) => !!entry.userId && userEligibilityMap[entry.userId] === true
|
||||||
!!entry.userId && userEligibilityMap[entry.userId] === true,
|
)
|
||||||
);
|
const eligibleTotalStreams = _.sumBy(eligibleEntries, 'streams')
|
||||||
const eligibleTotalStreams = _.sumBy(eligibleEntries, "streams");
|
|
||||||
|
|
||||||
const payoutsTotalStreamsFromDocs = _.sumBy(entries, "streams");
|
const payoutsTotalStreamsFromDocs = _.sumBy(entries, 'streams')
|
||||||
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null;
|
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null
|
||||||
let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0));
|
let totalStreams = _.toFinite(_.get(totalsData, 'totalStreams', 0))
|
||||||
if (!totalStreams || totalStreams < payoutsTotalStreamsFromDocs) {
|
if (!totalStreams || totalStreams < payoutsTotalStreamsFromDocs) {
|
||||||
totalStreams = payoutsTotalStreamsFromDocs;
|
totalStreams = payoutsTotalStreamsFromDocs
|
||||||
}
|
}
|
||||||
|
|
||||||
const payoutPool = _.round(
|
const payoutPool = _.round(DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO, 2)
|
||||||
DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
|
|
||||||
let allocations = _.map(eligibleEntries, (entry) => {
|
let allocations = _.map(eligibleEntries, (entry) => {
|
||||||
if (!eligibleTotalStreams) return 0;
|
if (!eligibleTotalStreams) return 0
|
||||||
const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams;
|
const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams
|
||||||
return _.round(rawAmount, 2);
|
return _.round(rawAmount, 2)
|
||||||
});
|
})
|
||||||
|
|
||||||
if (allocations.length > 0) {
|
if (allocations.length > 0) {
|
||||||
const allocatedTotal = _.round(_.sum(allocations), 2);
|
const allocatedTotal = _.round(_.sum(allocations), 2)
|
||||||
const remainder = _.round(payoutPool - allocatedTotal, 2);
|
const remainder = _.round(payoutPool - allocatedTotal, 2)
|
||||||
if (remainder !== 0) {
|
if (remainder !== 0) {
|
||||||
allocations[0] = _.round(allocations[0] + remainder, 2);
|
allocations[0] = _.round(allocations[0] + remainder, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,40 +137,36 @@ exports.distributeMonthlyPayouts = onSchedule(
|
|||||||
projectId: entry.projectId,
|
projectId: entry.projectId,
|
||||||
userId: entry.userId,
|
userId: entry.userId,
|
||||||
streams: entry.streams,
|
streams: entry.streams,
|
||||||
share: eligibleTotalStreams
|
share: eligibleTotalStreams ? _.round(entry.streams / eligibleTotalStreams, 6) : 0,
|
||||||
? _.round(entry.streams / eligibleTotalStreams, 6)
|
|
||||||
: 0,
|
|
||||||
amount: allocations[idx],
|
amount: allocations[idx],
|
||||||
statsDocPath: entry.statsDocPath,
|
statsDocPath: entry.statsDocPath,
|
||||||
}));
|
}))
|
||||||
|
|
||||||
const userDocs = _(payouts)
|
const userDocs = _(payouts)
|
||||||
.filter((payout) => !!payout.userId)
|
.filter((payout) => !!payout.userId)
|
||||||
.groupBy("userId")
|
.groupBy('userId')
|
||||||
.map((projects, userId) => {
|
.map((projects, userId) => {
|
||||||
const sortedProjects = _.orderBy(projects, ["amount"], ["desc"]).map(
|
const sortedProjects = _.orderBy(projects, ['amount'], ['desc']).map((project) => ({
|
||||||
(project) => ({
|
|
||||||
projectId: project.projectId,
|
projectId: project.projectId,
|
||||||
rank: project.rank,
|
rank: project.rank,
|
||||||
amount: project.amount,
|
amount: project.amount,
|
||||||
streams: project.streams,
|
streams: project.streams,
|
||||||
share: project.share,
|
share: project.share,
|
||||||
statsDocPath: project.statsDocPath,
|
statsDocPath: project.statsDocPath,
|
||||||
}),
|
}))
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
totalAmount: _.round(_.sumBy(projects, "amount"), 2),
|
totalAmount: _.round(_.sumBy(projects, 'amount'), 2),
|
||||||
totalStreams: _.sumBy(projects, "streams"),
|
totalStreams: _.sumBy(projects, 'streams'),
|
||||||
projects: sortedProjects,
|
projects: sortedProjects,
|
||||||
};
|
}
|
||||||
})
|
})
|
||||||
.value();
|
.value()
|
||||||
|
|
||||||
if (userDocs.length) {
|
if (userDocs.length) {
|
||||||
const docs = userDocs.map((userData) => {
|
const docs = userDocs.map((userData) => {
|
||||||
const docId = `${monthKey}_${userData.userId}`;
|
const docId = `${monthKey}_${userData.userId}`
|
||||||
return {
|
return {
|
||||||
ref: refList.monthlyPayoutEntries.doc(docId),
|
ref: refList.monthlyPayoutEntries.doc(docId),
|
||||||
data: {
|
data: {
|
||||||
@@ -200,35 +179,33 @@ exports.distributeMonthlyPayouts = onSchedule(
|
|||||||
computedAt: now,
|
computedAt: now,
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
await batchFirestore({
|
await batchFirestore({
|
||||||
docs,
|
docs,
|
||||||
type: BATCH_TYPE.UPDATE,
|
type: BATCH_TYPE.UPDATE,
|
||||||
});
|
})
|
||||||
|
|
||||||
const payoutLabel = `${String(context.month).padStart(2, "0")}/${
|
const payoutLabel = `${String(context.month).padStart(2, '0')}/${context.year}`
|
||||||
context.year
|
|
||||||
}`;
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
userDocs.map(async (userData) => {
|
userDocs.map(async (userData) => {
|
||||||
const receiverId =
|
const receiverId =
|
||||||
typeof userData.userId === "string" && userData.userId.trim()
|
typeof userData.userId === 'string' && userData.userId.trim()
|
||||||
? userData.userId.trim()
|
? userData.userId.trim()
|
||||||
: null;
|
: null
|
||||||
const amount = Number(userData.totalAmount) || 0;
|
const amount = Number(userData.totalAmount) || 0
|
||||||
if (!receiverId || amount <= 0) {
|
if (!receiverId || amount <= 0) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
const amountLabel = amount.toFixed(2);
|
const amountLabel = amount.toFixed(2)
|
||||||
const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`;
|
const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`
|
||||||
try {
|
try {
|
||||||
await sendNotification({
|
await sendNotification({
|
||||||
sender: "SYSTEM",
|
sender: 'SYSTEM',
|
||||||
receiver: receiverId,
|
receiver: receiverId,
|
||||||
receiverCollection: "users",
|
receiverCollection: 'users',
|
||||||
title: "Revenus disponibles",
|
title: 'Revenus disponibles',
|
||||||
message,
|
message,
|
||||||
data: {
|
data: {
|
||||||
type: ALERT_TYPE?.PAYOUT_AVAILABLE,
|
type: ALERT_TYPE?.PAYOUT_AVAILABLE,
|
||||||
@@ -239,19 +216,16 @@ exports.distributeMonthlyPayouts = onSchedule(
|
|||||||
totalStreams: userData.totalStreams,
|
totalStreams: userData.totalStreams,
|
||||||
projects: userData.projects,
|
projects: userData.projects,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
} catch (notifError) {
|
} catch (notifError) {
|
||||||
console.log(
|
console.log('[distributeMonthlyPayouts] Failed to send payout notification:', {
|
||||||
"[distributeMonthlyPayouts] Failed to send payout notification:",
|
|
||||||
{
|
|
||||||
userId: receiverId,
|
userId: receiverId,
|
||||||
error: notifError?.message || String(notifError),
|
error: notifError?.message || String(notifError),
|
||||||
},
|
})
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return null;
|
return null
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
@@ -270,14 +244,14 @@ exports.distributeMonthlyPayouts = onSchedule(
|
|||||||
totalRecipients: payouts.length,
|
totalRecipients: payouts.length,
|
||||||
totalEntries: entries.length,
|
totalEntries: entries.length,
|
||||||
eligibleEntries: eligibleEntries.length,
|
eligibleEntries: eligibleEntries.length,
|
||||||
totalAllocated: _.round(_.sumBy(payouts, "amount"), 2),
|
totalAllocated: _.round(_.sumBy(payouts, 'amount'), 2),
|
||||||
payouts,
|
payouts,
|
||||||
status: payouts.length ? "computed" : "no-data",
|
status: payouts.length ? 'computed' : 'no-data',
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
computedAt: now,
|
computedAt: now,
|
||||||
};
|
}
|
||||||
|
|
||||||
const docRef = refList.monthlyPayouts.doc(monthKey);
|
const docRef = refList.monthlyPayouts.doc(monthKey)
|
||||||
await docRef.set(summary, { merge: true });
|
await docRef.set(summary, { merge: true })
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|||||||
+53
-64
@@ -1,75 +1,67 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
const { onDocumentWritten } = require("firebase-functions/firestore");
|
const { onDocumentWritten } = require('firebase-functions/firestore')
|
||||||
const _ = require("lodash");
|
const _ = require('lodash')
|
||||||
const { refList, db } = require("../index");
|
const { refList, db } = require('../index')
|
||||||
const { getSunoTimestamps } = require("./lyrics");
|
const { getSunoTimestamps } = require('./lyrics')
|
||||||
const { deleteFolder } = require("../helpers/firebase");
|
const { deleteFolder } = require('../helpers/firebase')
|
||||||
const { buildMonthKey } = require("../helpers/stats");
|
const { buildMonthKey } = require('../helpers/stats')
|
||||||
|
|
||||||
exports.onProjectWritten = onDocumentWritten(
|
exports.onProjectWritten = onDocumentWritten('projects/{projectId}', async (projectSnap) => {
|
||||||
"projects/{projectId}",
|
|
||||||
async (projectSnap) => {
|
|
||||||
try {
|
try {
|
||||||
const { projectId } = projectSnap.params;
|
const { projectId } = projectSnap.params
|
||||||
const beforeData = projectSnap?.data?.before?.data() || null;
|
const beforeData = projectSnap?.data?.before?.data() || null
|
||||||
const afterData = projectSnap?.data?.after?.data() || null;
|
const afterData = projectSnap?.data?.after?.data() || null
|
||||||
|
|
||||||
if (!afterData) {
|
if (!afterData) {
|
||||||
const { userId } = beforeData || {};
|
const { userId } = beforeData || {}
|
||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
await deleteFolder(`users/${userId}/projects/${projectId}/`);
|
await deleteFolder(`users/${userId}/projects/${projectId}/`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await refList.tasks
|
const snapshot = await refList.tasks.where('projectId', '==', projectId).get()
|
||||||
.where("projectId", "==", projectId)
|
|
||||||
.get();
|
|
||||||
if (!snapshot.empty) {
|
if (!snapshot.empty) {
|
||||||
const batch = db.batch();
|
const batch = db.batch()
|
||||||
snapshot.forEach((doc) => {
|
snapshot.forEach((doc) => {
|
||||||
batch.delete(doc.ref);
|
batch.delete(doc.ref)
|
||||||
});
|
})
|
||||||
await batch.commit();
|
await batch.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
} else if (!beforeData) {
|
} else if (!beforeData) {
|
||||||
//song create
|
//song create
|
||||||
} else {
|
} else {
|
||||||
if (!beforeData?.songUrl && afterData?.songUrl) {
|
if (!beforeData?.songUrl && afterData?.songUrl) {
|
||||||
await getSunoTimestamps(projectId);
|
await getSunoTimestamps(projectId)
|
||||||
await refList.projects.doc(projectId).update({ hasSong: true });
|
await refList.projects.doc(projectId).update({ hasSong: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!beforeData?.playbackUrl && afterData?.playbackUrl) {
|
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 beforeViews = _.toFinite(_.get(beforeData, 'views', 0))
|
||||||
const afterViews = _.toFinite(_.get(afterData, "views", 0));
|
const afterViews = _.toFinite(_.get(afterData, 'views', 0))
|
||||||
const delta = afterViews - beforeViews;
|
const delta = afterViews - beforeViews
|
||||||
|
|
||||||
if (delta > 0) {
|
if (delta > 0) {
|
||||||
const userIdRaw = _.get(afterData, "userId", null);
|
const userIdRaw = _.get(afterData, 'userId', null)
|
||||||
const userId =
|
const userId = _.isString(userIdRaw) && _.trim(userIdRaw).length ? _.trim(userIdRaw) : null
|
||||||
_.isString(userIdRaw) && _.trim(userIdRaw).length
|
const now = admin.firestore.Timestamp.now()
|
||||||
? _.trim(userIdRaw)
|
const monthKey = buildMonthKey(now)
|
||||||
: null;
|
|
||||||
const now = admin.firestore.Timestamp.now();
|
|
||||||
const monthKey = buildMonthKey(now);
|
|
||||||
|
|
||||||
const statsDocRef = refList.projectStreamStats
|
const statsDocRef = refList.projectStreamStats
|
||||||
.doc(projectId)
|
.doc(projectId)
|
||||||
.collection("monthlyListens")
|
.collection('monthlyListens')
|
||||||
.doc(monthKey);
|
.doc(monthKey)
|
||||||
const totalsDocRef =
|
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey)
|
||||||
refList.projectStreamStatsMonthlyTotals.doc(monthKey);
|
|
||||||
|
|
||||||
await db.runTransaction(async (transaction) => {
|
await db.runTransaction(async (transaction) => {
|
||||||
const statsSnapshot = await transaction.get(statsDocRef);
|
const statsSnapshot = await transaction.get(statsDocRef)
|
||||||
const totalsSnapshot = await transaction.get(totalsDocRef);
|
const totalsSnapshot = await transaction.get(totalsDocRef)
|
||||||
const updatePayload = {
|
const updatePayload = {
|
||||||
projectId,
|
projectId,
|
||||||
userId,
|
userId,
|
||||||
@@ -78,15 +70,14 @@ exports.onProjectWritten = onDocumentWritten(
|
|||||||
lastStreamAt: now,
|
lastStreamAt: now,
|
||||||
lastDelta: delta,
|
lastDelta: delta,
|
||||||
streams: FieldValue.increment(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 = {
|
const totalsUpdatePayload = {
|
||||||
monthKey,
|
monthKey,
|
||||||
@@ -94,22 +85,20 @@ exports.onProjectWritten = onDocumentWritten(
|
|||||||
lastStreamAt: now,
|
lastStreamAt: now,
|
||||||
lastDelta: delta,
|
lastDelta: delta,
|
||||||
totalStreams: FieldValue.increment(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) {
|
} catch (error) {
|
||||||
console.log("onProjectViewsIncrement error", {
|
console.log('onProjectViewsIncrement error', {
|
||||||
message: error?.message || String(error || ""),
|
message: error?.message || String(error || ''),
|
||||||
});
|
})
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
);
|
|
||||||
|
|||||||
+30
-37
@@ -1,22 +1,22 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
const { onSchedule } = require('firebase-functions/v2/scheduler')
|
||||||
const { refList } = require("../index");
|
const { refList } = require('../index')
|
||||||
const firestore = refList.projects.firestore;
|
const firestore = refList.projects.firestore
|
||||||
|
|
||||||
function buildMonthContext(referenceDate) {
|
function buildMonthContext(referenceDate) {
|
||||||
const current = referenceDate ? new Date(referenceDate) : new Date();
|
const current = referenceDate ? new Date(referenceDate) : new Date()
|
||||||
current.setHours(0, 0, 0, 0);
|
current.setHours(0, 0, 0, 0)
|
||||||
current.setDate(1);
|
current.setDate(1)
|
||||||
|
|
||||||
const target = new Date(current);
|
const target = new Date(current)
|
||||||
target.setMonth(target.getMonth() - 1);
|
target.setMonth(target.getMonth() - 1)
|
||||||
|
|
||||||
const month = target.getMonth();
|
const month = target.getMonth()
|
||||||
const year = target.getFullYear();
|
const year = target.getFullYear()
|
||||||
const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`;
|
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`
|
||||||
const rangeStart = new Date(year, month, 1, 0, 0, 0, 0);
|
const rangeStart = new Date(year, month, 1, 0, 0, 0, 0)
|
||||||
const rangeEnd = new Date(year, month + 1, 0, 23, 59, 59, 999);
|
const rangeEnd = new Date(year, month + 1, 0, 23, 59, 59, 999)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
year,
|
year,
|
||||||
@@ -24,27 +24,22 @@ function buildMonthContext(referenceDate) {
|
|||||||
monthKey,
|
monthKey,
|
||||||
rangeStart,
|
rangeStart,
|
||||||
rangeEnd,
|
rangeEnd,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.snapshotMonthlyTopSongs = onSchedule(
|
exports.snapshotMonthlyTopSongs = onSchedule(
|
||||||
{
|
{
|
||||||
schedule: "5 0 1 * *",
|
schedule: '5 0 1 * *',
|
||||||
timeZone: "Europe/Paris",
|
timeZone: 'Europe/Paris',
|
||||||
},
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
const { scheduleTime } = event;
|
const { scheduleTime } = event
|
||||||
const context = buildMonthContext(
|
const context = buildMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
|
||||||
scheduleTime ? new Date(scheduleTime) : new Date()
|
|
||||||
);
|
|
||||||
|
|
||||||
const topProjectsSnap = await refList.projects
|
const topProjectsSnap = await refList.projects.orderBy('views', 'desc').limit(3).get()
|
||||||
.orderBy("views", "desc")
|
|
||||||
.limit(3)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
const topProjects = topProjectsSnap.docs.map((doc, index) => {
|
const topProjects = topProjectsSnap.docs.map((doc, index) => {
|
||||||
const data = doc.data() || {};
|
const data = doc.data() || {}
|
||||||
return {
|
return {
|
||||||
rank: index + 1,
|
rank: index + 1,
|
||||||
projectId: doc.id,
|
projectId: doc.id,
|
||||||
@@ -54,14 +49,12 @@ exports.snapshotMonthlyTopSongs = onSchedule(
|
|||||||
coverUrl: data.coverUrl || null,
|
coverUrl: data.coverUrl || null,
|
||||||
songUrl: data.songUrl || null,
|
songUrl: data.songUrl || null,
|
||||||
views: data.views || 0,
|
views: data.views || 0,
|
||||||
};
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
const docRef = firestore
|
const docRef = firestore.collection('monthlyTopSongs').doc(context.monthKey)
|
||||||
.collection("monthlyTopSongs")
|
|
||||||
.doc(context.monthKey);
|
|
||||||
|
|
||||||
const existingSnapshot = await docRef.get();
|
const existingSnapshot = await docRef.get()
|
||||||
const payload = {
|
const payload = {
|
||||||
monthKey: context.monthKey,
|
monthKey: context.monthKey,
|
||||||
month: context.month,
|
month: context.month,
|
||||||
@@ -73,12 +66,12 @@ exports.snapshotMonthlyTopSongs = onSchedule(
|
|||||||
topProjects,
|
topProjects,
|
||||||
totalProjects: topProjects.length,
|
totalProjects: topProjects.length,
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
};
|
}
|
||||||
|
|
||||||
if (!existingSnapshot.exists) {
|
if (!existingSnapshot.exists) {
|
||||||
payload.createdAt = FieldValue.serverTimestamp();
|
payload.createdAt = FieldValue.serverTimestamp()
|
||||||
}
|
}
|
||||||
|
|
||||||
await docRef.set(payload, { merge: true });
|
await docRef.set(payload, { merge: true })
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
|||||||
+180
-257
@@ -1,15 +1,15 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { HttpsError, onCall } = require("firebase-functions/https");
|
const { HttpsError, onCall } = require('firebase-functions/https')
|
||||||
|
|
||||||
let FieldValue = null;
|
let FieldValue = null
|
||||||
try {
|
try {
|
||||||
({ FieldValue } = require("firebase-admin/firestore"));
|
;({ FieldValue } = require('firebase-admin/firestore'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[stripe] FieldValue import failed", error?.message);
|
console.warn('[stripe] FieldValue import failed', error?.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { REGION, refsList } = require("../index");
|
const { REGION, refsList } = require('../index')
|
||||||
const STRIPE_MODE = "test";
|
const STRIPE_MODE = 'test'
|
||||||
const {
|
const {
|
||||||
getStripeClient,
|
getStripeClient,
|
||||||
getReturnUrls,
|
getReturnUrls,
|
||||||
@@ -19,23 +19,20 @@ const {
|
|||||||
formatCheckoutSessionResponse,
|
formatCheckoutSessionResponse,
|
||||||
getPortalConfigurationId,
|
getPortalConfigurationId,
|
||||||
mapStripeErrorToHttps,
|
mapStripeErrorToHttps,
|
||||||
} = require("../helpers/stripe");
|
} = require('../helpers/stripe')
|
||||||
|
|
||||||
const paymentsCollection = admin.firestore().collection("payments");
|
const paymentsCollection = admin.firestore().collection('payments')
|
||||||
|
|
||||||
const getServerTimestamp = () => {
|
const getServerTimestamp = () => {
|
||||||
if (FieldValue?.serverTimestamp) {
|
if (FieldValue?.serverTimestamp) {
|
||||||
return FieldValue.serverTimestamp();
|
return FieldValue.serverTimestamp()
|
||||||
}
|
}
|
||||||
const fallback = admin.firestore?.FieldValue;
|
const fallback = admin.firestore?.FieldValue
|
||||||
if (fallback?.serverTimestamp) {
|
if (fallback?.serverTimestamp) {
|
||||||
return fallback.serverTimestamp();
|
return fallback.serverTimestamp()
|
||||||
}
|
}
|
||||||
throw new HttpsError(
|
throw new HttpsError('failed-precondition', 'Firestore FieldValue.serverTimestamp indisponible.')
|
||||||
"failed-precondition",
|
}
|
||||||
"Firestore FieldValue.serverTimestamp indisponible.",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizePaymentIntent = (paymentIntent) => ({
|
const normalizePaymentIntent = (paymentIntent) => ({
|
||||||
id: paymentIntent.id,
|
id: paymentIntent.id,
|
||||||
@@ -48,53 +45,44 @@ const normalizePaymentIntent = (paymentIntent) => ({
|
|||||||
created: paymentIntent.created,
|
created: paymentIntent.created,
|
||||||
latest_charge: paymentIntent.latest_charge,
|
latest_charge: paymentIntent.latest_charge,
|
||||||
metadata: paymentIntent.metadata,
|
metadata: paymentIntent.metadata,
|
||||||
});
|
})
|
||||||
|
|
||||||
const createCheckoutSession = onCall({ region: REGION }, async (request) => {
|
const createCheckoutSession = onCall({ region: REGION }, async (request) => {
|
||||||
try {
|
try {
|
||||||
const uid = request?.auth?.uid;
|
const uid = request?.auth?.uid
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('unauthenticated', 'Connecte-toi pour créer une session Stripe.')
|
||||||
"unauthenticated",
|
|
||||||
"Connecte-toi pour créer une session Stripe.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedUserId =
|
const requestedUserId =
|
||||||
typeof request?.data?.userID === "string"
|
typeof request?.data?.userID === 'string' ? request.data.userID.trim() : null
|
||||||
? request.data.userID.trim()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (requestedUserId && requestedUserId !== uid) {
|
if (requestedUserId && requestedUserId !== uid) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"permission-denied",
|
'permission-denied',
|
||||||
"Tu ne peux créer une session que pour ton propre compte.",
|
'Tu ne peux créer une session que pour ton propre compte.'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const productList = Array.isArray(request?.data?.productList)
|
const productList = Array.isArray(request?.data?.productList) ? request.data.productList : []
|
||||||
? request.data.productList
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient()
|
||||||
const { lineItems, summary, hasSubscription } =
|
const { lineItems, summary, hasSubscription } = await buildCheckoutLineItems(productList, {
|
||||||
await buildCheckoutLineItems(productList, { stripe });
|
stripe,
|
||||||
|
})
|
||||||
|
|
||||||
const mode = hasSubscription ? "subscription" : "payment";
|
const mode = hasSubscription ? 'subscription' : 'payment'
|
||||||
const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls);
|
const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls)
|
||||||
|
|
||||||
const { customerId } = await ensureStripeCustomer({
|
const { customerId } = await ensureStripeCustomer({
|
||||||
uid,
|
uid,
|
||||||
stripe,
|
stripe,
|
||||||
refsList,
|
refsList,
|
||||||
createIfMissing: true,
|
createIfMissing: true,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('internal', 'Impossible de retrouver le client Stripe associé.')
|
||||||
"internal",
|
|
||||||
"Impossible de retrouver le client Stripe associé.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
@@ -107,12 +95,12 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => {
|
|||||||
metadata: {
|
metadata: {
|
||||||
firebaseUID: uid,
|
firebaseUID: uid,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
await paymentsCollection.doc(session.id).set({
|
await paymentsCollection.doc(session.id).set({
|
||||||
userId: uid,
|
userId: uid,
|
||||||
customerId,
|
customerId,
|
||||||
status: session.status || "created",
|
status: session.status || 'created',
|
||||||
mode,
|
mode,
|
||||||
createdAt: getServerTimestamp(),
|
createdAt: getServerTimestamp(),
|
||||||
updatedAt: getServerTimestamp(),
|
updatedAt: getServerTimestamp(),
|
||||||
@@ -123,59 +111,53 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => {
|
|||||||
amountTotal: session.amount_total,
|
amountTotal: session.amount_total,
|
||||||
currency: session.currency,
|
currency: session.currency,
|
||||||
paymentStatus: session.payment_status,
|
paymentStatus: session.payment_status,
|
||||||
});
|
})
|
||||||
|
|
||||||
return formatCheckoutSessionResponse(session);
|
return formatCheckoutSessionResponse(session)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[createCheckoutSession] error", error);
|
console.error('[createCheckoutSession] error', error)
|
||||||
if (error instanceof HttpsError) {
|
if (error instanceof HttpsError) {
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
throw mapStripeErrorToHttps(
|
throw mapStripeErrorToHttps(error, 'Création de la session Stripe impossible.')
|
||||||
error,
|
|
||||||
"Création de la session Stripe impossible.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
const getPremiumStatus = onCall({ region: REGION }, async (request) => {
|
const getPremiumStatus = onCall({ region: REGION }, async (request) => {
|
||||||
try {
|
try {
|
||||||
const uid = request?.auth?.uid;
|
const uid = request?.auth?.uid
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('unauthenticated', 'Connecte-toi pour consulter ton statut Stripe.')
|
||||||
"unauthenticated",
|
|
||||||
"Connecte-toi pour consulter ton statut Stripe.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient()
|
||||||
const { customerId } = await ensureStripeCustomer({
|
const { customerId } = await ensureStripeCustomer({
|
||||||
uid,
|
uid,
|
||||||
stripe,
|
stripe,
|
||||||
refsList,
|
refsList,
|
||||||
createIfMissing: false,
|
createIfMissing: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
return {
|
return {
|
||||||
customerId: null,
|
customerId: null,
|
||||||
subscriptions: [],
|
subscriptions: [],
|
||||||
invoices: [],
|
invoices: [],
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [subscriptions, invoices] = await Promise.all([
|
const [subscriptions, invoices] = await Promise.all([
|
||||||
stripe.subscriptions.list({
|
stripe.subscriptions.list({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
status: "all",
|
status: 'all',
|
||||||
expand: ["data.items.data.price"],
|
expand: ['data.items.data.price'],
|
||||||
limit: 20,
|
limit: 20,
|
||||||
}),
|
}),
|
||||||
stripe.invoices.list({
|
stripe.invoices.list({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
}),
|
}),
|
||||||
]);
|
])
|
||||||
|
|
||||||
const formattedSubscriptions = subscriptions.data.map((subscription) => ({
|
const formattedSubscriptions = subscriptions.data.map((subscription) => ({
|
||||||
id: subscription.id,
|
id: subscription.id,
|
||||||
@@ -193,7 +175,7 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => {
|
|||||||
currency: item.price?.currency,
|
currency: item.price?.currency,
|
||||||
recurring: item.price?.recurring,
|
recurring: item.price?.recurring,
|
||||||
})),
|
})),
|
||||||
}));
|
}))
|
||||||
|
|
||||||
const formattedInvoices = invoices.data.map((invoice) => ({
|
const formattedInvoices = invoices.data.map((invoice) => ({
|
||||||
id: invoice.id,
|
id: invoice.id,
|
||||||
@@ -205,179 +187,153 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => {
|
|||||||
hosted_invoice_url: invoice.hosted_invoice_url,
|
hosted_invoice_url: invoice.hosted_invoice_url,
|
||||||
invoice_pdf: invoice.invoice_pdf,
|
invoice_pdf: invoice.invoice_pdf,
|
||||||
created: invoice.created,
|
created: invoice.created,
|
||||||
}));
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
customerId,
|
customerId,
|
||||||
subscriptions: formattedSubscriptions,
|
subscriptions: formattedSubscriptions,
|
||||||
invoices: formattedInvoices,
|
invoices: formattedInvoices,
|
||||||
};
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[getPremiumStatus] error", error);
|
console.error('[getPremiumStatus] error', error)
|
||||||
if (error instanceof HttpsError) {
|
if (error instanceof HttpsError) {
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
throw mapStripeErrorToHttps(
|
throw mapStripeErrorToHttps(error, 'Impossible de récupérer le statut Stripe.')
|
||||||
error,
|
|
||||||
"Impossible de récupérer le statut Stripe.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
const createStripeCustomerPortalSession = onCall(
|
const createStripeCustomerPortalSession = onCall({ region: REGION }, async (request) => {
|
||||||
{ region: REGION },
|
|
||||||
async (request) => {
|
|
||||||
try {
|
try {
|
||||||
const uid = request?.auth?.uid;
|
const uid = request?.auth?.uid
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('unauthenticated', 'Connecte-toi pour ouvrir le portail client Stripe.')
|
||||||
"unauthenticated",
|
|
||||||
"Connecte-toi pour ouvrir le portail client Stripe.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient()
|
||||||
const { customerId } = await ensureStripeCustomer({
|
const { customerId } = await ensureStripeCustomer({
|
||||||
uid,
|
uid,
|
||||||
stripe,
|
stripe,
|
||||||
refsList,
|
refsList,
|
||||||
createIfMissing: false,
|
createIfMissing: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('failed-precondition', 'Aucun client Stripe associé à cet utilisateur.')
|
||||||
"failed-precondition",
|
|
||||||
"Aucun client Stripe associé à cet utilisateur.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const portalConfigurationId = await getPortalConfigurationId(stripe);
|
const portalConfigurationId = await getPortalConfigurationId(stripe)
|
||||||
if (!portalConfigurationId) {
|
if (!portalConfigurationId) {
|
||||||
const modeLabel = STRIPE_MODE === "prod" ? "production" : "test";
|
const modeLabel = STRIPE_MODE === 'prod' ? 'production' : 'test'
|
||||||
const envKeySuffix = STRIPE_MODE === "prod" ? "PROD" : "TEST";
|
const envKeySuffix = STRIPE_MODE === 'prod' ? 'PROD' : 'TEST'
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"failed-precondition",
|
'failed-precondition',
|
||||||
`Configure le portail client Stripe en mode ${modeLabel} ou renseigne STRIPE_PORTAL_CONFIGURATION_${envKeySuffix}.`,
|
`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({
|
const portalSession = await stripe.billingPortal.sessions.create({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
return_url: successUrl,
|
return_url: successUrl,
|
||||||
configuration: portalConfigurationId,
|
configuration: portalConfigurationId,
|
||||||
});
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: portalSession.id,
|
id: portalSession.id,
|
||||||
url: portalSession.url,
|
url: portalSession.url,
|
||||||
created: portalSession.created,
|
created: portalSession.created,
|
||||||
};
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[createStripeCustomerPortalSession] error", error);
|
console.error('[createStripeCustomerPortalSession] error', error)
|
||||||
if (error instanceof HttpsError) {
|
if (error instanceof HttpsError) {
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
throw mapStripeErrorToHttps(
|
throw mapStripeErrorToHttps(error, 'Ouverture du portail Stripe impossible.')
|
||||||
error,
|
|
||||||
"Ouverture du portail Stripe impossible.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
);
|
|
||||||
|
|
||||||
const resolveStripeConnectAccountId = (userData) => {
|
const resolveStripeConnectAccountId = (userData) => {
|
||||||
if (!userData || typeof userData !== "object") {
|
if (!userData || typeof userData !== 'object') {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidatePaths = [
|
const candidatePaths = [
|
||||||
["stripeConnectAccountId"],
|
['stripeConnectAccountId'],
|
||||||
["stripeConnectId"],
|
['stripeConnectId'],
|
||||||
["stripeAccountId"],
|
['stripeAccountId'],
|
||||||
["stripeConnectAccount"],
|
['stripeConnectAccount'],
|
||||||
["stripeAccount"],
|
['stripeAccount'],
|
||||||
["stripe", "connectAccountId"],
|
['stripe', 'connectAccountId'],
|
||||||
["stripe", "accountId"],
|
['stripe', 'accountId'],
|
||||||
["providers", "stripeConnect", "accountId"],
|
['providers', 'stripeConnect', 'accountId'],
|
||||||
];
|
]
|
||||||
|
|
||||||
for (const path of candidatePaths) {
|
for (const path of candidatePaths) {
|
||||||
let current = userData;
|
let current = userData
|
||||||
for (const key of path) {
|
for (const key of path) {
|
||||||
if (!current || typeof current !== "object") {
|
if (!current || typeof current !== 'object') {
|
||||||
current = null;
|
current = null
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
current = current[key];
|
current = current[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof current === "string" && current.trim()) {
|
if (typeof current === 'string' && current.trim()) {
|
||||||
return current.trim();
|
return current.trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|
||||||
const ensureStripeConnectAccount = async ({
|
const ensureStripeConnectAccount = async ({ uid, stripe, userRef, userData }) => {
|
||||||
uid,
|
|
||||||
stripe,
|
|
||||||
userRef,
|
|
||||||
userData,
|
|
||||||
}) => {
|
|
||||||
if (!uid || !stripe) {
|
if (!uid || !stripe) {
|
||||||
return { connectAccountId: null, userData, createdAccount: false };
|
return { connectAccountId: null, userData, createdAccount: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
let connectAccountId = resolveStripeConnectAccountId(userData);
|
let connectAccountId = resolveStripeConnectAccountId(userData)
|
||||||
if (connectAccountId) {
|
if (connectAccountId) {
|
||||||
return { connectAccountId, userData, createdAccount: false };
|
return { connectAccountId, userData, createdAccount: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
let authRecord = null;
|
let authRecord = null
|
||||||
try {
|
try {
|
||||||
authRecord = await admin.auth().getUser(uid);
|
authRecord = await admin.auth().getUser(uid)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn('[ensureStripeConnectAccount] Impossible de récupérer auth user', error)
|
||||||
"[ensureStripeConnectAccount] Impossible de récupérer auth user",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = userData?.email || authRecord?.email || undefined;
|
const email = userData?.email || authRecord?.email || undefined
|
||||||
|
|
||||||
const accountParams = {
|
const accountParams = {
|
||||||
type: "express",
|
type: 'express',
|
||||||
capabilities: {
|
capabilities: {
|
||||||
card_payments: { requested: true },
|
card_payments: { requested: true },
|
||||||
transfers: { requested: true },
|
transfers: { requested: true },
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
firebaseUID: uid,
|
firebaseUID: uid,
|
||||||
appMode: STRIPE_MODE || "test",
|
appMode: STRIPE_MODE || 'test',
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
if (email) {
|
|
||||||
accountParams.email = email;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = await stripe.accounts.create(accountParams);
|
if (email) {
|
||||||
connectAccountId = account?.id;
|
accountParams.email = email
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await stripe.accounts.create(accountParams)
|
||||||
|
connectAccountId = account?.id
|
||||||
|
|
||||||
if (!connectAccountId) {
|
if (!connectAccountId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('internal', 'Stripe n’a pas renvoyé d’identifiant de compte Connect.')
|
||||||
"internal",
|
|
||||||
"Stripe n’a pas renvoyé d’identifiant de compte Connect.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const providersConnect = {
|
const providersConnect = {
|
||||||
...(userData?.providers?.stripeConnect || {}),
|
...(userData?.providers?.stripeConnect || {}),
|
||||||
accountId: connectAccountId,
|
accountId: connectAccountId,
|
||||||
};
|
}
|
||||||
|
|
||||||
if (userRef) {
|
if (userRef) {
|
||||||
await userRef.set(
|
await userRef.set(
|
||||||
@@ -391,8 +347,8 @@ const ensureStripeConnectAccount = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -406,44 +362,39 @@ const ensureStripeConnectAccount = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
createdAccount: true,
|
createdAccount: true,
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const createStripeConnectLoginLink = onCall(
|
const createStripeConnectLoginLink = onCall({ region: REGION }, async (request) => {
|
||||||
{ region: REGION },
|
|
||||||
async (request) => {
|
|
||||||
try {
|
try {
|
||||||
const uid = request?.auth?.uid;
|
const uid = request?.auth?.uid
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('unauthenticated', 'Connecte-toi pour ouvrir Stripe Connect.')
|
||||||
"unauthenticated",
|
|
||||||
"Connecte-toi pour ouvrir Stripe Connect.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRef = refsList?.users?.doc(uid);
|
const userRef = refsList?.users?.doc(uid)
|
||||||
const snapshot = userRef ? await userRef.get() : null;
|
const snapshot = userRef ? await userRef.get() : null
|
||||||
let userData = snapshot?.exists ? snapshot.data() : null;
|
let userData = snapshot?.exists ? snapshot.data() : null
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient()
|
||||||
const ensureResult = await ensureStripeConnectAccount({
|
const ensureResult = await ensureStripeConnectAccount({
|
||||||
uid,
|
uid,
|
||||||
stripe,
|
stripe,
|
||||||
userRef,
|
userRef,
|
||||||
userData,
|
userData,
|
||||||
});
|
})
|
||||||
|
|
||||||
const connectAccountId = ensureResult.connectAccountId;
|
const connectAccountId = ensureResult.connectAccountId
|
||||||
userData = ensureResult.userData;
|
userData = ensureResult.userData
|
||||||
|
|
||||||
if (!connectAccountId) {
|
if (!connectAccountId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"failed-precondition",
|
'failed-precondition',
|
||||||
"Impossible de créer un compte Stripe Connect pour cet utilisateur.",
|
'Impossible de créer un compte Stripe Connect pour cet utilisateur.'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUrl = getReturnBaseUrl();
|
const redirectUrl = getReturnBaseUrl()
|
||||||
|
|
||||||
const loginLink = await stripe.accounts.createLoginLink(
|
const loginLink = await stripe.accounts.createLoginLink(
|
||||||
connectAccountId,
|
connectAccountId,
|
||||||
@@ -451,14 +402,11 @@ const createStripeConnectLoginLink = onCall(
|
|||||||
? {
|
? {
|
||||||
redirect_url: redirectUrl,
|
redirect_url: redirectUrl,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined
|
||||||
);
|
)
|
||||||
|
|
||||||
if (!loginLink?.url) {
|
if (!loginLink?.url) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('internal', 'Stripe Connect n’a pas renvoyé de lien de connexion.')
|
||||||
"internal",
|
|
||||||
"Stripe Connect n’a pas renvoyé de lien de connexion.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -467,109 +415,87 @@ const createStripeConnectLoginLink = onCall(
|
|||||||
created: loginLink.created,
|
created: loginLink.created,
|
||||||
connectAccountId,
|
connectAccountId,
|
||||||
createdAccount: ensureResult.createdAccount,
|
createdAccount: ensureResult.createdAccount,
|
||||||
};
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[createStripeConnectLoginLink] error", error);
|
console.error('[createStripeConnectLoginLink] error', error)
|
||||||
if (error instanceof HttpsError) {
|
if (error instanceof HttpsError) {
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
throw mapStripeErrorToHttps(
|
throw mapStripeErrorToHttps(error, 'Ouverture de Stripe Connect impossible.')
|
||||||
error,
|
|
||||||
"Ouverture de Stripe Connect impossible.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
);
|
|
||||||
|
|
||||||
const verifyStripePayment = onCall({ region: REGION }, async (request) => {
|
const verifyStripePayment = onCall({ region: REGION }, async (request) => {
|
||||||
try {
|
try {
|
||||||
const uid = request?.auth?.uid;
|
const uid = request?.auth?.uid
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('unauthenticated', 'Connecte-toi pour vérifier un paiement Stripe.')
|
||||||
"unauthenticated",
|
|
||||||
"Connecte-toi pour vérifier un paiement Stripe.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawPaymentId =
|
const rawPaymentId =
|
||||||
typeof request?.data?.paymentId === "string"
|
typeof request?.data?.paymentId === 'string' ? request.data.paymentId.trim() : ''
|
||||||
? request.data.paymentId.trim()
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (!rawPaymentId) {
|
if (!rawPaymentId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('invalid-argument', 'Fournis un identifiant de paiement Stripe.')
|
||||||
"invalid-argument",
|
|
||||||
"Fournis un identifiant de paiement Stripe.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient()
|
||||||
|
|
||||||
const fetchPaymentIntent = async (paymentIntentId) =>
|
const fetchPaymentIntent = async (paymentIntentId) =>
|
||||||
stripe.paymentIntents.retrieve(paymentIntentId, {
|
stripe.paymentIntents.retrieve(paymentIntentId, {
|
||||||
expand: ["latest_charge"],
|
expand: ['latest_charge'],
|
||||||
});
|
})
|
||||||
|
|
||||||
const fetchCheckoutSession = async (sessionId) =>
|
const fetchCheckoutSession = async (sessionId) =>
|
||||||
stripe.checkout.sessions.retrieve(sessionId, {
|
stripe.checkout.sessions.retrieve(sessionId, {
|
||||||
expand: ["payment_intent"],
|
expand: ['payment_intent'],
|
||||||
});
|
})
|
||||||
|
|
||||||
let paymentIntent = null;
|
let paymentIntent = null
|
||||||
let checkoutSession = null;
|
let checkoutSession = null
|
||||||
let paymentType = null;
|
let paymentType = null
|
||||||
|
|
||||||
if (rawPaymentId.startsWith("pi_")) {
|
if (rawPaymentId.startsWith('pi_')) {
|
||||||
paymentIntent = await fetchPaymentIntent(rawPaymentId);
|
paymentIntent = await fetchPaymentIntent(rawPaymentId)
|
||||||
paymentType = "payment_intent";
|
paymentType = 'payment_intent'
|
||||||
} else if (rawPaymentId.startsWith("cs_")) {
|
} else if (rawPaymentId.startsWith('cs_')) {
|
||||||
checkoutSession = await fetchCheckoutSession(rawPaymentId);
|
checkoutSession = await fetchCheckoutSession(rawPaymentId)
|
||||||
paymentIntent =
|
paymentIntent =
|
||||||
checkoutSession?.payment_intent &&
|
checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object'
|
||||||
typeof checkoutSession.payment_intent === "object"
|
|
||||||
? checkoutSession.payment_intent
|
? checkoutSession.payment_intent
|
||||||
: checkoutSession?.payment_intent
|
: checkoutSession?.payment_intent
|
||||||
? await fetchPaymentIntent(checkoutSession.payment_intent)
|
? await fetchPaymentIntent(checkoutSession.payment_intent)
|
||||||
: null;
|
: null
|
||||||
paymentType = "checkout_session";
|
paymentType = 'checkout_session'
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
paymentIntent = await fetchPaymentIntent(rawPaymentId);
|
paymentIntent = await fetchPaymentIntent(rawPaymentId)
|
||||||
paymentType = "payment_intent";
|
paymentType = 'payment_intent'
|
||||||
} catch (intentError) {
|
} catch (intentError) {
|
||||||
try {
|
try {
|
||||||
checkoutSession = await fetchCheckoutSession(rawPaymentId);
|
checkoutSession = await fetchCheckoutSession(rawPaymentId)
|
||||||
paymentType = "checkout_session";
|
paymentType = 'checkout_session'
|
||||||
paymentIntent =
|
paymentIntent =
|
||||||
checkoutSession?.payment_intent &&
|
checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object'
|
||||||
typeof checkoutSession.payment_intent === "object"
|
|
||||||
? checkoutSession.payment_intent
|
? checkoutSession.payment_intent
|
||||||
: checkoutSession?.payment_intent
|
: checkoutSession?.payment_intent
|
||||||
? await fetchPaymentIntent(checkoutSession.payment_intent)
|
? await fetchPaymentIntent(checkoutSession.payment_intent)
|
||||||
: null;
|
: null
|
||||||
} catch (sessionError) {
|
} catch (sessionError) {
|
||||||
console.error("[verifyStripePayment] lookup failure", {
|
console.error('[verifyStripePayment] lookup failure', {
|
||||||
intentError,
|
intentError,
|
||||||
sessionError,
|
sessionError,
|
||||||
});
|
})
|
||||||
throw new HttpsError(
|
throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.')
|
||||||
"not-found",
|
|
||||||
"Aucun paiement Stripe trouvé avec cet identifiant.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!paymentIntent && !checkoutSession) {
|
if (!paymentIntent && !checkoutSession) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.')
|
||||||
"not-found",
|
|
||||||
"Aucun paiement Stripe trouvé avec cet identifiant.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedIntent = paymentIntent
|
const normalizedIntent = paymentIntent ? normalizePaymentIntent(paymentIntent) : null
|
||||||
? normalizePaymentIntent(paymentIntent)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
type: paymentType,
|
type: paymentType,
|
||||||
@@ -594,41 +520,38 @@ const verifyStripePayment = onCall({ region: REGION }, async (request) => {
|
|||||||
checkoutSession?.status ??
|
checkoutSession?.status ??
|
||||||
null,
|
null,
|
||||||
currency: normalizedIntent?.currency ?? checkoutSession?.currency ?? null,
|
currency: normalizedIntent?.currency ?? checkoutSession?.currency ?? null,
|
||||||
};
|
}
|
||||||
|
|
||||||
const paymentDocId =
|
const paymentDocId =
|
||||||
paymentType === "checkout_session"
|
paymentType === 'checkout_session' ? checkoutSession?.id : normalizedIntent?.id
|
||||||
? checkoutSession?.id
|
|
||||||
: normalizedIntent?.id;
|
|
||||||
|
|
||||||
if (paymentDocId) {
|
if (paymentDocId) {
|
||||||
const paymentDocRef = paymentsCollection.doc(paymentDocId);
|
const paymentDocRef = paymentsCollection.doc(paymentDocId)
|
||||||
const existing = await paymentDocRef.get();
|
const existing = await paymentDocRef.get()
|
||||||
if (existing.exists) {
|
if (existing.exists) {
|
||||||
await paymentDocRef.set(
|
await paymentDocRef.set(
|
||||||
{
|
{
|
||||||
status: response.status,
|
status: response.status,
|
||||||
paymentStatus: checkoutSession?.payment_status,
|
paymentStatus: checkoutSession?.payment_status,
|
||||||
amountTotal:
|
amountTotal: checkoutSession?.amount_total ?? normalizedIntent?.amount,
|
||||||
checkoutSession?.amount_total ?? normalizedIntent?.amount,
|
|
||||||
amountReceived: normalizedIntent?.amount_received,
|
amountReceived: normalizedIntent?.amount_received,
|
||||||
currency: response.currency,
|
currency: response.currency,
|
||||||
updatedAt: getServerTimestamp(),
|
updatedAt: getServerTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[verifyStripePayment] error", error);
|
console.error('[verifyStripePayment] error', error)
|
||||||
if (error instanceof HttpsError) {
|
if (error instanceof HttpsError) {
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
throw mapStripeErrorToHttps(error, "Vérification du paiement impossible.");
|
throw mapStripeErrorToHttps(error, 'Vérification du paiement impossible.')
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createCheckoutSession,
|
createCheckoutSession,
|
||||||
@@ -636,4 +559,4 @@ module.exports = {
|
|||||||
createStripeCustomerPortalSession,
|
createStripeCustomerPortalSession,
|
||||||
createStripeConnectLoginLink,
|
createStripeConnectLoginLink,
|
||||||
verifyStripePayment,
|
verifyStripePayment,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
const { HttpsError, onCall } = require("firebase-functions/https");
|
const { HttpsError, onCall } = require('firebase-functions/https')
|
||||||
|
|
||||||
const { getStripeClient, mapStripeErrorToHttps } = require("../../helpers/stripe");
|
const { getStripeClient, mapStripeErrorToHttps } = require('../../helpers/stripe')
|
||||||
const { REGION } = require("./config");
|
const { REGION } = require('./config')
|
||||||
const {
|
const {
|
||||||
SUBSCRIPTION_PRICE_IDS,
|
SUBSCRIPTION_PRICE_IDS,
|
||||||
SUBSCRIPTION_PRICE_METADATA,
|
SUBSCRIPTION_PRICE_METADATA,
|
||||||
COIN_PACK_PRODUCTS,
|
COIN_PACK_PRODUCTS,
|
||||||
} = require("./constants");
|
} = require('./constants')
|
||||||
const { parseCoinsPerMonth, formatCoinPack } = require("./shared");
|
const { parseCoinsPerMonth, formatCoinPack } = require('./shared')
|
||||||
|
|
||||||
const formatPlan = (price, priceId) => {
|
const formatPlan = (price, priceId) => {
|
||||||
if (!price || typeof price !== "object") {
|
if (!price || typeof price !== 'object') {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const product =
|
const product = typeof price.product === 'object' && price.product !== null ? price.product : {}
|
||||||
typeof price.product === "object" && price.product !== null
|
|
||||||
? price.product
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: price.id || priceId,
|
id: price.id || priceId,
|
||||||
priceId: price.id || priceId,
|
priceId: price.id || priceId,
|
||||||
active: price.active !== false,
|
active: price.active !== false,
|
||||||
currency: price.currency || "eur",
|
currency: price.currency || 'eur',
|
||||||
unitAmount: price.unit_amount,
|
unitAmount: price.unit_amount,
|
||||||
unitAmountDecimal: price.unit_amount_decimal,
|
unitAmountDecimal: price.unit_amount_decimal,
|
||||||
transformQuantity: price.transform_quantity || null,
|
transformQuantity: price.transform_quantity || null,
|
||||||
@@ -36,16 +33,16 @@ const formatPlan = (price, priceId) => {
|
|||||||
metadata: price.metadata || {},
|
metadata: price.metadata || {},
|
||||||
product: {
|
product: {
|
||||||
id: product.id || null,
|
id: product.id || null,
|
||||||
name: product.name || "",
|
name: product.name || '',
|
||||||
description: product.description || "",
|
description: product.description || '',
|
||||||
metadata: product.metadata || {},
|
metadata: product.metadata || {},
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const listSubscriptionPlans = onCall({ region: REGION }, async () => {
|
const listSubscriptionPlans = onCall({ region: REGION }, async () => {
|
||||||
try {
|
try {
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient()
|
||||||
|
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
Object.entries(SUBSCRIPTION_PRICE_IDS).map(async ([period, priceIds]) => {
|
Object.entries(SUBSCRIPTION_PRICE_IDS).map(async ([period, priceIds]) => {
|
||||||
@@ -53,88 +50,79 @@ const listSubscriptionPlans = onCall({ region: REGION }, async () => {
|
|||||||
priceIds.map(async (priceId) => {
|
priceIds.map(async (priceId) => {
|
||||||
try {
|
try {
|
||||||
const price = await stripe.prices.retrieve(priceId, {
|
const price = await stripe.prices.retrieve(priceId, {
|
||||||
expand: ["product"],
|
expand: ['product'],
|
||||||
});
|
})
|
||||||
return formatPlan(price, priceId);
|
return formatPlan(price, priceId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`[subscription-listSubscriptionPlans] Impossible de récupérer ${priceId}`,
|
`[subscription-listSubscriptionPlans] Impossible de récupérer ${priceId}`,
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
return [period, periodPlans.filter(Boolean)];
|
return [period, periodPlans.filter(Boolean)]
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plans: Object.fromEntries(entries),
|
plans: Object.fromEntries(entries),
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
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 () => {
|
const listCoinPacks = onCall({ region: REGION }, async () => {
|
||||||
try {
|
try {
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient()
|
||||||
|
|
||||||
const packs = await Promise.all(
|
const packs = await Promise.all(
|
||||||
COIN_PACK_PRODUCTS.map(async (pack) => {
|
COIN_PACK_PRODUCTS.map(async (pack) => {
|
||||||
try {
|
try {
|
||||||
const product = await stripe.products.retrieve(pack.productId, {
|
const product = await stripe.products.retrieve(pack.productId, {
|
||||||
expand: ["default_price"],
|
expand: ['default_price'],
|
||||||
});
|
})
|
||||||
|
|
||||||
let resolvedPrice = null;
|
let resolvedPrice = null
|
||||||
if (typeof product?.default_price === "string") {
|
if (typeof product?.default_price === 'string') {
|
||||||
resolvedPrice = await stripe.prices.retrieve(product.default_price);
|
resolvedPrice = await stripe.prices.retrieve(product.default_price)
|
||||||
} else if (
|
} else if (product?.default_price && typeof product.default_price === 'object') {
|
||||||
product?.default_price &&
|
resolvedPrice = product.default_price
|
||||||
typeof product.default_price === "object"
|
|
||||||
) {
|
|
||||||
resolvedPrice = product.default_price;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatted = formatCoinPack({
|
const formatted = formatCoinPack({
|
||||||
product,
|
product,
|
||||||
price: resolvedPrice,
|
price: resolvedPrice,
|
||||||
});
|
})
|
||||||
return {
|
return {
|
||||||
...formatted,
|
...formatted,
|
||||||
coinPackKey: pack.key || null,
|
coinPackKey: pack.key || null,
|
||||||
};
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[subscription-listCoinPacks] Unable to retrieve product",
|
'[subscription-listCoinPacks] Unable to retrieve product',
|
||||||
pack.productId,
|
pack.productId,
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
packs: packs.filter(Boolean),
|
packs: packs.filter(Boolean),
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
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 = {
|
module.exports = {
|
||||||
listSubscriptionPlans,
|
listSubscriptionPlans,
|
||||||
listCoinPacks,
|
listCoinPacks,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { HttpsError, onCall } = require("firebase-functions/https");
|
const { HttpsError, onCall } = require('firebase-functions/https')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getStripeClient,
|
getStripeClient,
|
||||||
@@ -7,101 +7,86 @@ const {
|
|||||||
getReturnUrls,
|
getReturnUrls,
|
||||||
formatCheckoutSessionResponse,
|
formatCheckoutSessionResponse,
|
||||||
mapStripeErrorToHttps,
|
mapStripeErrorToHttps,
|
||||||
} = require("../../helpers/stripe");
|
} = require('../../helpers/stripe')
|
||||||
const { REGION } = require("./config");
|
const { REGION } = require('./config')
|
||||||
const {
|
const {
|
||||||
ALL_SUBSCRIPTION_PRICE_IDS,
|
ALL_SUBSCRIPTION_PRICE_IDS,
|
||||||
COIN_PACK_PRODUCT_IDS,
|
COIN_PACK_PRODUCT_IDS,
|
||||||
COIN_PACK_PRODUCT_MAP,
|
COIN_PACK_PRODUCT_MAP,
|
||||||
} = require("./constants");
|
} = require('./constants')
|
||||||
const {
|
const { refsList, formatCoinPack, getSubscriptionMetaFromPrice } = require('./shared')
|
||||||
refsList,
|
|
||||||
formatCoinPack,
|
|
||||||
getSubscriptionMetaFromPrice,
|
|
||||||
} = require("./shared");
|
|
||||||
|
|
||||||
const CHECKOUT_UI_MODES = new Set(["hosted", "embedded"]);
|
const CHECKOUT_UI_MODES = new Set(['hosted', 'embedded'])
|
||||||
|
|
||||||
const sanitizePriceId = (value) => {
|
const sanitizePriceId = (value) => {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== 'string') {
|
||||||
return "";
|
return ''
|
||||||
}
|
}
|
||||||
return value.trim();
|
return value.trim()
|
||||||
};
|
}
|
||||||
|
|
||||||
const resolveCheckoutUiMode = (request) => {
|
const resolveCheckoutUiMode = (request) => {
|
||||||
if (!request || !request.data || typeof request.data.uiMode === "undefined") {
|
if (!request || !request.data || typeof request.data.uiMode === 'undefined') {
|
||||||
return "hosted";
|
return 'hosted'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof request.data.uiMode !== "string") {
|
if (typeof request.data.uiMode !== 'string') {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").',
|
'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedUiMode = request.data.uiMode.trim().toLowerCase();
|
const normalizedUiMode = request.data.uiMode.trim().toLowerCase()
|
||||||
if (!CHECKOUT_UI_MODES.has(normalizedUiMode)) {
|
if (!CHECKOUT_UI_MODES.has(normalizedUiMode)) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
`uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`,
|
`uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizedUiMode;
|
return normalizedUiMode
|
||||||
};
|
}
|
||||||
|
|
||||||
const withCheckoutNavigationParams = (
|
const withCheckoutNavigationParams = (baseParams, { uiMode, successUrl, cancelUrl }) => {
|
||||||
baseParams,
|
if (uiMode === 'embedded') {
|
||||||
{ uiMode, successUrl, cancelUrl },
|
|
||||||
) => {
|
|
||||||
if (uiMode === "embedded") {
|
|
||||||
return {
|
return {
|
||||||
...baseParams,
|
...baseParams,
|
||||||
ui_mode: "embedded",
|
ui_mode: 'embedded',
|
||||||
return_url: undefined,
|
return_url: undefined,
|
||||||
redirect_on_completion: "never",
|
redirect_on_completion: 'never',
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseParams,
|
...baseParams,
|
||||||
success_url: successUrl,
|
success_url: successUrl,
|
||||||
cancel_url: cancelUrl,
|
cancel_url: cancelUrl,
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const createSubscriptionCheckoutSession = onCall(
|
const createSubscriptionCheckoutSession = onCall({ region: REGION }, async (request) => {
|
||||||
{ region: REGION },
|
|
||||||
async (request) => {
|
|
||||||
try {
|
try {
|
||||||
const uid = request?.auth?.uid;
|
const uid = request?.auth?.uid
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('unauthenticated', 'Connecte-toi pour souscrire un abonnement.')
|
||||||
"unauthenticated",
|
|
||||||
"Connecte-toi pour souscrire un abonnement.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawPriceId = request?.data?.priceId;
|
const rawPriceId = request?.data?.priceId
|
||||||
const priceId = sanitizePriceId(rawPriceId);
|
const priceId = sanitizePriceId(rawPriceId)
|
||||||
if (!priceId) {
|
if (!priceId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('invalid-argument', 'Un identifiant de prix Stripe est requis.')
|
||||||
"invalid-argument",
|
|
||||||
"Un identifiant de prix Stripe est requis.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALL_SUBSCRIPTION_PRICE_IDS.includes(priceId)) {
|
if (!ALL_SUBSCRIPTION_PRICE_IDS.includes(priceId)) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
`L'identifiant de prix ${priceId} n'est pas pris en charge.`,
|
`L'identifiant de prix ${priceId} n'est pas pris en charge.`
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient()
|
||||||
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
|
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
|
||||||
|
|
||||||
const { lineItems, summary } = await buildCheckoutLineItems(
|
const { lineItems, summary } = await buildCheckoutLineItems(
|
||||||
[
|
[
|
||||||
@@ -111,40 +96,40 @@ const createSubscriptionCheckoutSession = onCall(
|
|||||||
isRenewable: true,
|
isRenewable: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ stripe },
|
{ stripe }
|
||||||
);
|
)
|
||||||
|
|
||||||
const uiMode = resolveCheckoutUiMode(request);
|
const uiMode = resolveCheckoutUiMode(request)
|
||||||
const shouldProvideReturnUrls = uiMode !== "embedded";
|
const shouldProvideReturnUrls = uiMode !== 'embedded'
|
||||||
const { successUrl, cancelUrl } = shouldProvideReturnUrls
|
const { successUrl, cancelUrl } = shouldProvideReturnUrls
|
||||||
? getReturnUrls(request?.data?.returnUrls)
|
? getReturnUrls(request?.data?.returnUrls)
|
||||||
: { successUrl: null, cancelUrl: null };
|
: { successUrl: null, cancelUrl: null }
|
||||||
|
|
||||||
const { customerId } = await ensureStripeCustomer({
|
const { customerId } = await ensureStripeCustomer({
|
||||||
uid,
|
uid,
|
||||||
stripe,
|
stripe,
|
||||||
refsList,
|
refsList,
|
||||||
createIfMissing: true,
|
createIfMissing: true,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"failed-precondition",
|
'failed-precondition',
|
||||||
"Impossible de retrouver le client Stripe associé.",
|
'Impossible de retrouver le client Stripe associé.'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create(
|
const session = await stripe.checkout.sessions.create(
|
||||||
withCheckoutNavigationParams(
|
withCheckoutNavigationParams(
|
||||||
{
|
{
|
||||||
mode: "subscription",
|
mode: 'subscription',
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
line_items: lineItems,
|
line_items: lineItems,
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
metadata: {
|
metadata: {
|
||||||
firebaseUID: uid,
|
firebaseUID: uid,
|
||||||
priceId,
|
priceId,
|
||||||
purchaseType: "SUBSCRIPTION",
|
purchaseType: 'SUBSCRIPTION',
|
||||||
subscriptionLevel: priceMeta.level || null,
|
subscriptionLevel: priceMeta.level || null,
|
||||||
subscriptionBillingPeriod: priceMeta.billingPeriod || null,
|
subscriptionBillingPeriod: priceMeta.billingPeriod || null,
|
||||||
},
|
},
|
||||||
@@ -153,122 +138,103 @@ const createSubscriptionCheckoutSession = onCall(
|
|||||||
uiMode,
|
uiMode,
|
||||||
successUrl,
|
successUrl,
|
||||||
cancelUrl,
|
cancelUrl,
|
||||||
},
|
}
|
||||||
),
|
)
|
||||||
);
|
)
|
||||||
|
|
||||||
return formatCheckoutSessionResponse(session);
|
return formatCheckoutSessionResponse(session)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error('[subscription-createSubscriptionCheckoutSession] error', error)
|
||||||
"[subscription-createSubscriptionCheckoutSession] error",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
if (error instanceof HttpsError) {
|
if (error instanceof HttpsError) {
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
throw mapStripeErrorToHttps(
|
throw mapStripeErrorToHttps(error, "Impossible de créer la session d'abonnement Stripe.")
|
||||||
error,
|
|
||||||
"Impossible de créer la session d'abonnement Stripe.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
);
|
|
||||||
|
|
||||||
const createCoinPackCheckoutSession = onCall(
|
const createCoinPackCheckoutSession = onCall({ region: REGION }, async (request) => {
|
||||||
{ region: REGION },
|
|
||||||
async (request) => {
|
|
||||||
try {
|
try {
|
||||||
const uid = request?.auth?.uid;
|
const uid = request?.auth?.uid
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('unauthenticated', 'Connecte-toi pour acheter un pack de pièces.')
|
||||||
"unauthenticated",
|
|
||||||
"Connecte-toi pour acheter un pack de pièces.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawProductId = request?.data?.productId;
|
const rawProductId = request?.data?.productId
|
||||||
const productId =
|
const productId = typeof rawProductId === 'string' ? rawProductId.trim() : ''
|
||||||
typeof rawProductId === "string" ? rawProductId.trim() : "";
|
|
||||||
|
|
||||||
if (!productId) {
|
if (!productId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('invalid-argument', 'Un identifiant de produit Stripe est requis.')
|
||||||
"invalid-argument",
|
|
||||||
"Un identifiant de produit Stripe est requis.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!COIN_PACK_PRODUCT_IDS.includes(productId)) {
|
if (!COIN_PACK_PRODUCT_IDS.includes(productId)) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
'invalid-argument',
|
||||||
`Le produit ${productId} n'est pas un pack de pièces autorisé`,
|
`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, {
|
const product = await stripe.products.retrieve(productId, {
|
||||||
expand: ["default_price"],
|
expand: ['default_price'],
|
||||||
});
|
})
|
||||||
|
|
||||||
let resolvedPrice = null;
|
let resolvedPrice = null
|
||||||
if (typeof product?.default_price === "string") {
|
if (typeof product?.default_price === 'string') {
|
||||||
resolvedPrice = await stripe.prices.retrieve(product.default_price);
|
resolvedPrice = await stripe.prices.retrieve(product.default_price)
|
||||||
} else if (
|
} else if (product?.default_price && typeof product.default_price === 'object') {
|
||||||
product?.default_price &&
|
resolvedPrice = product.default_price
|
||||||
typeof product.default_price === "object"
|
|
||||||
) {
|
|
||||||
resolvedPrice = product.default_price;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let coinPack = null;
|
let coinPack = null
|
||||||
try {
|
try {
|
||||||
coinPack = formatCoinPack({
|
coinPack = formatCoinPack({
|
||||||
product,
|
product,
|
||||||
price: resolvedPrice,
|
price: resolvedPrice,
|
||||||
});
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[subscription-createCoinPackCheckoutSession] invalid coin pack metadata",
|
'[subscription-createCoinPackCheckoutSession] invalid coin pack metadata',
|
||||||
productId,
|
productId,
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"failed-precondition",
|
'failed-precondition',
|
||||||
"Le pack Stripe est mal configuré (metadata.coins manquant).",
|
'Le pack Stripe est mal configuré (metadata.coins manquant).'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!coinPack?.priceId) {
|
if (!coinPack?.priceId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"failed-precondition",
|
'failed-precondition',
|
||||||
"Impossible de déterminer le prix Stripe pour ce pack.",
|
'Impossible de déterminer le prix Stripe pour ce pack.'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const uiMode = resolveCheckoutUiMode(request);
|
const uiMode = resolveCheckoutUiMode(request)
|
||||||
const shouldProvideReturnUrls = uiMode !== "embedded";
|
const shouldProvideReturnUrls = uiMode !== 'embedded'
|
||||||
const { successUrl, cancelUrl } = shouldProvideReturnUrls
|
const { successUrl, cancelUrl } = shouldProvideReturnUrls
|
||||||
? getReturnUrls(request?.data?.returnUrls)
|
? getReturnUrls(request?.data?.returnUrls)
|
||||||
: { successUrl: null, cancelUrl: null };
|
: { successUrl: null, cancelUrl: null }
|
||||||
|
|
||||||
const { customerId } = await ensureStripeCustomer({
|
const { customerId } = await ensureStripeCustomer({
|
||||||
uid,
|
uid,
|
||||||
stripe,
|
stripe,
|
||||||
refsList,
|
refsList,
|
||||||
createIfMissing: true,
|
createIfMissing: true,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"failed-precondition",
|
'failed-precondition',
|
||||||
"Impossible de retrouver le client Stripe associé.",
|
'Impossible de retrouver le client Stripe associé.'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create(
|
const session = await stripe.checkout.sessions.create(
|
||||||
withCheckoutNavigationParams(
|
withCheckoutNavigationParams(
|
||||||
{
|
{
|
||||||
mode: "payment",
|
mode: 'payment',
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
@@ -279,7 +245,7 @@ const createCoinPackCheckoutSession = onCall(
|
|||||||
allow_promotion_codes: false,
|
allow_promotion_codes: false,
|
||||||
metadata: {
|
metadata: {
|
||||||
firebaseUID: uid,
|
firebaseUID: uid,
|
||||||
purchaseType: "COIN_PACK",
|
purchaseType: 'COIN_PACK',
|
||||||
coinPackProductId: coinPack.productId,
|
coinPackProductId: coinPack.productId,
|
||||||
coinPackPriceId: coinPack.priceId,
|
coinPackPriceId: coinPack.priceId,
|
||||||
coinAmount: coinPack.coinAmount,
|
coinAmount: coinPack.coinAmount,
|
||||||
@@ -290,29 +256,22 @@ const createCoinPackCheckoutSession = onCall(
|
|||||||
uiMode,
|
uiMode,
|
||||||
successUrl,
|
successUrl,
|
||||||
cancelUrl,
|
cancelUrl,
|
||||||
},
|
}
|
||||||
),
|
)
|
||||||
);
|
)
|
||||||
|
|
||||||
return formatCheckoutSessionResponse(session);
|
return formatCheckoutSessionResponse(session)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error('[subscription-createCoinPackCheckoutSession] error', error)
|
||||||
"[subscription-createCoinPackCheckoutSession] error",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
if (error instanceof HttpsError) {
|
if (error instanceof HttpsError) {
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
throw mapStripeErrorToHttps(
|
throw mapStripeErrorToHttps(error, "Impossible de créer la session d'achat de pièces.")
|
||||||
error,
|
|
||||||
"Impossible de créer la session d'achat de pièces.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createSubscriptionCheckoutSession,
|
createSubscriptionCheckoutSession,
|
||||||
createCoinPackCheckoutSession,
|
createCoinPackCheckoutSession,
|
||||||
resolveCheckoutUiMode,
|
resolveCheckoutUiMode,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const REGION = process.env.FIREBASE_REGION || "europe-west1";
|
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
REGION,
|
REGION,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,86 +1,76 @@
|
|||||||
const SUBSCRIPTION_PRICE_IDS = {
|
const SUBSCRIPTION_PRICE_IDS = {
|
||||||
monthly: [
|
monthly: [
|
||||||
"price_1SPgitCzf2o5bDRdbnhLFx6f",
|
'price_1SPgitCzf2o5bDRdbnhLFx6f',
|
||||||
"price_1SPgjCCzf2o5bDRdr08Xzp8u",
|
'price_1SPgjCCzf2o5bDRdr08Xzp8u',
|
||||||
"price_1SPgjaCzf2o5bDRdd9Xo2u26",
|
'price_1SPgjaCzf2o5bDRdd9Xo2u26',
|
||||||
],
|
],
|
||||||
annual: [
|
annual: [
|
||||||
"price_1SPgkDCzf2o5bDRdNGLVNeQ3",
|
'price_1SPgkDCzf2o5bDRdNGLVNeQ3',
|
||||||
"price_1SPgkXCzf2o5bDRdejBVxEBY",
|
'price_1SPgkXCzf2o5bDRdejBVxEBY',
|
||||||
"price_1SPgkqCzf2o5bDRdIcUwTDrm",
|
'price_1SPgkqCzf2o5bDRdIcUwTDrm',
|
||||||
],
|
],
|
||||||
};
|
}
|
||||||
|
|
||||||
const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat();
|
const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat()
|
||||||
|
|
||||||
const SUBSCRIPTION_LEVEL_ALLOWANCES = {
|
const SUBSCRIPTION_LEVEL_ALLOWANCES = {
|
||||||
starter: 10,
|
starter: 10,
|
||||||
pro: 40,
|
pro: 40,
|
||||||
premium: 60,
|
premium: 60,
|
||||||
};
|
}
|
||||||
|
|
||||||
const SUBSCRIPTION_PRICE_METADATA = {
|
const SUBSCRIPTION_PRICE_METADATA = {
|
||||||
price_1SPgitCzf2o5bDRdbnhLFx6f: {
|
price_1SPgitCzf2o5bDRdbnhLFx6f: {
|
||||||
level: "starter",
|
level: 'starter',
|
||||||
billingPeriod: "monthly",
|
billingPeriod: 'monthly',
|
||||||
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
|
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
|
||||||
},
|
},
|
||||||
price_1SPgjCCzf2o5bDRdr08Xzp8u: {
|
price_1SPgjCCzf2o5bDRdr08Xzp8u: {
|
||||||
level: "pro",
|
level: 'pro',
|
||||||
billingPeriod: "monthly",
|
billingPeriod: 'monthly',
|
||||||
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
|
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
|
||||||
},
|
},
|
||||||
price_1SPgjaCzf2o5bDRdd9Xo2u26: {
|
price_1SPgjaCzf2o5bDRdd9Xo2u26: {
|
||||||
level: "premium",
|
level: 'premium',
|
||||||
billingPeriod: "monthly",
|
billingPeriod: 'monthly',
|
||||||
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
|
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
|
||||||
},
|
},
|
||||||
price_1SPgkDCzf2o5bDRdNGLVNeQ3: {
|
price_1SPgkDCzf2o5bDRdNGLVNeQ3: {
|
||||||
level: "starter",
|
level: 'starter',
|
||||||
billingPeriod: "annual",
|
billingPeriod: 'annual',
|
||||||
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
|
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
|
||||||
},
|
},
|
||||||
price_1SPgkXCzf2o5bDRdejBVxEBY: {
|
price_1SPgkXCzf2o5bDRdejBVxEBY: {
|
||||||
level: "pro",
|
level: 'pro',
|
||||||
billingPeriod: "annual",
|
billingPeriod: 'annual',
|
||||||
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
|
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
|
||||||
},
|
},
|
||||||
price_1SPgkqCzf2o5bDRdIcUwTDrm: {
|
price_1SPgkqCzf2o5bDRdIcUwTDrm: {
|
||||||
level: "premium",
|
level: 'premium',
|
||||||
billingPeriod: "annual",
|
billingPeriod: 'annual',
|
||||||
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
|
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
const COIN_PACK_PRODUCTS = [
|
const COIN_PACK_PRODUCTS = [
|
||||||
{ productId: "prod_TMPPEXZ1wGk2cS", key: "starter" },
|
{ productId: 'prod_TMPPEXZ1wGk2cS', key: 'starter' },
|
||||||
{ productId: "prod_TMPQ5SNdS47gY6", key: "pro" },
|
{ productId: 'prod_TMPQ5SNdS47gY6', key: 'pro' },
|
||||||
{ productId: "prod_TMPRpA0qQSHXj5", key: "premium" },
|
{ productId: 'prod_TMPRpA0qQSHXj5', key: 'premium' },
|
||||||
];
|
]
|
||||||
|
|
||||||
const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId);
|
const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId)
|
||||||
|
|
||||||
const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce(
|
const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce(
|
||||||
(acc, pack) => ({
|
(acc, pack) => ({
|
||||||
...acc,
|
...acc,
|
||||||
[pack.productId]: pack,
|
[pack.productId]: pack,
|
||||||
}),
|
}),
|
||||||
{},
|
{}
|
||||||
);
|
)
|
||||||
|
|
||||||
const PREMIUM_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]);
|
const PREMIUM_SUBSCRIPTION_STATUSES = new Set(['active', 'trialing'])
|
||||||
const CANCELABLE_SUBSCRIPTION_STATUSES = new Set([
|
const CANCELABLE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid'])
|
||||||
"trialing",
|
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid'])
|
||||||
"active",
|
|
||||||
"past_due",
|
|
||||||
"unpaid",
|
|
||||||
]);
|
|
||||||
const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
|
|
||||||
"trialing",
|
|
||||||
"active",
|
|
||||||
"past_due",
|
|
||||||
"unpaid",
|
|
||||||
]);
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
SUBSCRIPTION_PRICE_IDS,
|
SUBSCRIPTION_PRICE_IDS,
|
||||||
@@ -93,4 +83,4 @@ module.exports = {
|
|||||||
PREMIUM_SUBSCRIPTION_STATUSES,
|
PREMIUM_SUBSCRIPTION_STATUSES,
|
||||||
CANCELABLE_SUBSCRIPTION_STATUSES,
|
CANCELABLE_SUBSCRIPTION_STATUSES,
|
||||||
ACTIVE_SUBSCRIPTION_STATUSES,
|
ACTIVE_SUBSCRIPTION_STATUSES,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
const { listSubscriptionPlans, listCoinPacks } = require("./catalog");
|
const { listSubscriptionPlans, listCoinPacks } = require('./catalog')
|
||||||
const {
|
const { createSubscriptionCheckoutSession, createCoinPackCheckoutSession } = require('./checkout')
|
||||||
createSubscriptionCheckoutSession,
|
const { cancelActiveSubscription, getActiveSubscription } = require('./management')
|
||||||
createCoinPackCheckoutSession,
|
const { handleStripeWebhook } = require('./webhooks')
|
||||||
} = require("./checkout");
|
const { processAnnualSubscriptionAllowances } = require('./schedule')
|
||||||
const {
|
|
||||||
cancelActiveSubscription,
|
|
||||||
getActiveSubscription,
|
|
||||||
} = require("./management");
|
|
||||||
const { handleStripeWebhook } = require("./webhooks");
|
|
||||||
const { processAnnualSubscriptionAllowances } = require("./schedule");
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
listSubscriptionPlans,
|
listSubscriptionPlans,
|
||||||
@@ -19,4 +13,4 @@ module.exports = {
|
|||||||
createCoinPackCheckoutSession,
|
createCoinPackCheckoutSession,
|
||||||
handleStripeWebhook,
|
handleStripeWebhook,
|
||||||
processAnnualSubscriptionAllowances,
|
processAnnualSubscriptionAllowances,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,91 +1,74 @@
|
|||||||
const { HttpsError, onCall } = require("firebase-functions/https");
|
const { HttpsError, onCall } = require('firebase-functions/https')
|
||||||
|
|
||||||
const { getStripeClient, mapStripeErrorToHttps } = require("../../helpers/stripe");
|
const { getStripeClient, mapStripeErrorToHttps } = require('../../helpers/stripe')
|
||||||
const { REGION } = require("./config");
|
const { REGION } = require('./config')
|
||||||
const {
|
const { CANCELABLE_SUBSCRIPTION_STATUSES, ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants')
|
||||||
CANCELABLE_SUBSCRIPTION_STATUSES,
|
const { refsList, formatSubscriptionForClient, resolveUserContext } = require('./shared')
|
||||||
ACTIVE_SUBSCRIPTION_STATUSES,
|
|
||||||
} = require("./constants");
|
|
||||||
const {
|
|
||||||
refsList,
|
|
||||||
formatSubscriptionForClient,
|
|
||||||
resolveUserContext,
|
|
||||||
} = require("./shared");
|
|
||||||
|
|
||||||
const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
|
const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
|
||||||
try {
|
try {
|
||||||
const uid = request?.auth?.uid;
|
const uid = request?.auth?.uid
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('unauthenticated', 'Connecte-toi pour gérer ton abonnement.')
|
||||||
"unauthenticated",
|
|
||||||
"Connecte-toi pour gérer ton abonnement.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient()
|
||||||
|
|
||||||
const userRef = refsList?.users?.doc(uid) || null;
|
const userRef = refsList?.users?.doc(uid) || null
|
||||||
const snapshot = userRef ? await userRef.get() : null;
|
const snapshot = userRef ? await userRef.get() : null
|
||||||
const userData = snapshot?.exists ? snapshot.data() || {} : {};
|
const userData = snapshot?.exists ? snapshot.data() || {} : {}
|
||||||
|
|
||||||
const inputSubscriptionId =
|
const inputSubscriptionId =
|
||||||
typeof request?.data?.subscriptionId === "string"
|
typeof request?.data?.subscriptionId === 'string' ? request.data.subscriptionId.trim() : ''
|
||||||
? request.data.subscriptionId.trim()
|
|
||||||
: "";
|
|
||||||
|
|
||||||
let subscriptionId =
|
let subscriptionId =
|
||||||
inputSubscriptionId ||
|
inputSubscriptionId ||
|
||||||
userData?.stripeSubscription?.id ||
|
userData?.stripeSubscription?.id ||
|
||||||
userData?.stripeSubscription?.subscriptionId ||
|
userData?.stripeSubscription?.subscriptionId ||
|
||||||
null;
|
null
|
||||||
|
|
||||||
const customerId = userData?.stripeCustomerId || null;
|
const customerId = userData?.stripeCustomerId || null
|
||||||
|
|
||||||
if (!subscriptionId && customerId) {
|
if (!subscriptionId && customerId) {
|
||||||
try {
|
try {
|
||||||
const response = await stripe.subscriptions.list({
|
const response = await stripe.subscriptions.list({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
status: "all",
|
status: 'all',
|
||||||
limit: 5,
|
limit: 5,
|
||||||
});
|
})
|
||||||
const { data: subscriptionList = [] } = response || {};
|
const { data: subscriptionList = [] } = response || {}
|
||||||
const activeSubscription = subscriptionList.find(
|
const activeSubscription = subscriptionList.find(
|
||||||
(candidate) =>
|
(candidate) => candidate?.status && CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status)
|
||||||
candidate?.status &&
|
)
|
||||||
CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status),
|
|
||||||
);
|
|
||||||
if (activeSubscription?.id) {
|
if (activeSubscription?.id) {
|
||||||
subscriptionId = activeSubscription.id;
|
subscriptionId = activeSubscription.id
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[subscription-cancelActiveSubscription] Unable to list subscriptions",
|
'[subscription-cancelActiveSubscription] Unable to list subscriptions',
|
||||||
customerId,
|
customerId,
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!subscriptionId) {
|
if (!subscriptionId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('failed-precondition', 'Aucun abonnement actif à annuler.')
|
||||||
"failed-precondition",
|
|
||||||
"Aucun abonnement actif à annuler.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
throw new HttpsError("not-found", "Abonnement introuvable côté Stripe.");
|
throw new HttpsError('not-found', 'Abonnement introuvable côté Stripe.')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscription.status === "canceled") {
|
if (subscription.status === 'canceled') {
|
||||||
return {
|
return {
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
status: subscription.status,
|
status: subscription.status,
|
||||||
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
||||||
currentPeriodEnd: subscription.current_period_end || null,
|
currentPeriodEnd: subscription.current_period_end || null,
|
||||||
alreadyCanceled: true,
|
alreadyCanceled: true,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscription.cancel_at_period_end === true) {
|
if (subscription.cancel_at_period_end === true) {
|
||||||
@@ -95,15 +78,12 @@ const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
|
|||||||
cancelAtPeriodEnd: true,
|
cancelAtPeriodEnd: true,
|
||||||
currentPeriodEnd: subscription.current_period_end || null,
|
currentPeriodEnd: subscription.current_period_end || null,
|
||||||
alreadyCanceled: false,
|
alreadyCanceled: false,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedSubscription = await stripe.subscriptions.update(
|
const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
|
||||||
subscriptionId,
|
|
||||||
{
|
|
||||||
cancel_at_period_end: true,
|
cancel_at_period_end: true,
|
||||||
},
|
})
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscriptionId: updatedSubscription.id,
|
subscriptionId: updatedSubscription.id,
|
||||||
@@ -111,30 +91,24 @@ const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
|
|||||||
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end === true,
|
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end === true,
|
||||||
currentPeriodEnd: updatedSubscription.current_period_end || null,
|
currentPeriodEnd: updatedSubscription.current_period_end || null,
|
||||||
alreadyCanceled: false,
|
alreadyCanceled: false,
|
||||||
};
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[subscription-cancelActiveSubscription] error", error);
|
console.error('[subscription-cancelActiveSubscription] error', error)
|
||||||
if (error instanceof HttpsError) {
|
if (error instanceof HttpsError) {
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
throw mapStripeErrorToHttps(
|
throw mapStripeErrorToHttps(error, "Impossible d'annuler l'abonnement Stripe.")
|
||||||
error,
|
|
||||||
"Impossible d'annuler l'abonnement Stripe.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
const getActiveSubscription = onCall({ region: REGION }, async (request) => {
|
const getActiveSubscription = onCall({ region: REGION }, async (request) => {
|
||||||
try {
|
try {
|
||||||
const uid = request?.auth?.uid;
|
const uid = request?.auth?.uid
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('unauthenticated', 'Connecte-toi pour récupérer ton abonnement.')
|
||||||
"unauthenticated",
|
|
||||||
"Connecte-toi pour récupérer ton abonnement.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
uid: resolvedUid,
|
uid: resolvedUid,
|
||||||
@@ -143,71 +117,66 @@ const getActiveSubscription = onCall({ region: REGION }, async (request) => {
|
|||||||
} = await resolveUserContext({
|
} = await resolveUserContext({
|
||||||
metadata: request?.data?.metadata || {},
|
metadata: request?.data?.metadata || {},
|
||||||
customerId: null,
|
customerId: null,
|
||||||
});
|
})
|
||||||
|
|
||||||
const lookupUid = resolvedUid || uid;
|
const lookupUid = resolvedUid || uid
|
||||||
const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null;
|
const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null
|
||||||
const snapshot = lookupRef ? await lookupRef.get() : null;
|
const snapshot = lookupRef ? await lookupRef.get() : null
|
||||||
const data = snapshot?.exists ? snapshot.data() || {} : userData || {};
|
const data = snapshot?.exists ? snapshot.data() || {} : userData || {}
|
||||||
|
|
||||||
const subscriptionId =
|
const subscriptionId =
|
||||||
data?.stripeSubscription?.id ||
|
data?.stripeSubscription?.id || data?.stripeSubscription?.subscriptionId || null
|
||||||
data?.stripeSubscription?.subscriptionId ||
|
const customerId = data?.stripeCustomerId || null
|
||||||
null;
|
|
||||||
const customerId = data?.stripeCustomerId || null;
|
|
||||||
|
|
||||||
if (!subscriptionId && !customerId) {
|
if (!subscriptionId && !customerId) {
|
||||||
return {
|
return {
|
||||||
subscription: null,
|
subscription: null,
|
||||||
customerId: null,
|
customerId: null,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscriptionId) {
|
if (subscriptionId) {
|
||||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
|
||||||
expand: ["items.data.price.product"],
|
expand: ['items.data.price.product'],
|
||||||
});
|
})
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
return {
|
return {
|
||||||
subscription: formatSubscriptionForClient(subscription),
|
subscription: formatSubscriptionForClient(subscription),
|
||||||
customerId: subscription.customer || customerId || null,
|
customerId: subscription.customer || customerId || null,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (customerId) {
|
if (customerId) {
|
||||||
const response = await stripe.subscriptions.list({
|
const response = await stripe.subscriptions.list({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
status: "all",
|
status: 'all',
|
||||||
limit: 5,
|
limit: 5,
|
||||||
expand: ["data.items.data.price.product"],
|
expand: ['data.items.data.price.product'],
|
||||||
});
|
})
|
||||||
const [subscription] = response?.data || [];
|
const [subscription] = response?.data || []
|
||||||
if (subscription && ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
|
if (subscription && ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
|
||||||
return {
|
return {
|
||||||
subscription: formatSubscriptionForClient(subscription),
|
subscription: formatSubscriptionForClient(subscription),
|
||||||
customerId,
|
customerId,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscription: null,
|
subscription: null,
|
||||||
customerId,
|
customerId,
|
||||||
};
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[subscription-getActiveSubscription] error", error);
|
console.error('[subscription-getActiveSubscription] error', error)
|
||||||
if (error instanceof HttpsError) {
|
if (error instanceof HttpsError) {
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
throw mapStripeErrorToHttps(
|
throw mapStripeErrorToHttps(error, "Impossible de récupérer l'abonnement Stripe.")
|
||||||
error,
|
|
||||||
"Impossible de récupérer l'abonnement Stripe.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
cancelActiveSubscription,
|
cancelActiveSubscription,
|
||||||
getActiveSubscription,
|
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 { ORDER_TYPES, createOrderDocument } = require('../helpers/orders')
|
||||||
const { batchFirestore } = require("../../helpers/firebase");
|
const { batchFirestore } = require('../../helpers/firebase')
|
||||||
const { BATCH_TYPE } = require("../../config/types");
|
const { BATCH_TYPE } = require('../../config/types')
|
||||||
const {
|
const { admin, refsList, computeNextGrantTimestamp, getServerTimestamp } = require('./shared')
|
||||||
admin,
|
const { ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants')
|
||||||
refsList,
|
|
||||||
computeNextGrantTimestamp,
|
|
||||||
getServerTimestamp,
|
|
||||||
} = require("./shared");
|
|
||||||
const { ACTIVE_SUBSCRIPTION_STATUSES } = require("./constants");
|
|
||||||
|
|
||||||
// Toggle to stop monthly grants for annual subscriptions while keeping logic handy.
|
// Toggle to stop monthly grants for annual subscriptions while keeping logic handy.
|
||||||
const ENABLE_ANNUAL_GRANT_SCHEDULER = true;
|
const ENABLE_ANNUAL_GRANT_SCHEDULER = true
|
||||||
|
|
||||||
const processAnnualSubscriptionAllowances = onSchedule(
|
const processAnnualSubscriptionAllowances = onSchedule(
|
||||||
{
|
{
|
||||||
schedule: "30 3 * * *",
|
schedule: '30 3 * * *',
|
||||||
timeZone: "Europe/Paris",
|
timeZone: 'Europe/Paris',
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
if (!ENABLE_ANNUAL_GRANT_SCHEDULER) {
|
if (!ENABLE_ANNUAL_GRANT_SCHEDULER) {
|
||||||
console.log(
|
console.log('[subscription-processAnnualSubscriptionAllowances] skipped (disabled)')
|
||||||
"[subscription-processAnnualSubscriptionAllowances] skipped (disabled)",
|
return
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nowTimestamp = admin.firestore.Timestamp.now();
|
const nowTimestamp = admin.firestore.Timestamp.now()
|
||||||
const pageSize = 200;
|
const pageSize = 200
|
||||||
let lastDoc = null;
|
let lastDoc = null
|
||||||
let processedUsers = 0;
|
let processedUsers = 0
|
||||||
let grantsCreated = 0;
|
let grantsCreated = 0
|
||||||
let docsToUpdate = [];
|
let docsToUpdate = []
|
||||||
|
|
||||||
const flushUpdates = async () => {
|
const flushUpdates = async () => {
|
||||||
if (!docsToUpdate.length) {
|
if (!docsToUpdate.length) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
await batchFirestore({
|
await batchFirestore({
|
||||||
docs: docsToUpdate,
|
docs: docsToUpdate,
|
||||||
type: BATCH_TYPE.UPDATE,
|
type: BATCH_TYPE.UPDATE,
|
||||||
});
|
})
|
||||||
docsToUpdate = [];
|
docsToUpdate = []
|
||||||
};
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
let query = refsList.users
|
let query = refsList.users
|
||||||
.where("premiumBillingPeriod", "==", "annual")
|
.where('premiumBillingPeriod', '==', 'annual')
|
||||||
.where("subscriptionGrantInterval", "==", "monthly")
|
.where('subscriptionGrantInterval', '==', 'monthly')
|
||||||
.where("subscriptionNextGrantAt", "<=", nowTimestamp)
|
.where('subscriptionNextGrantAt', '<=', nowTimestamp)
|
||||||
.orderBy("subscriptionNextGrantAt")
|
.orderBy('subscriptionNextGrantAt')
|
||||||
.limit(pageSize);
|
.limit(pageSize)
|
||||||
|
|
||||||
if (lastDoc) {
|
if (lastDoc) {
|
||||||
query = query.startAfter(lastDoc);
|
query = query.startAfter(lastDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await query.get();
|
const snapshot = await query.get()
|
||||||
if (snapshot.empty) {
|
if (snapshot.empty) {
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const doc of snapshot.docs) {
|
for (const doc of snapshot.docs) {
|
||||||
processedUsers += 1;
|
processedUsers += 1
|
||||||
const data = doc.data() || {};
|
const data = doc.data() || {}
|
||||||
|
|
||||||
const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0);
|
const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0)
|
||||||
if (!Number.isFinite(coinsPerMonth) || coinsPerMonth <= 0) {
|
if (!Number.isFinite(coinsPerMonth) || coinsPerMonth <= 0) {
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const status =
|
const status =
|
||||||
typeof data.stripeSubscriptionStatus === "string"
|
typeof data.stripeSubscriptionStatus === 'string'
|
||||||
? data.stripeSubscriptionStatus.toLowerCase()
|
? data.stripeSubscriptionStatus.toLowerCase()
|
||||||
: null;
|
: null
|
||||||
if (status && !ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
|
if (status && !ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextGrantAt = data.subscriptionNextGrantAt;
|
const nextGrantAt = data.subscriptionNextGrantAt
|
||||||
if (!nextGrantAt || typeof nextGrantAt.toDate !== "function") {
|
if (!nextGrantAt || typeof nextGrantAt.toDate !== 'function') {
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextGrantDate = nextGrantAt.toDate();
|
const nextGrantDate = nextGrantAt.toDate()
|
||||||
if (!nextGrantDate || nextGrantDate > new Date()) {
|
if (!nextGrantDate || nextGrantDate > new Date()) {
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionInfo =
|
const subscriptionInfo =
|
||||||
data.stripeSubscription &&
|
data.stripeSubscription && typeof data.stripeSubscription === 'object'
|
||||||
typeof data.stripeSubscription === "object"
|
|
||||||
? data.stripeSubscription
|
? data.stripeSubscription
|
||||||
: {};
|
: {}
|
||||||
const subscriptionId =
|
const subscriptionId = subscriptionInfo.id || data.stripeSubscriptionId || null
|
||||||
subscriptionInfo.id || data.stripeSubscriptionId || null;
|
|
||||||
|
|
||||||
const orderId = subscriptionId
|
const orderId = subscriptionId
|
||||||
? `subscription_${subscriptionId}_sched_${nextGrantAt.seconds}`
|
? `subscription_${subscriptionId}_sched_${nextGrantAt.seconds}`
|
||||||
: `subscription_${doc.id}_sched_${nextGrantAt.seconds}`;
|
: `subscription_${doc.id}_sched_${nextGrantAt.seconds}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { orderId: processedOrderId } = await createOrderDocument({
|
const { orderId: processedOrderId } = await createOrderDocument({
|
||||||
@@ -108,25 +99,25 @@ const processAnnualSubscriptionAllowances = onSchedule(
|
|||||||
type: ORDER_TYPES.SUBSCRIPTION,
|
type: ORDER_TYPES.SUBSCRIPTION,
|
||||||
amount: coinsPerMonth,
|
amount: coinsPerMonth,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: "STRIPE_SUBSCRIPTION",
|
source: 'STRIPE_SUBSCRIPTION',
|
||||||
schedule: "annual_scheduler",
|
schedule: 'annual_scheduler',
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
scheduledGrantAt: nextGrantDate.toISOString(),
|
scheduledGrantAt: nextGrantDate.toISOString(),
|
||||||
},
|
},
|
||||||
orderId,
|
orderId,
|
||||||
});
|
})
|
||||||
|
|
||||||
let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1);
|
let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1)
|
||||||
const currentPeriodEnd = subscriptionInfo.currentPeriodEnd;
|
const currentPeriodEnd = subscriptionInfo.currentPeriodEnd
|
||||||
if (
|
if (
|
||||||
nextGrantTimestamp &&
|
nextGrantTimestamp &&
|
||||||
currentPeriodEnd &&
|
currentPeriodEnd &&
|
||||||
typeof currentPeriodEnd.toDate === "function"
|
typeof currentPeriodEnd.toDate === 'function'
|
||||||
) {
|
) {
|
||||||
const periodEndDate = currentPeriodEnd.toDate();
|
const periodEndDate = currentPeriodEnd.toDate()
|
||||||
const nextGrantFutureDate = nextGrantTimestamp.toDate();
|
const nextGrantFutureDate = nextGrantTimestamp.toDate()
|
||||||
if (periodEndDate && nextGrantFutureDate > periodEndDate) {
|
if (periodEndDate && nextGrantFutureDate > periodEndDate) {
|
||||||
nextGrantTimestamp = null;
|
nextGrantTimestamp = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,53 +127,47 @@ const processAnnualSubscriptionAllowances = onSchedule(
|
|||||||
subscriptionLastGrantAt: getServerTimestamp(),
|
subscriptionLastGrantAt: getServerTimestamp(),
|
||||||
subscriptionLastGrantAmount: coinsPerMonth,
|
subscriptionLastGrantAmount: coinsPerMonth,
|
||||||
subscriptionLastGrantOrderId: processedOrderId,
|
subscriptionLastGrantOrderId: processedOrderId,
|
||||||
subscriptionLastGrantSource: "annual_scheduler",
|
subscriptionLastGrantSource: 'annual_scheduler',
|
||||||
subscriptionNextGrantAt: nextGrantTimestamp || null,
|
subscriptionNextGrantAt: nextGrantTimestamp || null,
|
||||||
subscriptionGrantInterval: nextGrantTimestamp ? "monthly" : null,
|
subscriptionGrantInterval: nextGrantTimestamp ? 'monthly' : null,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
grantsCreated += 1;
|
grantsCreated += 1
|
||||||
|
|
||||||
if (docsToUpdate.length >= 450) {
|
if (docsToUpdate.length >= 450) {
|
||||||
await flushUpdates();
|
await flushUpdates()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[subscription-processAnnualSubscriptionAllowances] Unable to create order",
|
'[subscription-processAnnualSubscriptionAllowances] Unable to create order',
|
||||||
{
|
{
|
||||||
userId: doc.id,
|
userId: doc.id,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
error: error?.message || error,
|
error: error?.message || error,
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastDoc = snapshot.docs[snapshot.docs.length - 1];
|
lastDoc = snapshot.docs[snapshot.docs.length - 1]
|
||||||
if (snapshot.size < pageSize) {
|
if (snapshot.size < pageSize) {
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await flushUpdates();
|
await flushUpdates()
|
||||||
|
|
||||||
console.log(
|
console.log('[subscription-processAnnualSubscriptionAllowances] completed', {
|
||||||
"[subscription-processAnnualSubscriptionAllowances] completed",
|
|
||||||
{
|
|
||||||
processedUsers,
|
processedUsers,
|
||||||
grantsCreated,
|
grantsCreated,
|
||||||
},
|
})
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error('[subscription-processAnnualSubscriptionAllowances] error', error)
|
||||||
"[subscription-processAnnualSubscriptionAllowances] error",
|
throw error
|
||||||
error,
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
processAnnualSubscriptionAllowances,
|
processAnnualSubscriptionAllowances,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,242 +1,226 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
|
|
||||||
const { refList } = require("../../index");
|
const { refList } = require('../../index')
|
||||||
const { STRIPE_WEBHOOK_SECRET } = require("../../config/keys");
|
const { STRIPE_WEBHOOK_SECRET } = require('../../config/keys')
|
||||||
const {
|
const {
|
||||||
SUBSCRIPTION_LEVEL_ALLOWANCES,
|
SUBSCRIPTION_LEVEL_ALLOWANCES,
|
||||||
SUBSCRIPTION_PRICE_METADATA,
|
SUBSCRIPTION_PRICE_METADATA,
|
||||||
COIN_PACK_PRODUCT_MAP,
|
COIN_PACK_PRODUCT_MAP,
|
||||||
} = require("./constants");
|
} = require('./constants')
|
||||||
|
|
||||||
const refsList = refList;
|
const refsList = refList
|
||||||
const paymentsCollection = admin.firestore().collection("payments");
|
const paymentsCollection = admin.firestore().collection('payments')
|
||||||
let cachedStripeWebhookSecret = null;
|
let cachedStripeWebhookSecret = null
|
||||||
|
|
||||||
const toFiniteNumber = (value) => {
|
const toFiniteNumber = (value) => {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
return value;
|
return value
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === 'string') {
|
||||||
const normalized = value.trim().replace(",", ".");
|
const normalized = value.trim().replace(',', '.')
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
const parsed = Number(normalized);
|
const parsed = Number(normalized)
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
}
|
}
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|
||||||
const parseCoinsPerMonth = (metadata) => {
|
const parseCoinsPerMonth = (metadata) => {
|
||||||
if (!metadata || typeof metadata !== "object") {
|
if (!metadata || typeof metadata !== 'object') {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
if (!Object.prototype.hasOwnProperty.call(metadata, "coinsPerMonth")) {
|
if (!Object.prototype.hasOwnProperty.call(metadata, 'coinsPerMonth')) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
const candidateValue = toFiniteNumber(metadata.coinsPerMonth);
|
const candidateValue = toFiniteNumber(metadata.coinsPerMonth)
|
||||||
if (candidateValue === null || candidateValue <= 0) {
|
if (candidateValue === null || candidateValue <= 0) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
return Math.round(candidateValue);
|
return Math.round(candidateValue)
|
||||||
};
|
}
|
||||||
|
|
||||||
const parseCoinAmount = (metadata) => {
|
const parseCoinAmount = (metadata) => {
|
||||||
if (!metadata || typeof metadata !== "object") {
|
if (!metadata || typeof metadata !== 'object') {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
if (!Object.prototype.hasOwnProperty.call(metadata, "coins")) {
|
if (!Object.prototype.hasOwnProperty.call(metadata, 'coins')) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
const candidateValue = toFiniteNumber(metadata.coins);
|
const candidateValue = toFiniteNumber(metadata.coins)
|
||||||
if (candidateValue === null || candidateValue <= 0) {
|
if (candidateValue === null || candidateValue <= 0) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
return Math.round(candidateValue);
|
return Math.round(candidateValue)
|
||||||
};
|
}
|
||||||
|
|
||||||
const toDateSafe = (value) => {
|
const toDateSafe = (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
return value;
|
return value
|
||||||
}
|
}
|
||||||
if (typeof value?.toDate === "function") {
|
if (typeof value?.toDate === 'function') {
|
||||||
try {
|
try {
|
||||||
return value.toDate();
|
return value.toDate()
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
if (value > 1e12) {
|
if (value > 1e12) {
|
||||||
return new Date(value);
|
return new Date(value)
|
||||||
}
|
}
|
||||||
return new Date(value * 1000);
|
return new Date(value * 1000)
|
||||||
}
|
}
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|
||||||
const addMonths = (date, months = 1) => {
|
const addMonths = (date, months = 1) => {
|
||||||
if (!(date instanceof Date) || !Number.isFinite(months)) {
|
if (!(date instanceof Date) || !Number.isFinite(months)) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
const result = new Date(date.getTime());
|
const result = new Date(date.getTime())
|
||||||
const initialDay = result.getDate();
|
const initialDay = result.getDate()
|
||||||
result.setMonth(result.getMonth() + months);
|
result.setMonth(result.getMonth() + months)
|
||||||
if (result.getDate() !== initialDay) {
|
if (result.getDate() !== initialDay) {
|
||||||
result.setDate(0);
|
result.setDate(0)
|
||||||
}
|
}
|
||||||
return result;
|
return result
|
||||||
};
|
}
|
||||||
|
|
||||||
const computeNextGrantTimestamp = (base, months = 1) => {
|
const computeNextGrantTimestamp = (base, months = 1) => {
|
||||||
const baseDate = toDateSafe(base);
|
const baseDate = toDateSafe(base)
|
||||||
if (!baseDate) {
|
if (!baseDate) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
const nextDate = addMonths(baseDate, months);
|
const nextDate = addMonths(baseDate, months)
|
||||||
if (!nextDate) {
|
if (!nextDate) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
return admin.firestore.Timestamp.fromDate(nextDate);
|
return admin.firestore.Timestamp.fromDate(nextDate)
|
||||||
};
|
}
|
||||||
|
|
||||||
const getServerTimestamp = () => {
|
const getServerTimestamp = () => {
|
||||||
if (typeof FieldValue?.serverTimestamp === "function") {
|
if (typeof FieldValue?.serverTimestamp === 'function') {
|
||||||
return FieldValue.serverTimestamp();
|
return FieldValue.serverTimestamp()
|
||||||
}
|
}
|
||||||
const fallback = admin.firestore?.FieldValue;
|
const fallback = admin.firestore?.FieldValue
|
||||||
if (typeof fallback?.serverTimestamp === "function") {
|
if (typeof fallback?.serverTimestamp === 'function') {
|
||||||
return fallback.serverTimestamp();
|
return fallback.serverTimestamp()
|
||||||
}
|
}
|
||||||
throw new Error("Firestore FieldValue.serverTimestamp indisponible.");
|
throw new Error('Firestore FieldValue.serverTimestamp indisponible.')
|
||||||
};
|
}
|
||||||
|
|
||||||
const resolveStripeWebhookSecret = () => {
|
const resolveStripeWebhookSecret = () => {
|
||||||
if (cachedStripeWebhookSecret) {
|
if (cachedStripeWebhookSecret) {
|
||||||
return cachedStripeWebhookSecret;
|
return cachedStripeWebhookSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
const envSecret =
|
const envSecret =
|
||||||
typeof process?.env?.STRIPE_WEBHOOK_SECRET === "string"
|
typeof process?.env?.STRIPE_WEBHOOK_SECRET === 'string'
|
||||||
? process.env.STRIPE_WEBHOOK_SECRET.trim()
|
? process.env.STRIPE_WEBHOOK_SECRET.trim()
|
||||||
: "";
|
: ''
|
||||||
const inlineSecret =
|
const inlineSecret = typeof STRIPE_WEBHOOK_SECRET === 'string' ? STRIPE_WEBHOOK_SECRET.trim() : ''
|
||||||
typeof STRIPE_WEBHOOK_SECRET === "string"
|
|
||||||
? STRIPE_WEBHOOK_SECRET.trim()
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const secret = envSecret || inlineSecret;
|
const secret = envSecret || inlineSecret
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error("STRIPE_WEBHOOK_SECRET not configured");
|
throw new Error('STRIPE_WEBHOOK_SECRET not configured')
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedStripeWebhookSecret = secret;
|
cachedStripeWebhookSecret = secret
|
||||||
return cachedStripeWebhookSecret;
|
return cachedStripeWebhookSecret
|
||||||
};
|
}
|
||||||
|
|
||||||
const toFirestoreTimestamp = (unixSeconds) => {
|
const toFirestoreTimestamp = (unixSeconds) => {
|
||||||
if (typeof unixSeconds !== "number" || !Number.isFinite(unixSeconds)) {
|
if (typeof unixSeconds !== 'number' || !Number.isFinite(unixSeconds)) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000);
|
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[subscription-toFirestoreTimestamp] Conversion error", unixSeconds, error);
|
console.error('[subscription-toFirestoreTimestamp] Conversion error', unixSeconds, error)
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const extractFirebaseUid = (metadata) => {
|
const extractFirebaseUid = (metadata) => {
|
||||||
if (!metadata || typeof metadata !== "object") {
|
if (!metadata || typeof metadata !== 'object') {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = [
|
const candidates = [metadata.firebaseUID, metadata.firebaseUid, metadata.uid, metadata.userId]
|
||||||
metadata.firebaseUID,
|
|
||||||
metadata.firebaseUid,
|
|
||||||
metadata.uid,
|
|
||||||
metadata.userId,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (let index = 0; index < candidates.length; index += 1) {
|
for (let index = 0; index < candidates.length; index += 1) {
|
||||||
const candidate = candidates[index];
|
const candidate = candidates[index]
|
||||||
if (typeof candidate === "string" && candidate.trim()) {
|
if (typeof candidate === 'string' && candidate.trim()) {
|
||||||
return candidate.trim();
|
return candidate.trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|
||||||
const formatCoinPack = ({ product, price }) => {
|
const formatCoinPack = ({ product, price }) => {
|
||||||
if (!product || typeof product !== "object") {
|
if (!product || typeof product !== 'object') {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPrice =
|
const resolvedPrice =
|
||||||
price ||
|
price || (typeof product.default_price === 'object' && product.default_price) || null
|
||||||
(typeof product.default_price === "object" && product.default_price) ||
|
|
||||||
null;
|
|
||||||
|
|
||||||
const priceId =
|
const priceId =
|
||||||
(resolvedPrice && resolvedPrice.id) ||
|
(resolvedPrice && resolvedPrice.id) ||
|
||||||
(typeof product.default_price === "string" ? product.default_price : null);
|
(typeof product.default_price === 'string' ? product.default_price : null)
|
||||||
|
|
||||||
const coinAmount = parseCoinAmount(product?.metadata || {});
|
const coinAmount = parseCoinAmount(product?.metadata || {})
|
||||||
if (coinAmount === null) {
|
if (coinAmount === null) {
|
||||||
throw new Error(
|
throw new Error(`[formatCoinPack] Missing metadata.coins on product ${product.id}`)
|
||||||
`[formatCoinPack] Missing metadata.coins on product ${product.id}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
priceId,
|
priceId,
|
||||||
name: product.name || "",
|
name: product.name || '',
|
||||||
description: product.description || "",
|
description: product.description || '',
|
||||||
coinAmount,
|
coinAmount,
|
||||||
currency:
|
currency:
|
||||||
resolvedPrice?.currency ||
|
resolvedPrice?.currency ||
|
||||||
(typeof resolvedPrice?.currency === "string"
|
(typeof resolvedPrice?.currency === 'string' ? resolvedPrice.currency.toLowerCase() : 'eur'),
|
||||||
? resolvedPrice.currency.toLowerCase()
|
|
||||||
: "eur"),
|
|
||||||
unitAmount: resolvedPrice?.unit_amount ?? null,
|
unitAmount: resolvedPrice?.unit_amount ?? null,
|
||||||
metadata: product.metadata || {},
|
metadata: product.metadata || {},
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const getSubscriptionMetaFromPrice = (priceId) => {
|
const getSubscriptionMetaFromPrice = (priceId) => {
|
||||||
if (typeof priceId !== "string") {
|
if (typeof priceId !== 'string') {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
return SUBSCRIPTION_PRICE_METADATA[priceId] || null;
|
return SUBSCRIPTION_PRICE_METADATA[priceId] || null
|
||||||
};
|
}
|
||||||
|
|
||||||
const buildEventSnapshot = (eventType, entityId) => {
|
const buildEventSnapshot = (eventType, entityId) => {
|
||||||
const now = admin.firestore.Timestamp.now();
|
const now = admin.firestore.Timestamp.now()
|
||||||
return {
|
return {
|
||||||
eventType: eventType || null,
|
eventType: eventType || null,
|
||||||
entityId: entityId || null,
|
entityId: entityId || null,
|
||||||
syncedAt: now,
|
syncedAt: now,
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const formatSubscriptionForClient = (subscription) => {
|
const formatSubscriptionForClient = (subscription) => {
|
||||||
if (!subscription || typeof subscription !== "object") {
|
if (!subscription || typeof subscription !== 'object') {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: subscription.id,
|
id: subscription.id,
|
||||||
status: subscription.status,
|
status: subscription.status,
|
||||||
customer: subscription.customer,
|
customer: subscription.customer,
|
||||||
currentPeriodStart: toFirestoreTimestamp(
|
currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
|
||||||
subscription.current_period_start,
|
|
||||||
),
|
|
||||||
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
|
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
|
||||||
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
||||||
created: toFirestoreTimestamp(subscription.created),
|
created: toFirestoreTimestamp(subscription.created),
|
||||||
@@ -248,12 +232,12 @@ const formatSubscriptionForClient = (subscription) => {
|
|||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
metadata: subscription.metadata || {},
|
metadata: subscription.metadata || {},
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const buildSubscriptionPayload = (subscription) => {
|
const buildSubscriptionPayload = (subscription) => {
|
||||||
if (!subscription || typeof subscription !== "object") {
|
if (!subscription || typeof subscription !== 'object') {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = Array.isArray(subscription.items?.data)
|
const items = Array.isArray(subscription.items?.data)
|
||||||
@@ -264,20 +248,19 @@ const buildSubscriptionPayload = (subscription) => {
|
|||||||
quantity: item.quantity || 0,
|
quantity: item.quantity || 0,
|
||||||
price: item.price || null,
|
price: item.price || null,
|
||||||
}))
|
}))
|
||||||
: [];
|
: []
|
||||||
|
|
||||||
const primaryItem = items[0] || null;
|
const primaryItem = items[0] || null
|
||||||
const productId = primaryItem?.productId || null;
|
const productId = primaryItem?.productId || null
|
||||||
const priceId = primaryItem?.priceId || null;
|
const priceId = primaryItem?.priceId || null
|
||||||
|
|
||||||
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
|
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
|
||||||
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
|
const metadataLevel = subscription.metadata?.subscriptionLevel || null
|
||||||
const resolvedLevel = metadataLevel || priceMeta?.level || null;
|
const resolvedLevel = metadataLevel || priceMeta?.level || null
|
||||||
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null;
|
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null
|
||||||
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
|
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null
|
||||||
|
|
||||||
const customerId =
|
const customerId = typeof subscription.customer === 'string' ? subscription.customer : null
|
||||||
typeof subscription.customer === "string" ? subscription.customer : null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: subscription.id,
|
id: subscription.id,
|
||||||
@@ -289,34 +272,32 @@ const buildSubscriptionPayload = (subscription) => {
|
|||||||
billingPeriod: resolvedPeriod,
|
billingPeriod: resolvedPeriod,
|
||||||
status: subscription.status || null,
|
status: subscription.status || null,
|
||||||
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
||||||
currentPeriodStart: toFirestoreTimestamp(
|
currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
|
||||||
subscription.current_period_start,
|
|
||||||
),
|
|
||||||
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
|
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const resolveUserContext = async ({ metadata, customerId }) => {
|
const resolveUserContext = async ({ metadata, customerId }) => {
|
||||||
const firebaseUid = extractFirebaseUid(metadata);
|
const firebaseUid = extractFirebaseUid(metadata)
|
||||||
|
|
||||||
if (firebaseUid) {
|
if (firebaseUid) {
|
||||||
const userRef = refsList?.users?.doc(firebaseUid) || null;
|
const userRef = refsList?.users?.doc(firebaseUid) || null
|
||||||
if (userRef) {
|
if (userRef) {
|
||||||
try {
|
try {
|
||||||
const snapshot = await userRef.get();
|
const snapshot = await userRef.get()
|
||||||
if (snapshot.exists) {
|
if (snapshot.exists) {
|
||||||
return {
|
return {
|
||||||
uid: firebaseUid,
|
uid: firebaseUid,
|
||||||
userRef,
|
userRef,
|
||||||
userData: snapshot.data() || null,
|
userData: snapshot.data() || null,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[subscription-resolveUserContext] Unable to read user",
|
'[subscription-resolveUserContext] Unable to read user',
|
||||||
firebaseUid,
|
firebaseUid,
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,23 +305,23 @@ const resolveUserContext = async ({ metadata, customerId }) => {
|
|||||||
if (customerId) {
|
if (customerId) {
|
||||||
try {
|
try {
|
||||||
const snapshot = await refsList.users
|
const snapshot = await refsList.users
|
||||||
.where("stripeCustomerId", "==", customerId)
|
.where('stripeCustomerId', '==', customerId)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get();
|
.get()
|
||||||
if (!snapshot.empty) {
|
if (!snapshot.empty) {
|
||||||
const doc = snapshot.docs[0];
|
const doc = snapshot.docs[0]
|
||||||
return {
|
return {
|
||||||
uid: doc.id,
|
uid: doc.id,
|
||||||
userRef: doc.ref,
|
userRef: doc.ref,
|
||||||
userData: doc.data() || null,
|
userData: doc.data() || null,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[subscription-resolveUserContext] Unable to query by customer",
|
'[subscription-resolveUserContext] Unable to query by customer',
|
||||||
customerId,
|
customerId,
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,15 +329,15 @@ const resolveUserContext = async ({ metadata, customerId }) => {
|
|||||||
uid: firebaseUid || null,
|
uid: firebaseUid || null,
|
||||||
userRef: null,
|
userRef: null,
|
||||||
userData: null,
|
userData: null,
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const upsertPaymentDocument = async (docId, data = {}) => {
|
const upsertPaymentDocument = async (docId, data = {}) => {
|
||||||
if (!docId) {
|
if (!docId) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const docRef = paymentsCollection.doc(docId);
|
const docRef = paymentsCollection.doc(docId)
|
||||||
try {
|
try {
|
||||||
await docRef.set(
|
await docRef.set(
|
||||||
{
|
{
|
||||||
@@ -364,17 +345,13 @@ const upsertPaymentDocument = async (docId, data = {}) => {
|
|||||||
updatedAt: getServerTimestamp(),
|
updatedAt: getServerTimestamp(),
|
||||||
createdAt: getServerTimestamp(),
|
createdAt: getServerTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error('[subscription-upsertPaymentDocument] Failed to persist payment', docId, error)
|
||||||
"[subscription-upsertPaymentDocument] Failed to persist payment",
|
|
||||||
docId,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return docRef;
|
return docRef
|
||||||
};
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
admin,
|
admin,
|
||||||
@@ -398,4 +375,4 @@ module.exports = {
|
|||||||
resolveUserContext,
|
resolveUserContext,
|
||||||
upsertPaymentDocument,
|
upsertPaymentDocument,
|
||||||
COIN_PACK_PRODUCT_MAP,
|
COIN_PACK_PRODUCT_MAP,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
const { onRequest } = require("firebase-functions/v2/https");
|
const { onRequest } = require('firebase-functions/v2/https')
|
||||||
const { HttpsError } = require("firebase-functions/https");
|
const { HttpsError } = require('firebase-functions/https')
|
||||||
|
|
||||||
const { ORDER_TYPES, createOrderDocument } = require("../helpers/orders");
|
const { ORDER_TYPES, createOrderDocument } = require('../helpers/orders')
|
||||||
const { getStripeClient } = require("../../helpers/stripe");
|
const { getStripeClient } = require('../../helpers/stripe')
|
||||||
const { REGION } = require("./config");
|
const { REGION } = require('./config')
|
||||||
const {
|
const {
|
||||||
paymentsCollection,
|
paymentsCollection,
|
||||||
resolveStripeWebhookSecret,
|
resolveStripeWebhookSecret,
|
||||||
@@ -17,25 +17,21 @@ const {
|
|||||||
buildSubscriptionPayload,
|
buildSubscriptionPayload,
|
||||||
resolveUserContext,
|
resolveUserContext,
|
||||||
upsertPaymentDocument,
|
upsertPaymentDocument,
|
||||||
} = require("./shared");
|
} = require('./shared')
|
||||||
const { PREMIUM_SUBSCRIPTION_STATUSES } = require("./constants");
|
const { PREMIUM_SUBSCRIPTION_STATUSES } = require('./constants')
|
||||||
|
|
||||||
const handleCheckoutSessionCompleted = async (
|
const handleCheckoutSessionCompleted = async (session, event, { stripe } = {}) => {
|
||||||
session,
|
if (!session || typeof session !== 'object') {
|
||||||
event,
|
return
|
||||||
{ stripe } = {},
|
|
||||||
) => {
|
|
||||||
if (!session || typeof session !== "object") {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const firebaseUid = extractFirebaseUid(session.metadata);
|
const firebaseUid = extractFirebaseUid(session.metadata)
|
||||||
const paymentDocRef = await upsertPaymentDocument(session.id, {
|
const paymentDocRef = await upsertPaymentDocument(session.id, {
|
||||||
userId: firebaseUid || null,
|
userId: firebaseUid || null,
|
||||||
customerId: session.customer || null,
|
customerId: session.customer || null,
|
||||||
subscriptionId: session.subscription || null,
|
subscriptionId: session.subscription || null,
|
||||||
invoiceId: session.invoice || null,
|
invoiceId: session.invoice || null,
|
||||||
status: session.status || "completed",
|
status: session.status || 'completed',
|
||||||
paymentStatus: session.payment_status || null,
|
paymentStatus: session.payment_status || null,
|
||||||
mode: session.mode || null,
|
mode: session.mode || null,
|
||||||
amountSubtotal: session.amount_subtotal ?? null,
|
amountSubtotal: session.amount_subtotal ?? null,
|
||||||
@@ -44,109 +40,103 @@ const handleCheckoutSessionCompleted = async (
|
|||||||
metadata: session.metadata || {},
|
metadata: session.metadata || {},
|
||||||
completedAt: toFirestoreTimestamp(session.created),
|
completedAt: toFirestoreTimestamp(session.created),
|
||||||
expiresAt: toFirestoreTimestamp(session.expires_at),
|
expiresAt: toFirestoreTimestamp(session.expires_at),
|
||||||
paymentIntentId:
|
paymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : null,
|
||||||
typeof session.payment_intent === "string"
|
|
||||||
? session.payment_intent
|
|
||||||
: null,
|
|
||||||
lastEventType: event?.type || null,
|
lastEventType: event?.type || null,
|
||||||
lastEventId: event?.id || null,
|
lastEventId: event?.id || null,
|
||||||
lastEventAt: getServerTimestamp(),
|
lastEventAt: getServerTimestamp(),
|
||||||
});
|
})
|
||||||
|
|
||||||
const { uid, userRef } = await resolveUserContext({
|
const { uid, userRef } = await resolveUserContext({
|
||||||
metadata: session.metadata,
|
metadata: session.metadata,
|
||||||
customerId: session.customer,
|
customerId: session.customer,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (userRef) {
|
if (userRef) {
|
||||||
const lastEvent = buildEventSnapshot(event?.type, session.id);
|
const lastEvent = buildEventSnapshot(event?.type, session.id)
|
||||||
if (firebaseUid) {
|
if (firebaseUid) {
|
||||||
lastEvent.uid = firebaseUid;
|
lastEvent.uid = firebaseUid
|
||||||
}
|
}
|
||||||
|
|
||||||
const userUpdate = {
|
const userUpdate = {
|
||||||
lastStripeWebhookEvent: lastEvent,
|
lastStripeWebhookEvent: lastEvent,
|
||||||
};
|
}
|
||||||
|
|
||||||
if (session.customer) {
|
if (session.customer) {
|
||||||
userUpdate.stripeCustomerId = session.customer;
|
userUpdate.stripeCustomerId = session.customer
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.metadata?.subscriptionLevel) {
|
if (session.metadata?.subscriptionLevel) {
|
||||||
userUpdate.premiumLevel = session.metadata.subscriptionLevel;
|
userUpdate.premiumLevel = session.metadata.subscriptionLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.metadata?.subscriptionBillingPeriod) {
|
if (session.metadata?.subscriptionBillingPeriod) {
|
||||||
userUpdate.premiumBillingPeriod =
|
userUpdate.premiumBillingPeriod = session.metadata.subscriptionBillingPeriod
|
||||||
session.metadata.subscriptionBillingPeriod;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await userRef.set(userUpdate, { merge: true });
|
await userRef.set(userUpdate, { merge: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
stripe &&
|
stripe &&
|
||||||
session.mode === "subscription" &&
|
session.mode === 'subscription' &&
|
||||||
typeof session.subscription === "string" &&
|
typeof session.subscription === 'string' &&
|
||||||
session.subscription
|
session.subscription
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const subscription = await stripe.subscriptions.retrieve(
|
const subscription = await stripe.subscriptions.retrieve(session.subscription, {
|
||||||
session.subscription,
|
expand: ['items.data.price.product'],
|
||||||
{ expand: ["items.data.price.product"] },
|
})
|
||||||
);
|
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
await handleCustomerSubscriptionEvent(subscription, event, { stripe });
|
await handleCustomerSubscriptionEvent(subscription, event, { stripe })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[subscription-handleCheckoutSessionCompleted] Unable to sync subscription",
|
'[subscription-handleCheckoutSessionCompleted] Unable to sync subscription',
|
||||||
session.subscription,
|
session.subscription,
|
||||||
error,
|
error
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
session.mode === "payment" &&
|
session.mode === 'payment' &&
|
||||||
(session.payment_status === "paid" ||
|
(session.payment_status === 'paid' || session.payment_status === 'no_payment_required') &&
|
||||||
session.payment_status === "no_payment_required") &&
|
session.metadata?.purchaseType === 'COIN_PACK' &&
|
||||||
session.metadata?.purchaseType === "COIN_PACK" &&
|
|
||||||
userRef
|
userRef
|
||||||
) {
|
) {
|
||||||
const coinAmountRaw = Number(session.metadata?.coinAmount || 0);
|
const coinAmountRaw = Number(session.metadata?.coinAmount || 0)
|
||||||
const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0;
|
const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0
|
||||||
|
|
||||||
if (coinAmount > 0 && paymentDocRef) {
|
if (coinAmount > 0 && paymentDocRef) {
|
||||||
let paymentSnapshot = null;
|
let paymentSnapshot = null
|
||||||
try {
|
try {
|
||||||
paymentSnapshot = await paymentDocRef.get();
|
paymentSnapshot = await paymentDocRef.get()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[subscription-handleCheckoutSessionCompleted] Unable to read payment doc",
|
'[subscription-handleCheckoutSessionCompleted] Unable to read payment doc',
|
||||||
session.id,
|
session.id,
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const alreadyGranted = Boolean(
|
const alreadyGranted = Boolean(
|
||||||
paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt,
|
paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt
|
||||||
);
|
)
|
||||||
|
|
||||||
if (!alreadyGranted) {
|
if (!alreadyGranted) {
|
||||||
const targetUserId = uid || firebaseUid || userRef.id;
|
const targetUserId = uid || firebaseUid || userRef.id
|
||||||
|
|
||||||
await createOrderDocument({
|
await createOrderDocument({
|
||||||
userId: targetUserId,
|
userId: targetUserId,
|
||||||
type: ORDER_TYPES.COINS,
|
type: ORDER_TYPES.COINS,
|
||||||
amount: coinAmount,
|
amount: coinAmount,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: "STRIPE_CHECKOUT",
|
source: 'STRIPE_CHECKOUT',
|
||||||
paymentId: session.id || null,
|
paymentId: session.id || null,
|
||||||
coinPackKey: session.metadata?.coinPackKey || null,
|
coinPackKey: session.metadata?.coinPackKey || null,
|
||||||
},
|
},
|
||||||
orderId: `stripe_${session.id}`,
|
orderId: `stripe_${session.id}`,
|
||||||
});
|
})
|
||||||
|
|
||||||
await paymentDocRef.set(
|
await paymentDocRef.set(
|
||||||
{
|
{
|
||||||
@@ -154,25 +144,21 @@ const handleCheckoutSessionCompleted = async (
|
|||||||
coinPackGrantedAmount: coinAmount,
|
coinPackGrantedAmount: coinAmount,
|
||||||
coinPackGrantedKey: session.metadata?.coinPackKey || null,
|
coinPackGrantedKey: session.metadata?.coinPackKey || null,
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleCustomerSubscriptionEvent = async (
|
const handleCustomerSubscriptionEvent = async (subscription, event, { stripe } = {}) => {
|
||||||
subscription,
|
if (!subscription || typeof subscription !== 'object') {
|
||||||
event,
|
return
|
||||||
{ stripe } = {},
|
|
||||||
) => {
|
|
||||||
if (!subscription || typeof subscription !== "object") {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionPayload = buildSubscriptionPayload(subscription);
|
const subscriptionPayload = buildSubscriptionPayload(subscription)
|
||||||
if (!subscriptionPayload) {
|
if (!subscriptionPayload) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -182,36 +168,36 @@ const handleCustomerSubscriptionEvent = async (
|
|||||||
} = await resolveUserContext({
|
} = await resolveUserContext({
|
||||||
metadata: subscription.metadata,
|
metadata: subscription.metadata,
|
||||||
customerId: subscription.customer,
|
customerId: subscription.customer,
|
||||||
});
|
})
|
||||||
|
|
||||||
let userData = resolvedUserData || null;
|
let userData = resolvedUserData || null
|
||||||
if (!userData && userRef) {
|
if (!userData && userRef) {
|
||||||
try {
|
try {
|
||||||
const snapshot = await userRef.get();
|
const snapshot = await userRef.get()
|
||||||
userData = snapshot.exists ? snapshot.data() || null : null;
|
userData = snapshot.exists ? snapshot.data() || null : null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[subscription-handleCustomerSubscriptionEvent] Unable to read user",
|
'[subscription-handleCustomerSubscriptionEvent] Unable to read user',
|
||||||
subscription.customer,
|
subscription.customer,
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolvedCustomerId = subscriptionPayload?.customerId || null;
|
let resolvedCustomerId = subscriptionPayload?.customerId || null
|
||||||
if (!resolvedCustomerId && subscription.customer) {
|
if (!resolvedCustomerId && subscription.customer) {
|
||||||
resolvedCustomerId = subscription.customer;
|
resolvedCustomerId = subscription.customer
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackUid = extractFirebaseUid(subscription.metadata);
|
const fallbackUid = extractFirebaseUid(subscription.metadata)
|
||||||
const resolvedUid = uid || fallbackUid || null;
|
const resolvedUid = uid || fallbackUid || null
|
||||||
|
|
||||||
await upsertPaymentDocument(subscription.id, {
|
await upsertPaymentDocument(subscription.id, {
|
||||||
userId: resolvedUid,
|
userId: resolvedUid,
|
||||||
customerId: resolvedCustomerId,
|
customerId: resolvedCustomerId,
|
||||||
subscriptionId: subscription.id || null,
|
subscriptionId: subscription.id || null,
|
||||||
status: subscription.status || null,
|
status: subscription.status || null,
|
||||||
mode: "subscription",
|
mode: 'subscription',
|
||||||
priceId: subscriptionPayload?.priceId || null,
|
priceId: subscriptionPayload?.priceId || null,
|
||||||
productId: subscriptionPayload?.productId || null,
|
productId: subscriptionPayload?.productId || null,
|
||||||
cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null,
|
cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null,
|
||||||
@@ -224,76 +210,66 @@ const handleCustomerSubscriptionEvent = async (
|
|||||||
lastEventType: event?.type || null,
|
lastEventType: event?.type || null,
|
||||||
lastEventId: event?.id || null,
|
lastEventId: event?.id || null,
|
||||||
lastEventAt: getServerTimestamp(),
|
lastEventAt: getServerTimestamp(),
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!userRef) {
|
if (!userRef) {
|
||||||
console.warn(
|
console.warn('[subscription-handleCustomerSubscriptionEvent] User not resolved', {
|
||||||
"[subscription-handleCustomerSubscriptionEvent] User not resolved",
|
|
||||||
{
|
|
||||||
subscriptionId: subscription?.id || null,
|
subscriptionId: subscription?.id || null,
|
||||||
customerId: subscription?.customer || null,
|
customerId: subscription?.customer || null,
|
||||||
metadataKeys: Object.keys(subscription?.metadata || {}),
|
metadataKeys: Object.keys(subscription?.metadata || {}),
|
||||||
eventType: event?.type || null,
|
eventType: event?.type || null,
|
||||||
},
|
})
|
||||||
);
|
return
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastEvent = buildEventSnapshot(event?.type, subscription.id);
|
const lastEvent = buildEventSnapshot(event?.type, subscription.id)
|
||||||
if (resolvedUid) {
|
if (resolvedUid) {
|
||||||
lastEvent.uid = resolvedUid;
|
lastEvent.uid = resolvedUid
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId);
|
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId)
|
||||||
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
|
const metadataLevel = subscription.metadata?.subscriptionLevel || null
|
||||||
const metadataPeriod =
|
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null
|
||||||
subscription.metadata?.subscriptionBillingPeriod || null;
|
const resolvedLevel = metadataLevel || priceMeta?.level || null
|
||||||
const resolvedLevel = metadataLevel || priceMeta?.level || null;
|
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null
|
||||||
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
|
|
||||||
|
|
||||||
const isPremium = subscription.status
|
const isPremium = subscription.status
|
||||||
? PREMIUM_SUBSCRIPTION_STATUSES.has(subscription.status)
|
? PREMIUM_SUBSCRIPTION_STATUSES.has(subscription.status)
|
||||||
: false;
|
: false
|
||||||
|
|
||||||
const primaryItem = Array.isArray(subscriptionPayload?.items)
|
const primaryItem = Array.isArray(subscriptionPayload?.items)
|
||||||
? subscriptionPayload.items[0]
|
? subscriptionPayload.items[0]
|
||||||
: null;
|
: null
|
||||||
|
|
||||||
let coinsPerMonth = null;
|
let coinsPerMonth = null
|
||||||
const productMetadata =
|
const productMetadata =
|
||||||
primaryItem?.price &&
|
primaryItem?.price &&
|
||||||
typeof primaryItem.price === "object" &&
|
typeof primaryItem.price === 'object' &&
|
||||||
primaryItem.price.product &&
|
primaryItem.price.product &&
|
||||||
typeof primaryItem.price.product === "object"
|
typeof primaryItem.price.product === 'object'
|
||||||
? primaryItem.price.product.metadata
|
? primaryItem.price.product.metadata
|
||||||
: null;
|
: null
|
||||||
|
|
||||||
coinsPerMonth = parseCoinsPerMonth(productMetadata || {});
|
coinsPerMonth = parseCoinsPerMonth(productMetadata || {})
|
||||||
|
|
||||||
if (coinsPerMonth === null && stripe && primaryItem?.price?.id) {
|
if (coinsPerMonth === null && stripe && primaryItem?.price?.id) {
|
||||||
try {
|
try {
|
||||||
const priceWithProduct = await stripe.prices.retrieve(
|
const priceWithProduct = await stripe.prices.retrieve(primaryItem.price.id, {
|
||||||
primaryItem.price.id,
|
expand: ['product'],
|
||||||
{ expand: ["product"] },
|
})
|
||||||
);
|
coinsPerMonth = parseCoinsPerMonth(priceWithProduct?.product?.metadata || {})
|
||||||
coinsPerMonth = parseCoinsPerMonth(
|
|
||||||
priceWithProduct?.product?.metadata || {},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata",
|
'[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata',
|
||||||
primaryItem.price.id,
|
primaryItem.price.id,
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let subscriptionNextGrantAt = null;
|
let subscriptionNextGrantAt = null
|
||||||
if (coinsPerMonth !== null && subscriptionPayload?.currentPeriodEnd) {
|
if (coinsPerMonth !== null && subscriptionPayload?.currentPeriodEnd) {
|
||||||
subscriptionNextGrantAt = computeNextGrantTimestamp(
|
subscriptionNextGrantAt = computeNextGrantTimestamp(subscriptionPayload.currentPeriodEnd, 1)
|
||||||
subscriptionPayload.currentPeriodEnd,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userUpdate = {
|
const userUpdate = {
|
||||||
@@ -313,53 +289,46 @@ const handleCustomerSubscriptionEvent = async (
|
|||||||
premiumLevel: isPremium ? resolvedLevel : null,
|
premiumLevel: isPremium ? resolvedLevel : null,
|
||||||
premiumBillingPeriod: isPremium ? resolvedPeriod : null,
|
premiumBillingPeriod: isPremium ? resolvedPeriod : null,
|
||||||
subscriptionNextGrantAt,
|
subscriptionNextGrantAt,
|
||||||
};
|
}
|
||||||
|
|
||||||
if (!isPremium) {
|
if (!isPremium) {
|
||||||
userUpdate.subscriptionNextGrantAt = null;
|
userUpdate.subscriptionNextGrantAt = null
|
||||||
}
|
}
|
||||||
|
|
||||||
await userRef.set(userUpdate, { merge: true });
|
await userRef.set(userUpdate, { merge: true })
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
|
const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
|
||||||
if (!invoice || typeof invoice !== "object") {
|
if (!invoice || typeof invoice !== 'object') {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const firebaseUid = extractFirebaseUid(invoice.metadata);
|
const firebaseUid = extractFirebaseUid(invoice.metadata)
|
||||||
const eventType = event?.type || null;
|
const eventType = event?.type || null
|
||||||
const paymentDocRef = paymentsCollection.doc(invoice.id);
|
const paymentDocRef = paymentsCollection.doc(invoice.id)
|
||||||
|
|
||||||
let resolvedSubscriptionId =
|
let resolvedSubscriptionId =
|
||||||
typeof invoice.subscription === "string" && invoice.subscription
|
typeof invoice.subscription === 'string' && invoice.subscription ? invoice.subscription : null
|
||||||
? invoice.subscription
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!resolvedSubscriptionId) {
|
if (!resolvedSubscriptionId) {
|
||||||
const lineSubscriptionId = Array.isArray(invoice?.lines?.data)
|
const lineSubscriptionId = Array.isArray(invoice?.lines?.data)
|
||||||
? invoice.lines.data
|
? invoice.lines.data
|
||||||
.map((line) =>
|
.map((line) =>
|
||||||
typeof line?.subscription === "string" && line.subscription
|
typeof line?.subscription === 'string' && line.subscription ? line.subscription : null
|
||||||
? line.subscription
|
|
||||||
: null,
|
|
||||||
)
|
)
|
||||||
.find((value) => value)
|
.find((value) => value)
|
||||||
: null;
|
: null
|
||||||
|
|
||||||
if (lineSubscriptionId) {
|
if (lineSubscriptionId) {
|
||||||
resolvedSubscriptionId = lineSubscriptionId;
|
resolvedSubscriptionId = lineSubscriptionId
|
||||||
console.log(
|
console.log('[subscription-handleInvoiceEvent] Subscription resolved from invoice line', {
|
||||||
"[subscription-handleInvoiceEvent] Subscription resolved from invoice line",
|
|
||||||
{
|
|
||||||
invoiceId: invoice?.id || null,
|
invoiceId: invoice?.id || null,
|
||||||
subscriptionId: resolvedSubscriptionId,
|
subscriptionId: resolvedSubscriptionId,
|
||||||
},
|
})
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[subscription-handleInvoiceEvent] Received invoice webhook", {
|
console.log('[subscription-handleInvoiceEvent] Received invoice webhook', {
|
||||||
eventType,
|
eventType,
|
||||||
invoiceId: invoice?.id || null,
|
invoiceId: invoice?.id || null,
|
||||||
subscriptionId: invoice?.subscription || null,
|
subscriptionId: invoice?.subscription || null,
|
||||||
@@ -368,17 +337,14 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
|
|||||||
status: invoice?.status || null,
|
status: invoice?.status || null,
|
||||||
billingReason: invoice?.billing_reason || null,
|
billingReason: invoice?.billing_reason || null,
|
||||||
attemptCount: invoice?.attempt_count ?? null,
|
attemptCount: invoice?.attempt_count ?? null,
|
||||||
});
|
})
|
||||||
|
|
||||||
await upsertPaymentDocument(invoice.id, {
|
await upsertPaymentDocument(invoice.id, {
|
||||||
userId: firebaseUid || null,
|
userId: firebaseUid || null,
|
||||||
customerId: invoice.customer || null,
|
customerId: invoice.customer || null,
|
||||||
subscriptionId: resolvedSubscriptionId,
|
subscriptionId: resolvedSubscriptionId,
|
||||||
status: invoice.status || null,
|
status: invoice.status || null,
|
||||||
paymentStatus:
|
paymentStatus: eventType === 'invoice.payment_failed' ? 'failed' : invoice.status || null,
|
||||||
eventType === "invoice.payment_failed"
|
|
||||||
? "failed"
|
|
||||||
: invoice.status || null,
|
|
||||||
amountDue: invoice.amount_due ?? null,
|
amountDue: invoice.amount_due ?? null,
|
||||||
amountPaid: invoice.amount_paid ?? null,
|
amountPaid: invoice.amount_paid ?? null,
|
||||||
amountRemaining: invoice.amount_remaining ?? null,
|
amountRemaining: invoice.amount_remaining ?? null,
|
||||||
@@ -394,8 +360,8 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
|
|||||||
lastEventType: eventType,
|
lastEventType: eventType,
|
||||||
lastEventId: event?.id || null,
|
lastEventId: event?.id || null,
|
||||||
lastEventAt: getServerTimestamp(),
|
lastEventAt: getServerTimestamp(),
|
||||||
mode: "invoice",
|
mode: 'invoice',
|
||||||
});
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
uid,
|
uid,
|
||||||
@@ -404,124 +370,109 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
|
|||||||
} = await resolveUserContext({
|
} = await resolveUserContext({
|
||||||
metadata: invoice.metadata,
|
metadata: invoice.metadata,
|
||||||
customerId: invoice.customer,
|
customerId: invoice.customer,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!userRef) {
|
if (!userRef) {
|
||||||
console.warn(
|
console.warn('[subscription-handleInvoiceEvent] User context not resolved', {
|
||||||
"[subscription-handleInvoiceEvent] User context not resolved",
|
|
||||||
{
|
|
||||||
invoiceId: invoice?.id || null,
|
invoiceId: invoice?.id || null,
|
||||||
customerId: invoice?.customer || null,
|
customerId: invoice?.customer || null,
|
||||||
firebaseUid: firebaseUid || null,
|
firebaseUid: firebaseUid || null,
|
||||||
metadataKeys: Object.keys(invoice?.metadata || {}),
|
metadataKeys: Object.keys(invoice?.metadata || {}),
|
||||||
},
|
})
|
||||||
);
|
return
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let userData = resolvedUserData || null;
|
let userData = resolvedUserData || null
|
||||||
if (!userData) {
|
if (!userData) {
|
||||||
try {
|
try {
|
||||||
const snapshot = await userRef.get();
|
const snapshot = await userRef.get()
|
||||||
userData = snapshot.exists ? snapshot.data() || null : null;
|
userData = snapshot.exists ? snapshot.data() || null : null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[subscription-handleInvoiceEvent] Unable to read user",
|
'[subscription-handleInvoiceEvent] Unable to read user',
|
||||||
invoice.customer,
|
invoice.customer,
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastEvent = buildEventSnapshot(eventType, invoice.id);
|
const lastEvent = buildEventSnapshot(eventType, invoice.id)
|
||||||
if (firebaseUid) {
|
if (firebaseUid) {
|
||||||
lastEvent.uid = firebaseUid;
|
lastEvent.uid = firebaseUid
|
||||||
}
|
}
|
||||||
|
|
||||||
const userUpdate = {
|
const userUpdate = {
|
||||||
lastStripeWebhookEvent: lastEvent,
|
lastStripeWebhookEvent: lastEvent,
|
||||||
};
|
}
|
||||||
|
|
||||||
const billingReason = invoice.billing_reason || null;
|
const billingReason = invoice.billing_reason || null
|
||||||
const isInvoicePaid = invoice.status === "paid";
|
const isInvoicePaid = invoice.status === 'paid'
|
||||||
if (
|
if (billingReason === 'subscription_create' || billingReason === 'subscription_cycle') {
|
||||||
billingReason === "subscription_create" ||
|
|
||||||
billingReason === "subscription_cycle"
|
|
||||||
) {
|
|
||||||
if (isInvoicePaid && invoice.subscription) {
|
if (isInvoicePaid && invoice.subscription) {
|
||||||
userUpdate.subscriptionLastInvoiceAt = getServerTimestamp();
|
userUpdate.subscriptionLastInvoiceAt = getServerTimestamp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await userRef.set(userUpdate, { merge: true });
|
await userRef.set(userUpdate, { merge: true })
|
||||||
|
|
||||||
const subscriptionLine = Array.isArray(invoice?.lines?.data)
|
const subscriptionLine = Array.isArray(invoice?.lines?.data)
|
||||||
? invoice.lines.data.find(
|
? invoice.lines.data.find(
|
||||||
(line) =>
|
(line) => line && typeof line === 'object' && (line.type === 'subscription' || line.price)
|
||||||
line &&
|
|
||||||
typeof line === "object" &&
|
|
||||||
(line.type === "subscription" || line.price),
|
|
||||||
)
|
)
|
||||||
: null;
|
: null
|
||||||
|
|
||||||
const priceId =
|
const priceId =
|
||||||
typeof subscriptionLine?.price?.id === "string"
|
typeof subscriptionLine?.price?.id === 'string'
|
||||||
? subscriptionLine.price.id
|
? subscriptionLine.price.id
|
||||||
: typeof subscriptionLine?.price === "string"
|
: typeof subscriptionLine?.price === 'string'
|
||||||
? subscriptionLine.price
|
? subscriptionLine.price
|
||||||
: null;
|
: null
|
||||||
|
|
||||||
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
|
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
|
||||||
const billingPeriod =
|
const billingPeriod =
|
||||||
priceMeta.billingPeriod ||
|
priceMeta.billingPeriod ||
|
||||||
(subscriptionLine?.price?.recurring?.interval === "year"
|
(subscriptionLine?.price?.recurring?.interval === 'year'
|
||||||
? "annual"
|
? 'annual'
|
||||||
: subscriptionLine?.price?.recurring?.interval === "month"
|
: subscriptionLine?.price?.recurring?.interval === 'month'
|
||||||
? "monthly"
|
? 'monthly'
|
||||||
: null);
|
: null)
|
||||||
|
|
||||||
const coinsPerMonth =
|
const coinsPerMonth =
|
||||||
priceMeta.coinsPerMonth ??
|
priceMeta.coinsPerMonth ??
|
||||||
parseCoinsPerMonth(subscriptionLine?.price?.product?.metadata || {}) ??
|
parseCoinsPerMonth(subscriptionLine?.price?.product?.metadata || {}) ??
|
||||||
null;
|
null
|
||||||
|
|
||||||
const coinsToGrant =
|
const coinsToGrant =
|
||||||
billingPeriod === "annual" && typeof coinsPerMonth === "number"
|
billingPeriod === 'annual' && typeof coinsPerMonth === 'number' ? coinsPerMonth * 12 : null
|
||||||
? coinsPerMonth * 12
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const targetSubscriptionId =
|
const targetSubscriptionId =
|
||||||
resolvedSubscriptionId ||
|
resolvedSubscriptionId ||
|
||||||
(typeof invoice.subscription === "string" ? invoice.subscription : null) ||
|
(typeof invoice.subscription === 'string' ? invoice.subscription : null) ||
|
||||||
(typeof subscriptionLine?.subscription === "string"
|
(typeof subscriptionLine?.subscription === 'string' ? subscriptionLine.subscription : null)
|
||||||
? subscriptionLine.subscription
|
|
||||||
: null);
|
|
||||||
|
|
||||||
const shouldGrantUpfront =
|
const shouldGrantUpfront =
|
||||||
isInvoicePaid &&
|
isInvoicePaid &&
|
||||||
coinsToGrant &&
|
coinsToGrant &&
|
||||||
(billingReason === "subscription_create" ||
|
(billingReason === 'subscription_create' || billingReason === 'subscription_cycle')
|
||||||
billingReason === "subscription_cycle");
|
|
||||||
|
|
||||||
if (shouldGrantUpfront && targetSubscriptionId) {
|
if (shouldGrantUpfront && targetSubscriptionId) {
|
||||||
let grantSnapshot = null;
|
let grantSnapshot = null
|
||||||
try {
|
try {
|
||||||
grantSnapshot = await paymentDocRef.get();
|
grantSnapshot = await paymentDocRef.get()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[subscription-handleInvoiceEvent] Unable to read payment doc before grant",
|
'[subscription-handleInvoiceEvent] Unable to read payment doc before grant',
|
||||||
invoice?.id || null,
|
invoice?.id || null,
|
||||||
error?.message || error,
|
error?.message || error
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const alreadyGranted =
|
const alreadyGranted =
|
||||||
grantSnapshot?.exists &&
|
grantSnapshot?.exists && Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt)
|
||||||
Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt);
|
|
||||||
|
|
||||||
if (!alreadyGranted) {
|
if (!alreadyGranted) {
|
||||||
const targetUserId = uid || firebaseUid || userRef.id;
|
const targetUserId = uid || firebaseUid || userRef.id
|
||||||
const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}`;
|
const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { orderId: processedOrderId } = await createOrderDocument({
|
const { orderId: processedOrderId } = await createOrderDocument({
|
||||||
@@ -529,139 +480,129 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
|
|||||||
type: ORDER_TYPES.SUBSCRIPTION,
|
type: ORDER_TYPES.SUBSCRIPTION,
|
||||||
amount: coinsToGrant,
|
amount: coinsToGrant,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: "STRIPE_INVOICE",
|
source: 'STRIPE_INVOICE',
|
||||||
billingPeriod,
|
billingPeriod,
|
||||||
coinsPerMonth,
|
coinsPerMonth,
|
||||||
invoiceId: invoice.id || null,
|
invoiceId: invoice.id || null,
|
||||||
subscriptionId: targetSubscriptionId,
|
subscriptionId: targetSubscriptionId,
|
||||||
grantStrategy: "upfront",
|
grantStrategy: 'upfront',
|
||||||
},
|
},
|
||||||
orderId,
|
orderId,
|
||||||
});
|
})
|
||||||
|
|
||||||
await paymentDocRef.set(
|
await paymentDocRef.set(
|
||||||
{
|
{
|
||||||
subscriptionCoinsGrantedAt: getServerTimestamp(),
|
subscriptionCoinsGrantedAt: getServerTimestamp(),
|
||||||
subscriptionCoinsGrantAmount: coinsToGrant,
|
subscriptionCoinsGrantAmount: coinsToGrant,
|
||||||
subscriptionCoinsGrantOrderId: processedOrderId,
|
subscriptionCoinsGrantOrderId: processedOrderId,
|
||||||
subscriptionCoinsGrantSource: "invoice_upfront",
|
subscriptionCoinsGrantSource: 'invoice_upfront',
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
|
|
||||||
await userRef.set(
|
await userRef.set(
|
||||||
{
|
{
|
||||||
subscriptionLastGrantAt: getServerTimestamp(),
|
subscriptionLastGrantAt: getServerTimestamp(),
|
||||||
subscriptionLastGrantAmount: coinsToGrant,
|
subscriptionLastGrantAmount: coinsToGrant,
|
||||||
subscriptionLastGrantOrderId: processedOrderId,
|
subscriptionLastGrantOrderId: processedOrderId,
|
||||||
subscriptionLastGrantSource: "invoice_upfront",
|
subscriptionLastGrantSource: 'invoice_upfront',
|
||||||
subscriptionNextGrantAt: null,
|
subscriptionNextGrantAt: null,
|
||||||
subscriptionGrantInterval: null,
|
subscriptionGrantInterval: null,
|
||||||
subscriptionGrantStrategy: "upfront",
|
subscriptionGrantStrategy: 'upfront',
|
||||||
subscriptionCoinsPerMonth: coinsPerMonth,
|
subscriptionCoinsPerMonth: coinsPerMonth,
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true }
|
||||||
);
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins",
|
'[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins',
|
||||||
{
|
{
|
||||||
invoiceId: invoice?.id || null,
|
invoiceId: invoice?.id || null,
|
||||||
subscriptionId: targetSubscriptionId,
|
subscriptionId: targetSubscriptionId,
|
||||||
error: error?.message || error,
|
error: error?.message || error,
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleStripeWebhookEvent = async ({ event, stripe }) => {
|
const handleStripeWebhookEvent = async ({ event, stripe }) => {
|
||||||
if (!event || typeof event !== "object") {
|
if (!event || typeof event !== 'object') {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType = event.type;
|
const eventType = event.type
|
||||||
switch (eventType) {
|
switch (eventType) {
|
||||||
case "checkout.session.completed":
|
case 'checkout.session.completed':
|
||||||
await handleCheckoutSessionCompleted(event.data?.object, event, {
|
await handleCheckoutSessionCompleted(event.data?.object, event, {
|
||||||
stripe,
|
stripe,
|
||||||
});
|
})
|
||||||
break;
|
break
|
||||||
case "customer.subscription.created":
|
case 'customer.subscription.created':
|
||||||
case "customer.subscription.updated":
|
case 'customer.subscription.updated':
|
||||||
case "customer.subscription.deleted":
|
case 'customer.subscription.deleted':
|
||||||
await handleCustomerSubscriptionEvent(event.data?.object, event, {
|
await handleCustomerSubscriptionEvent(event.data?.object, event, {
|
||||||
stripe,
|
stripe,
|
||||||
});
|
})
|
||||||
break;
|
break
|
||||||
case "invoice.payment_succeeded":
|
case 'invoice.payment_succeeded':
|
||||||
case "invoice.payment_failed":
|
case 'invoice.payment_failed':
|
||||||
case "invoice.finalized":
|
case 'invoice.finalized':
|
||||||
await handleInvoiceEvent(event.data?.object, event, { stripe });
|
await handleInvoiceEvent(event.data?.object, event, { stripe })
|
||||||
break;
|
break
|
||||||
default:
|
default:
|
||||||
console.log(
|
console.log('[subscription-handleStripeWebhookEvent] Unhandled event type', eventType)
|
||||||
"[subscription-handleStripeWebhookEvent] Unhandled event type",
|
|
||||||
eventType,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => {
|
const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== 'POST') {
|
||||||
res.status(405).send("Method Not Allowed");
|
res.status(405).send('Method Not Allowed')
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const signature = req.headers["stripe-signature"];
|
const signature = req.headers['stripe-signature']
|
||||||
if (!signature) {
|
if (!signature) {
|
||||||
res.status(400).send("Missing Stripe signature");
|
res.status(400).send('Missing Stripe signature')
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let rawBody = req.rawBody;
|
let rawBody = req.rawBody
|
||||||
if (!rawBody && req.body) {
|
if (!rawBody && req.body) {
|
||||||
rawBody = Buffer.from(JSON.stringify(req.body));
|
rawBody = Buffer.from(JSON.stringify(req.body))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rawBody) {
|
if (!rawBody) {
|
||||||
res.status(400).send("Missing request body");
|
res.status(400).send('Missing request body')
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let stripe = null;
|
let stripe = null
|
||||||
try {
|
try {
|
||||||
stripe = getStripeClient();
|
stripe = getStripeClient()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error('[subscription-handleStripeWebhook] Stripe client error', error)
|
||||||
"[subscription-handleStripeWebhook] Stripe client error",
|
res.status(500).send('Client Stripe indisponible')
|
||||||
error,
|
return
|
||||||
);
|
|
||||||
res.status(500).send("Client Stripe indisponible");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const event = stripe.webhooks.constructEvent(
|
const event = stripe.webhooks.constructEvent(rawBody, signature, resolveStripeWebhookSecret())
|
||||||
rawBody,
|
|
||||||
signature,
|
|
||||||
resolveStripeWebhookSecret(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await handleStripeWebhookEvent({ event, stripe });
|
await handleStripeWebhookEvent({ event, stripe })
|
||||||
|
|
||||||
res.status(200).send({ received: true });
|
res.status(200).send({ received: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[subscription-handleStripeWebhook] error", error);
|
console.error('[subscription-handleStripeWebhook] error', error)
|
||||||
if (error instanceof HttpsError) {
|
if (error instanceof HttpsError) {
|
||||||
res.status(400).send(error.message);
|
res.status(400).send(error.message)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
res.status(500).send("Erreur lors du traitement du webhook");
|
res.status(500).send('Erreur lors du traitement du webhook')
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
handleStripeWebhook,
|
handleStripeWebhook,
|
||||||
};
|
}
|
||||||
|
|||||||
+72
-83
@@ -1,151 +1,140 @@
|
|||||||
const { onObjectFinalized } = require("firebase-functions/v2/storage");
|
const { onObjectFinalized } = require('firebase-functions/v2/storage')
|
||||||
const logger = require("firebase-functions/logger");
|
const logger = require('firebase-functions/logger')
|
||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
const ffmpeg = require("fluent-ffmpeg");
|
const ffmpeg = require('fluent-ffmpeg')
|
||||||
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
|
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg')
|
||||||
const fs = require("node:fs/promises");
|
const fs = require('node:fs/promises')
|
||||||
const os = require("node:os");
|
const os = require('node:os')
|
||||||
const path = require("node:path");
|
const path = require('node:path')
|
||||||
const crypto = require("node:crypto");
|
const crypto = require('node:crypto')
|
||||||
const { refList } = require("../index");
|
const { refList } = require('../index')
|
||||||
|
|
||||||
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
ffmpeg.setFfmpegPath(ffmpegInstaller.path)
|
||||||
|
|
||||||
exports.generateVideoThumbnail = onObjectFinalized(
|
exports.generateVideoThumbnail = onObjectFinalized(
|
||||||
{
|
{
|
||||||
region: "europe-west1",
|
region: 'europe-west1',
|
||||||
timeoutSeconds: 180,
|
timeoutSeconds: 180,
|
||||||
memory: "1GiB",
|
memory: '1GiB',
|
||||||
cpu: 1,
|
cpu: 1,
|
||||||
},
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
const file = event.data || {};
|
const file = event.data || {}
|
||||||
const bucketName = file.bucket;
|
const bucketName = file.bucket
|
||||||
const objectName = file.name || "";
|
const objectName = file.name || ''
|
||||||
const contentType = file.contentType || "";
|
const contentType = file.contentType || ''
|
||||||
|
|
||||||
// Basic guards + helpful logs for debugging why events might be ignored
|
// Basic guards + helpful logs for debugging why events might be ignored
|
||||||
if (!bucketName || !objectName) {
|
if (!bucketName || !objectName) {
|
||||||
logger.info("[Thumbnail] Ignored: missing bucket or object name", {
|
logger.info('[Thumbnail] Ignored: missing bucket or object name', {
|
||||||
bucketName,
|
bucketName,
|
||||||
objectName,
|
objectName,
|
||||||
contentType,
|
contentType,
|
||||||
});
|
})
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid processing our own generated thumbnails
|
// Avoid processing our own generated thumbnails
|
||||||
if (/_thumb9x16\.jpg$/i.test(objectName)) {
|
if (/_thumb9x16\.jpg$/i.test(objectName)) {
|
||||||
logger.info("[Thumbnail] Ignored: already a thumbnail", { objectName });
|
logger.info('[Thumbnail] Ignored: already a thumbnail', { objectName })
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept if contentType says it's a video OR fallback to extension-based check
|
// Accept if contentType says it's a video OR fallback to extension-based check
|
||||||
const isVideoContentType =
|
const isVideoContentType = typeof contentType === 'string' && contentType.startsWith('video/')
|
||||||
typeof contentType === "string" && contentType.startsWith("video/");
|
const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(objectName.toLowerCase())
|
||||||
const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(
|
|
||||||
objectName.toLowerCase()
|
|
||||||
);
|
|
||||||
if (!isVideoContentType && !isVideoLikeName) {
|
if (!isVideoContentType && !isVideoLikeName) {
|
||||||
logger.info("[Thumbnail] Ignored: not a video", {
|
logger.info('[Thumbnail] Ignored: not a video', {
|
||||||
objectName,
|
objectName,
|
||||||
contentType,
|
contentType,
|
||||||
});
|
})
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("[Thumbnail] Event accepted", {
|
logger.info('[Thumbnail] Event accepted', {
|
||||||
bucketName,
|
bucketName,
|
||||||
objectName,
|
objectName,
|
||||||
contentType,
|
contentType,
|
||||||
});
|
})
|
||||||
|
|
||||||
const bucket = admin.storage().bucket(bucketName);
|
const bucket = admin.storage().bucket(bucketName)
|
||||||
const playbackMatch = objectName.match(
|
const playbackMatch = objectName.match(/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i)
|
||||||
/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i
|
const projectId = playbackMatch ? playbackMatch[1] : null
|
||||||
);
|
|
||||||
const projectId = playbackMatch ? playbackMatch[1] : null;
|
|
||||||
|
|
||||||
// Use unique folder under /tmp to avoid name collisions
|
// Use unique folder under /tmp to avoid name collisions
|
||||||
const tmpDir = path.join(
|
const tmpDir = path.join(os.tmpdir(), `thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`)
|
||||||
os.tmpdir(),
|
const baseName = path.basename(objectName)
|
||||||
`thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`
|
const dirName = path.posix.dirname(objectName)
|
||||||
);
|
const localVideoPath = path.join(tmpDir, baseName)
|
||||||
const baseName = path.basename(objectName);
|
|
||||||
const dirName = path.posix.dirname(objectName);
|
|
||||||
const localVideoPath = path.join(tmpDir, baseName);
|
|
||||||
|
|
||||||
const thumbBase = baseName.replace(/\.[^.]+$/, "") + "_thumb9x16.jpg";
|
const thumbBase = baseName.replace(/\.[^.]+$/, '') + '_thumb9x16.jpg'
|
||||||
const localThumbPath = path.join(tmpDir, thumbBase);
|
const localThumbPath = path.join(tmpDir, thumbBase)
|
||||||
const remoteThumbPath =
|
const remoteThumbPath =
|
||||||
dirName && dirName !== "."
|
dirName && dirName !== '.' ? path.posix.join(dirName, thumbBase) : thumbBase
|
||||||
? path.posix.join(dirName, thumbBase)
|
|
||||||
: thumbBase;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(tmpDir, { recursive: true });
|
await fs.mkdir(tmpDir, { recursive: true })
|
||||||
|
|
||||||
// Télécharger la vidéo depuis le bucket
|
// Télécharger la vidéo depuis le bucket
|
||||||
await bucket.file(objectName).download({ destination: localVideoPath });
|
await bucket.file(objectName).download({ destination: localVideoPath })
|
||||||
|
|
||||||
// Extraire 1 frame en 9:16 (1080x1920) de manière robuste
|
// Extraire 1 frame en 9:16 (1080x1920) de manière robuste
|
||||||
const vfCoverCrop =
|
const vfCoverCrop = 'scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920'
|
||||||
"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920";
|
|
||||||
const vfPad =
|
const vfPad =
|
||||||
"scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2";
|
'scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2'
|
||||||
|
|
||||||
const extractFrame = (ssSeconds, vf) =>
|
const extractFrame = (ssSeconds, vf) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
ffmpeg(localVideoPath)
|
ffmpeg(localVideoPath)
|
||||||
.inputOptions([`-ss ${ssSeconds}`])
|
.inputOptions([`-ss ${ssSeconds}`])
|
||||||
.frames(1)
|
.frames(1)
|
||||||
.outputOptions(["-vf", vf, "-q:v", "2"])
|
.outputOptions(['-vf', vf, '-q:v', '2'])
|
||||||
.output(localThumbPath)
|
.output(localThumbPath)
|
||||||
.on("end", resolve)
|
.on('end', resolve)
|
||||||
.on("error", reject)
|
.on('error', reject)
|
||||||
.run();
|
.run()
|
||||||
});
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1) Essai principal: 1s + cover/crop
|
// 1) Essai principal: 1s + cover/crop
|
||||||
await extractFrame(1, vfCoverCrop);
|
await extractFrame(1, vfCoverCrop)
|
||||||
} catch (e1) {
|
} catch (e1) {
|
||||||
logger.warn("[Thumbnail] First attempt failed, retrying at 0s", {
|
logger.warn('[Thumbnail] First attempt failed, retrying at 0s', {
|
||||||
objectName,
|
objectName,
|
||||||
error: e1?.message || String(e1),
|
error: e1?.message || String(e1),
|
||||||
});
|
})
|
||||||
try {
|
try {
|
||||||
// 2) Deuxième essai: 0s + cover/crop (si vidéo très courte)
|
// 2) Deuxième essai: 0s + cover/crop (si vidéo très courte)
|
||||||
await extractFrame(0, vfCoverCrop);
|
await extractFrame(0, vfCoverCrop)
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
logger.warn("[Thumbnail] Second attempt failed, fallback to pad", {
|
logger.warn('[Thumbnail] Second attempt failed, fallback to pad', {
|
||||||
objectName,
|
objectName,
|
||||||
error: e2?.message || String(e2),
|
error: e2?.message || String(e2),
|
||||||
});
|
})
|
||||||
// 3) Fallback: 0s + pad (aucun crop, bandes latérales si besoin)
|
// 3) Fallback: 0s + pad (aucun crop, bandes latérales si besoin)
|
||||||
await extractFrame(0, vfPad);
|
await extractFrame(0, vfPad)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload du thumbnail avec un token de téléchargement public Firebase
|
// Upload du thumbnail avec un token de téléchargement public Firebase
|
||||||
const downloadToken = crypto.randomUUID();
|
const downloadToken = crypto.randomUUID()
|
||||||
await bucket.upload(localThumbPath, {
|
await bucket.upload(localThumbPath, {
|
||||||
destination: remoteThumbPath,
|
destination: remoteThumbPath,
|
||||||
metadata: {
|
metadata: {
|
||||||
contentType: "image/jpeg",
|
contentType: 'image/jpeg',
|
||||||
cacheControl: "public, max-age=86400",
|
cacheControl: 'public, max-age=86400',
|
||||||
metadata: {
|
metadata: {
|
||||||
original: objectName,
|
original: objectName,
|
||||||
aspect: "9:16",
|
aspect: '9:16',
|
||||||
t: "1s",
|
t: '1s',
|
||||||
firebaseStorageDownloadTokens: downloadToken,
|
firebaseStorageDownloadTokens: downloadToken,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const encodedPath = encodeURIComponent(remoteThumbPath);
|
const encodedPath = encodeURIComponent(remoteThumbPath)
|
||||||
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`;
|
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`
|
||||||
|
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
await refList.projects.doc(projectId).set(
|
await refList.projects.doc(projectId).set(
|
||||||
@@ -154,23 +143,23 @@ exports.generateVideoThumbnail = onObjectFinalized(
|
|||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true }
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("✅ [Thumbnail] Uploaded", {
|
logger.info('✅ [Thumbnail] Uploaded', {
|
||||||
objectName,
|
objectName,
|
||||||
remoteThumbPath,
|
remoteThumbPath,
|
||||||
projectId,
|
projectId,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
});
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("❌ [Thumbnail] Failed", {
|
logger.error('❌ [Thumbnail] Failed', {
|
||||||
objectName,
|
objectName,
|
||||||
error: error?.message || String(error),
|
error: error?.message || String(error),
|
||||||
});
|
})
|
||||||
throw error;
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
|||||||
+115
-129
@@ -1,35 +1,35 @@
|
|||||||
// functions/mergeVideoAndAudio.js (ou dans index.js)
|
// functions/mergeVideoAndAudio.js (ou dans index.js)
|
||||||
|
|
||||||
const { onCall, HttpsError } = require("firebase-functions/v2/https");
|
const { onCall, HttpsError } = require('firebase-functions/v2/https')
|
||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const logger = require("firebase-functions/logger");
|
const logger = require('firebase-functions/logger')
|
||||||
const axios = require("axios");
|
const axios = require('axios')
|
||||||
const fs = require("node:fs/promises");
|
const fs = require('node:fs/promises')
|
||||||
const os = require("node:os");
|
const os = require('node:os')
|
||||||
const path = require("node:path");
|
const path = require('node:path')
|
||||||
const crypto = require("node:crypto");
|
const crypto = require('node:crypto')
|
||||||
const ffmpeg = require("fluent-ffmpeg");
|
const ffmpeg = require('fluent-ffmpeg')
|
||||||
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
|
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg')
|
||||||
const { Buffer } = require("node:buffer");
|
const { Buffer } = require('node:buffer')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
|
|
||||||
if (!admin.apps.length) admin.initializeApp();
|
if (!admin.apps.length) admin.initializeApp()
|
||||||
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
ffmpeg.setFfmpegPath(ffmpegInstaller.path)
|
||||||
|
|
||||||
const db = admin.firestore();
|
const db = admin.firestore()
|
||||||
const PLAYBACK_CODEC_TAG = "h264-v1";
|
const PLAYBACK_CODEC_TAG = 'h264-v1'
|
||||||
|
|
||||||
async function downloadToFile(url, destPath) {
|
async function downloadToFile(url, destPath) {
|
||||||
if (!/^https?:\/\//i.test(url || "")) {
|
if (!/^https?:\/\//i.test(url || '')) {
|
||||||
throw new HttpsError("invalid-argument", `URL non supportée: ${url}`);
|
throw new HttpsError('invalid-argument', `URL non supportée: ${url}`)
|
||||||
}
|
}
|
||||||
const res = await axios.get(url, { responseType: "arraybuffer" });
|
const res = await axios.get(url, { responseType: 'arraybuffer' })
|
||||||
await fs.writeFile(destPath, Buffer.from(res.data));
|
await fs.writeFile(destPath, Buffer.from(res.data))
|
||||||
return res.headers?.["content-type"] || "";
|
return res.headers?.['content-type'] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCALE_FILTER =
|
const SCALE_FILTER =
|
||||||
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease";
|
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease"
|
||||||
|
|
||||||
async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
|
async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -37,92 +37,92 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
|
|||||||
.input(videoPath) // 0:v
|
.input(videoPath) // 0:v
|
||||||
.input(audioPath) // 1:a
|
.input(audioPath) // 1:a
|
||||||
.outputOptions([
|
.outputOptions([
|
||||||
"-map",
|
'-map',
|
||||||
"0:v:0", // garder la 1re piste vidéo de l'entrée 0
|
'0:v:0', // garder la 1re piste vidéo de l'entrée 0
|
||||||
"-map",
|
'-map',
|
||||||
"1:a:0", // prendre la 1re piste audio de l'entrée 1
|
'1:a:0', // prendre la 1re piste audio de l'entrée 1
|
||||||
// Force une sortie H264 1080p max pour compatibilité totale iOS (les WebM VP8/9 posaient problème)
|
// Force une sortie H264 1080p max pour compatibilité totale iOS (les WebM VP8/9 posaient problème)
|
||||||
"-vf",
|
'-vf',
|
||||||
SCALE_FILTER,
|
SCALE_FILTER,
|
||||||
"-c:v",
|
'-c:v',
|
||||||
"libx264",
|
'libx264',
|
||||||
"-preset",
|
'-preset',
|
||||||
"veryfast",
|
'veryfast',
|
||||||
"-crf",
|
'-crf',
|
||||||
"22",
|
'22',
|
||||||
"-pix_fmt",
|
'-pix_fmt',
|
||||||
"yuv420p",
|
'yuv420p',
|
||||||
"-profile:v",
|
'-profile:v',
|
||||||
"high",
|
'high',
|
||||||
"-level:v",
|
'-level:v',
|
||||||
"4.1",
|
'4.1',
|
||||||
"-c:a",
|
'-c:a',
|
||||||
"aac",
|
'aac',
|
||||||
"-b:a",
|
'-b:a',
|
||||||
"192k",
|
'192k',
|
||||||
"-movflags",
|
'-movflags',
|
||||||
"+faststart",
|
'+faststart',
|
||||||
"-shortest", // couper à la plus courte des 2 sources
|
'-shortest', // couper à la plus courte des 2 sources
|
||||||
"-tag:v",
|
'-tag:v',
|
||||||
"avc1",
|
'avc1',
|
||||||
])
|
])
|
||||||
.on("error", reject)
|
.on('error', reject)
|
||||||
.on("end", resolve)
|
.on('end', resolve)
|
||||||
.save(outPath);
|
.save(outPath)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) {
|
async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "merge-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'merge-'))
|
||||||
const videoPath = path.join(tmpDir, "video.mp4");
|
const videoPath = path.join(tmpDir, 'video.mp4')
|
||||||
const audioPath = path.join(tmpDir, "audio.mp3");
|
const audioPath = path.join(tmpDir, 'audio.mp3')
|
||||||
const outPath = path.join(tmpDir, "output.mp4");
|
const outPath = path.join(tmpDir, 'output.mp4')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl });
|
logger.info('[merge] téléchargement des sources', { videoUrl, audioUrl })
|
||||||
|
|
||||||
await downloadToFile(videoUrl, videoPath);
|
await downloadToFile(videoUrl, videoPath)
|
||||||
await downloadToFile(audioUrl, audioPath);
|
await downloadToFile(audioUrl, audioPath)
|
||||||
|
|
||||||
logger.info("[merge] transcodage/mux ffmpeg");
|
logger.info('[merge] transcodage/mux ffmpeg')
|
||||||
await muxAudioIntoVideo({ videoPath, audioPath, outPath });
|
await muxAudioIntoVideo({ videoPath, audioPath, outPath })
|
||||||
|
|
||||||
const bucket = admin.storage().bucket();
|
const bucket = admin.storage().bucket()
|
||||||
const downloadToken = crypto.randomUUID();
|
const downloadToken = crypto.randomUUID()
|
||||||
|
|
||||||
await bucket.upload(outPath, {
|
await bucket.upload(outPath, {
|
||||||
destination: storagePath,
|
destination: storagePath,
|
||||||
metadata: {
|
metadata: {
|
||||||
contentType: "video/mp4",
|
contentType: 'video/mp4',
|
||||||
cacheControl: "public,max-age=86400",
|
cacheControl: 'public,max-age=86400',
|
||||||
metadata: { firebaseStorageDownloadTokens: downloadToken },
|
metadata: { firebaseStorageDownloadTokens: downloadToken },
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
||||||
storagePath
|
storagePath
|
||||||
)}?alt=media&token=${downloadToken}`;
|
)}?alt=media&token=${downloadToken}`
|
||||||
|
|
||||||
logger.info("[merge] upload terminé", { storagePath });
|
logger.info('[merge] upload terminé', { storagePath })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
url: fileUrl,
|
url: fileUrl,
|
||||||
contentType: "video/mp4",
|
contentType: 'video/mp4',
|
||||||
storagePath,
|
storagePath,
|
||||||
};
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("[merge] échec", { error: err?.message || String(err) });
|
logger.error('[merge] échec', { error: err?.message || String(err) })
|
||||||
if (err instanceof HttpsError) throw err;
|
if (err instanceof HttpsError) throw err
|
||||||
throw new HttpsError("internal", err?.message || "Fusion échouée");
|
throw new HttpsError('internal', err?.message || 'Fusion échouée')
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
|
async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
|
||||||
if (!projectId) return;
|
if (!projectId) return
|
||||||
const docRef = db.collection("projects").doc(projectId);
|
const docRef = db.collection('projects').doc(projectId)
|
||||||
await docRef.set(
|
await docRef.set(
|
||||||
{
|
{
|
||||||
playbackCompatibility: {
|
playbackCompatibility: {
|
||||||
@@ -132,89 +132,75 @@ async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true }
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.mergeVideoAndAudio = onCall(
|
exports.mergeVideoAndAudio = onCall(
|
||||||
{ timeoutSeconds: 540, memory: "1GiB" },
|
{ timeoutSeconds: 540, memory: '1GiB' },
|
||||||
async ({ data = {}, auth }) => {
|
async ({ data = {}, auth }) => {
|
||||||
const uid = auth?.uid;
|
const uid = auth?.uid
|
||||||
if (!uid)
|
if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise')
|
||||||
throw new HttpsError("unauthenticated", "Authentification requise");
|
|
||||||
|
|
||||||
const { videoUrl, audioUrl, storagePath, projectId } = data || {};
|
const { videoUrl, audioUrl, storagePath, projectId } = data || {}
|
||||||
|
|
||||||
if (!videoUrl || !audioUrl || !storagePath) {
|
if (!videoUrl || !audioUrl || !storagePath) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('invalid-argument', 'Requis: { videoUrl, audioUrl, storagePath }')
|
||||||
"invalid-argument",
|
|
||||||
"Requis: { videoUrl, audioUrl, storagePath }"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedPrefix = `users/${uid}/`;
|
const expectedPrefix = `users/${uid}/`
|
||||||
if (!storagePath.startsWith(expectedPrefix)) {
|
if (!storagePath.startsWith(expectedPrefix)) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('permission-denied', `storagePath doit commencer par ${expectedPrefix}`)
|
||||||
"permission-denied",
|
|
||||||
`storagePath doit commencer par ${expectedPrefix}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
|
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath })
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
|
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
|
||||||
}
|
}
|
||||||
return result;
|
return result
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
exports.reencodePlayback = onCall(
|
exports.reencodePlayback = onCall(
|
||||||
{ timeoutSeconds: 540, memory: "1GiB" },
|
{ timeoutSeconds: 540, memory: '1GiB' },
|
||||||
async ({ data = {}, auth }) => {
|
async ({ data = {}, auth }) => {
|
||||||
const uid = auth?.uid;
|
const uid = auth?.uid
|
||||||
if (!uid)
|
if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise')
|
||||||
throw new HttpsError("unauthenticated", "Authentification requise");
|
|
||||||
|
|
||||||
const projectId = data?.projectId;
|
const projectId = data?.projectId
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('invalid-argument', 'Requis: { projectId } pour relancer le transcodage')
|
||||||
"invalid-argument",
|
|
||||||
"Requis: { projectId } pour relancer le transcodage"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("[reencodePlayback] request received", {
|
logger.info('[reencodePlayback] request received', {
|
||||||
projectId,
|
projectId,
|
||||||
initiator: uid,
|
initiator: uid,
|
||||||
});
|
})
|
||||||
const projectRef = db.collection("projects").doc(projectId);
|
const projectRef = db.collection('projects').doc(projectId)
|
||||||
const projectSnap = await projectRef.get();
|
const projectSnap = await projectRef.get()
|
||||||
if (!projectSnap.exists) {
|
if (!projectSnap.exists) {
|
||||||
logger.warn("[reencodePlayback] project not found", {
|
logger.warn('[reencodePlayback] project not found', {
|
||||||
projectId,
|
projectId,
|
||||||
initiator: uid,
|
initiator: uid,
|
||||||
});
|
})
|
||||||
throw new HttpsError("not-found", "Projet introuvable");
|
throw new HttpsError('not-found', 'Projet introuvable')
|
||||||
}
|
}
|
||||||
const project = projectSnap.data() || {};
|
const project = projectSnap.data() || {}
|
||||||
const videoUrl = project.playbackUrl;
|
const videoUrl = project.playbackUrl
|
||||||
const audioUrl = project.songUrl;
|
const audioUrl = project.songUrl
|
||||||
const ownerId = project.userId;
|
const ownerId = project.userId
|
||||||
|
|
||||||
if (!videoUrl || !audioUrl || !ownerId) {
|
if (!videoUrl || !audioUrl || !ownerId) {
|
||||||
logger.warn("[reencodePlayback] missing fields", {
|
logger.warn('[reencodePlayback] missing fields', {
|
||||||
projectId,
|
projectId,
|
||||||
hasPlaybackUrl: !!videoUrl,
|
hasPlaybackUrl: !!videoUrl,
|
||||||
hasSongUrl: !!audioUrl,
|
hasSongUrl: !!audioUrl,
|
||||||
ownerId,
|
ownerId,
|
||||||
});
|
})
|
||||||
throw new HttpsError(
|
throw new HttpsError('failed-precondition', 'playbackUrl, songUrl ou userId manquant')
|
||||||
"failed-precondition",
|
|
||||||
"playbackUrl, songUrl ou userId manquant"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`;
|
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`
|
||||||
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
|
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath })
|
||||||
|
|
||||||
await projectRef.set(
|
await projectRef.set(
|
||||||
{
|
{
|
||||||
@@ -222,13 +208,13 @@ exports.reencodePlayback = onCall(
|
|||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true }
|
||||||
);
|
)
|
||||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
|
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
|
||||||
logger.info("[reencodePlayback] success", {
|
logger.info('[reencodePlayback] success', {
|
||||||
projectId,
|
projectId,
|
||||||
initiator: uid,
|
initiator: uid,
|
||||||
storagePath,
|
storagePath,
|
||||||
});
|
})
|
||||||
return result;
|
return result
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
|||||||
+64
-78
@@ -1,137 +1,123 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
const {
|
const { onDocumentDeleted, onDocumentCreated } = require('firebase-functions/firestore')
|
||||||
onDocumentDeleted,
|
const { refList } = require('../index')
|
||||||
onDocumentCreated,
|
const { ORDER_TYPES, createOrderDocument } = require('./helpers/orders')
|
||||||
} = require("firebase-functions/firestore");
|
const { deleteFolder } = require('../helpers/firebase')
|
||||||
const { refList } = require("../index");
|
const { Resend } = require('resend')
|
||||||
const { ORDER_TYPES, createOrderDocument } = require("./helpers/orders");
|
const { welcomeTemplate } = require('../helpers/email')
|
||||||
const { deleteFolder } = require("../helpers/firebase");
|
const { RESEND_API_KEY } = require('../config/keys')
|
||||||
const { Resend } = require("resend");
|
const { onRequest } = require('firebase-functions/https')
|
||||||
const { welcomeTemplate } = require("../helpers/email");
|
|
||||||
const { RESEND_API_KEY } = require("../config/keys");
|
|
||||||
const { onRequest } = require("firebase-functions/https");
|
|
||||||
|
|
||||||
const resendClient = new Resend(RESEND_API_KEY);
|
const resendClient = new Resend(RESEND_API_KEY)
|
||||||
const WELCOME_EMAIL_FROM =
|
const WELCOME_EMAIL_FROM = process.env.RESEND_FROM_EMAIL || 'MusicLand <musicland@musicland.ai>'
|
||||||
process.env.RESEND_FROM_EMAIL || "MusicLand <musicland@musicland.ai>";
|
const WELCOME_EMAIL_SUBJECT = 'Bienvenue sur MusicLand'
|
||||||
const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
|
|
||||||
|
|
||||||
exports.testWelcomMail = onRequest(async (req, res) => {
|
exports.testWelcomMail = onRequest(async (req, res) => {
|
||||||
if (req.method !== "GET") {
|
if (req.method !== 'GET') {
|
||||||
res.set("Allow", "GET");
|
res.set('Allow', 'GET')
|
||||||
return res
|
return res.status(405).json({ success: false, error: 'Method not allowed' })
|
||||||
.status(405)
|
|
||||||
.json({ success: false, error: "Method not allowed" });
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const targetEmail = req.query.email || "tdtomthomas@gmail.com";
|
const targetEmail = req.query.email || 'tdtomthomas@gmail.com'
|
||||||
const firstName = req.query.firstName || "Toto";
|
const firstName = req.query.firstName || 'Toto'
|
||||||
const lastName = req.query.lastName || "Test";
|
const lastName = req.query.lastName || 'Test'
|
||||||
const { data, error } = await resendClient.emails.send({
|
const { data, error } = await resendClient.emails.send({
|
||||||
from: WELCOME_EMAIL_FROM,
|
from: WELCOME_EMAIL_FROM,
|
||||||
to: [targetEmail],
|
to: [targetEmail],
|
||||||
subject: WELCOME_EMAIL_SUBJECT,
|
subject: WELCOME_EMAIL_SUBJECT,
|
||||||
html: welcomeTemplate({ firstName, lastName }),
|
html: welcomeTemplate({ firstName, lastName }),
|
||||||
});
|
})
|
||||||
if (error) {
|
if (error) {
|
||||||
console.log("Failed to send welcome email:", error);
|
console.log('Failed to send welcome email:', error)
|
||||||
return res
|
return res.status(500).json({ success: false, error: error.message || error.toString() })
|
||||||
.status(500)
|
|
||||||
.json({ success: false, error: error.message || error.toString() });
|
|
||||||
}
|
}
|
||||||
console.log("Welcome email sent:", data);
|
console.log('Welcome email sent:', data)
|
||||||
return res.status(200).json({ success: true, data });
|
return res.status(200).json({ success: true, data })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e)
|
||||||
return res
|
return res.status(500).json({ success: false, error: e.message || e.toString() })
|
||||||
.status(500)
|
|
||||||
.json({ success: false, error: e.message || e.toString() });
|
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
|
exports.onUserCreated = onDocumentCreated('users/{userID}', async (event) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const { email = '', firstName = '', lastName = '' } = event?.data?.data() || {}
|
||||||
email = "",
|
|
||||||
firstName = "",
|
|
||||||
lastName = "",
|
|
||||||
} = event?.data?.data() || {};
|
|
||||||
|
|
||||||
const userId = event?.params?.userID;
|
const userId = event?.params?.userID
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createOrderDocument({
|
await createOrderDocument({
|
||||||
userId,
|
userId,
|
||||||
type: ORDER_TYPES.GIFT,
|
type: ORDER_TYPES.GIFT,
|
||||||
amount: 10,
|
amount: 10,
|
||||||
metadata: { reason: "WELCOME_BONUS" },
|
metadata: { reason: 'WELCOME_BONUS' },
|
||||||
orderId: `welcome_${userId}`,
|
orderId: `welcome_${userId}`,
|
||||||
});
|
})
|
||||||
} catch (coinError) {
|
} catch (coinError) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[users-onUserCreated] Unable to grant welcome coins",
|
'[users-onUserCreated] Unable to grant welcome coins',
|
||||||
event?.params?.userID,
|
event?.params?.userID,
|
||||||
coinError?.message || coinError,
|
coinError?.message || coinError
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
if (email) {
|
if (email) {
|
||||||
if (!resendClient) {
|
if (!resendClient) {
|
||||||
console.warn("Resend API key not configured; skipping welcome email.");
|
console.warn('Resend API key not configured; skipping welcome email.')
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
console.log(`Sending welcome email to ${email}`);
|
console.log(`Sending welcome email to ${email}`)
|
||||||
const { data, error } = await resendClient.emails.send({
|
const { data, error } = await resendClient.emails.send({
|
||||||
from: WELCOME_EMAIL_FROM,
|
from: WELCOME_EMAIL_FROM,
|
||||||
to: [email],
|
to: [email],
|
||||||
subject: WELCOME_EMAIL_SUBJECT,
|
subject: WELCOME_EMAIL_SUBJECT,
|
||||||
html: welcomeTemplate({ firstName, lastName }),
|
html: welcomeTemplate({ firstName, lastName }),
|
||||||
});
|
})
|
||||||
if (error) {
|
if (error) {
|
||||||
console.log("Failed to send welcome email:", error);
|
console.log('Failed to send welcome email:', error)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
console.log("Welcome email sent:", data);
|
console.log('Welcome email sent:', data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Failed to send welcome email:", error);
|
console.log('Failed to send welcome email:', error)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error("User created with empty email");
|
throw new Error('User created with empty email')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
exports.onUserDelete = onDocumentDeleted("users/{userID}", async (event) => {
|
exports.onUserDelete = onDocumentDeleted('users/{userID}', async (event) => {
|
||||||
try {
|
try {
|
||||||
const userID = event?.params?.userID;
|
const userID = event?.params?.userID
|
||||||
await clearAllUserData(userID);
|
await clearAllUserData(userID)
|
||||||
|
|
||||||
await deleteFolder(`users/${userID}/`);
|
await deleteFolder(`users/${userID}/`)
|
||||||
|
|
||||||
await admin.auth().deleteUser(userID);
|
await admin.auth().deleteUser(userID)
|
||||||
console.log(`User ${userID} deleted successfully`);
|
console.log(`User ${userID} deleted successfully`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
async function clearAllUserData(userID) {
|
async function clearAllUserData(userID) {
|
||||||
const deleteAll = async (ref, key, operator = "==") => {
|
const deleteAll = async (ref, key, operator = '==') => {
|
||||||
const snapshot = await ref.where(key, operator, userID).get();
|
const snapshot = await ref.where(key, operator, userID).get()
|
||||||
snapshot.forEach((item) => item.ref.delete());
|
snapshot.forEach((item) => item.ref.delete())
|
||||||
};
|
}
|
||||||
const removeFromArray = async (ref, arrayName) => {
|
const removeFromArray = async (ref, arrayName) => {
|
||||||
const snapshot = await ref.where(arrayName, "array-contains", userID).get();
|
const snapshot = await ref.where(arrayName, 'array-contains', userID).get()
|
||||||
snapshot.forEach((item) =>
|
snapshot.forEach((item) =>
|
||||||
item.ref.update({
|
item.ref.update({
|
||||||
[arrayName]: FieldValue.arrayRemove(userID),
|
[arrayName]: FieldValue.arrayRemove(userID),
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
await deleteAll(refList.projects, "userId");
|
await deleteAll(refList.projects, 'userId')
|
||||||
await deleteAll(refList.playlists, "createdBy");
|
await deleteAll(refList.playlists, 'createdBy')
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-146
@@ -1,34 +1,29 @@
|
|||||||
const { onCall, HttpsError } = require("firebase-functions/v2/https");
|
const { onCall, HttpsError } = require('firebase-functions/v2/https')
|
||||||
const { defineSecret } = require("firebase-functions/params");
|
const { defineSecret } = require('firebase-functions/params')
|
||||||
const logger = require("firebase-functions/logger");
|
const logger = require('firebase-functions/logger')
|
||||||
const functions = require("firebase-functions");
|
const functions = require('firebase-functions')
|
||||||
const admin = require("firebase-admin");
|
const admin = require('firebase-admin')
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
const axios = require("axios");
|
const axios = require('axios')
|
||||||
const fs = require("node:fs");
|
const fs = require('node:fs')
|
||||||
const fsp = require("node:fs/promises");
|
const fsp = require('node:fs/promises')
|
||||||
const os = require("node:os");
|
const os = require('node:os')
|
||||||
const path = require("node:path");
|
const path = require('node:path')
|
||||||
const { google } = require("googleapis");
|
const { google } = require('googleapis')
|
||||||
|
|
||||||
// ---- Secrets déclarés (gen2 + Secret Manager)
|
// ---- Secrets déclarés (gen2 + Secret Manager)
|
||||||
const S_YT_CLIENT_ID = defineSecret("YOUTUBE_CLIENT_ID");
|
const S_YT_CLIENT_ID = defineSecret('YOUTUBE_CLIENT_ID')
|
||||||
const S_YT_CLIENT_SECRET = defineSecret("YOUTUBE_CLIENT_SECRET");
|
const S_YT_CLIENT_SECRET = defineSecret('YOUTUBE_CLIENT_SECRET')
|
||||||
const S_YT_REFRESH_TOKEN = defineSecret("YOUTUBE_REFRESH_TOKEN");
|
const S_YT_REFRESH_TOKEN = defineSecret('YOUTUBE_REFRESH_TOKEN')
|
||||||
const S_YT_REDIRECT_URI = defineSecret("YOUTUBE_REDIRECT_URI");
|
const S_YT_REDIRECT_URI = defineSecret('YOUTUBE_REDIRECT_URI')
|
||||||
const S_YT_PRIVACY_STATUS = defineSecret("YOUTUBE_PRIVACY_STATUS");
|
const S_YT_PRIVACY_STATUS = defineSecret('YOUTUBE_PRIVACY_STATUS')
|
||||||
const S_YT_CATEGORY_ID = defineSecret("YOUTUBE_CATEGORY_ID");
|
const S_YT_CATEGORY_ID = defineSecret('YOUTUBE_CATEGORY_ID')
|
||||||
|
|
||||||
// Firestore
|
// Firestore
|
||||||
const firestore = admin.firestore();
|
const firestore = admin.firestore()
|
||||||
const projectsRef = firestore.collection("projects");
|
const projectsRef = firestore.collection('projects')
|
||||||
|
|
||||||
const YOUTUBE_IN_PROGRESS_STATUSES = [
|
const YOUTUBE_IN_PROGRESS_STATUSES = ['PUBLISHING', 'UPLOADING', 'PROCESSING', 'QUEUED']
|
||||||
"PUBLISHING",
|
|
||||||
"UPLOADING",
|
|
||||||
"PROCESSING",
|
|
||||||
"QUEUED",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Lecture des secrets (recommandé en v2)
|
// Lecture des secrets (recommandé en v2)
|
||||||
const getSecretsYoutubeConfig = () =>
|
const getSecretsYoutubeConfig = () =>
|
||||||
@@ -40,44 +35,44 @@ const getSecretsYoutubeConfig = () =>
|
|||||||
redirect_uri: S_YT_REDIRECT_URI.value(),
|
redirect_uri: S_YT_REDIRECT_URI.value(),
|
||||||
privacy_status: S_YT_PRIVACY_STATUS.value(),
|
privacy_status: S_YT_PRIVACY_STATUS.value(),
|
||||||
category_id: S_YT_CATEGORY_ID.value(),
|
category_id: S_YT_CATEGORY_ID.value(),
|
||||||
}).filter(([, value]) => value !== undefined && value !== "")
|
}).filter(([, value]) => value !== undefined && value !== '')
|
||||||
);
|
)
|
||||||
|
|
||||||
// Compat facultative v1 -> renverra {} en v2 (et on log un warn propre)
|
// Compat facultative v1 -> renverra {} en v2 (et on log un warn propre)
|
||||||
const getLegacyYoutubeConfig = () => {
|
const getLegacyYoutubeConfig = () => {
|
||||||
if (typeof functions.config !== "function") {
|
if (typeof functions.config !== 'function') {
|
||||||
return {};
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return functions.config()?.youtube || {};
|
return functions.config()?.youtube || {}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
typeof error?.message === "string" &&
|
typeof error?.message === 'string' &&
|
||||||
error.message.includes("functions.config() is no longer available")
|
error.message.includes('functions.config() is no longer available')
|
||||||
) {
|
) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)"
|
'[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)'
|
||||||
);
|
)
|
||||||
return {};
|
return {}
|
||||||
}
|
}
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const ensureYoutubeConfig = () => {
|
const ensureYoutubeConfig = () => {
|
||||||
// Fusionne (par prudence) l’ancienne config et les secrets actuels
|
// Fusionne (par prudence) l’ancienne config et les secrets actuels
|
||||||
const firebaseConfig = getLegacyYoutubeConfig();
|
const firebaseConfig = getLegacyYoutubeConfig()
|
||||||
const secretConfig = getSecretsYoutubeConfig();
|
const secretConfig = getSecretsYoutubeConfig()
|
||||||
const cfg = { ...firebaseConfig, ...secretConfig };
|
const cfg = { ...firebaseConfig, ...secretConfig }
|
||||||
|
|
||||||
const requiredKeys = ["client_id", "client_secret", "refresh_token"];
|
const requiredKeys = ['client_id', 'client_secret', 'refresh_token']
|
||||||
const missing = requiredKeys.filter((key) => !cfg[key]);
|
const missing = requiredKeys.filter((key) => !cfg[key])
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"failed-precondition",
|
'failed-precondition',
|
||||||
`Configuration YouTube manquante: ${missing.join(", ")}`
|
`Configuration YouTube manquante: ${missing.join(', ')}`
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -87,63 +82,54 @@ const ensureYoutubeConfig = () => {
|
|||||||
redirectUri: cfg.redirect_uri,
|
redirectUri: cfg.redirect_uri,
|
||||||
defaultPrivacyStatus: cfg.privacy_status,
|
defaultPrivacyStatus: cfg.privacy_status,
|
||||||
defaultCategoryId: cfg.category_id,
|
defaultCategoryId: cfg.category_id,
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const createYoutubeClient = ({
|
const createYoutubeClient = ({ clientId, clientSecret, refreshToken, redirectUri }) => {
|
||||||
clientId,
|
const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri)
|
||||||
clientSecret,
|
oauth2Client.setCredentials({ refresh_token: refreshToken })
|
||||||
refreshToken,
|
|
||||||
redirectUri,
|
|
||||||
}) => {
|
|
||||||
const oauth2Client = new google.auth.OAuth2(
|
|
||||||
clientId,
|
|
||||||
clientSecret,
|
|
||||||
redirectUri
|
|
||||||
);
|
|
||||||
oauth2Client.setCredentials({ refresh_token: refreshToken });
|
|
||||||
const youtube = google.youtube({
|
const youtube = google.youtube({
|
||||||
version: "v3",
|
version: 'v3',
|
||||||
auth: oauth2Client,
|
auth: oauth2Client,
|
||||||
});
|
})
|
||||||
return { youtube, oauth2Client };
|
return { youtube, oauth2Client }
|
||||||
};
|
}
|
||||||
|
|
||||||
const downloadFile = async (url, destinationPath) => {
|
const downloadFile = async (url, destinationPath) => {
|
||||||
if (!/^https?:\/\//i.test(url || "")) {
|
if (!/^https?:\/\//i.test(url || '')) {
|
||||||
throw new HttpsError("invalid-argument", `URL non valide: ${url}`);
|
throw new HttpsError('invalid-argument', `URL non valide: ${url}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await fsp.mkdir(path.dirname(destinationPath), { recursive: true });
|
await fsp.mkdir(path.dirname(destinationPath), { recursive: true })
|
||||||
|
|
||||||
const response = await axios.get(url, { responseType: "stream" });
|
const response = await axios.get(url, { responseType: 'stream' })
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const writer = fs.createWriteStream(destinationPath);
|
const writer = fs.createWriteStream(destinationPath)
|
||||||
response.data.pipe(writer);
|
response.data.pipe(writer)
|
||||||
writer.on("finish", resolve);
|
writer.on('finish', resolve)
|
||||||
writer.on("error", reject);
|
writer.on('error', reject)
|
||||||
});
|
})
|
||||||
|
|
||||||
return destinationPath;
|
return destinationPath
|
||||||
};
|
}
|
||||||
|
|
||||||
const buildVideoMetadata = (project, defaults) => {
|
const buildVideoMetadata = (project, defaults) => {
|
||||||
const baseTitle = project?.title || "Création MusicLand";
|
const baseTitle = project?.title || 'Création MusicLand'
|
||||||
const youtubeTitle = `${baseTitle} | MusicLand`;
|
const youtubeTitle = `${baseTitle} | MusicLand`
|
||||||
const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`;
|
const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`
|
||||||
const tags = Array.isArray(project?.youtubeTags)
|
const tags = Array.isArray(project?.youtubeTags)
|
||||||
? project.youtubeTags.filter(Boolean).slice(0, 500)
|
? project.youtubeTags.filter(Boolean).slice(0, 500)
|
||||||
: undefined;
|
: undefined
|
||||||
|
|
||||||
const snippet = {
|
const snippet = {
|
||||||
title: youtubeTitle,
|
title: youtubeTitle,
|
||||||
description,
|
description,
|
||||||
categoryId: defaults.defaultCategoryId,
|
categoryId: defaults.defaultCategoryId,
|
||||||
};
|
}
|
||||||
|
|
||||||
if (tags && tags.length) {
|
if (tags && tags.length) {
|
||||||
snippet.tags = tags;
|
snippet.tags = tags
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -153,17 +139,17 @@ const buildVideoMetadata = (project, defaults) => {
|
|||||||
embeddable: true,
|
embeddable: true,
|
||||||
selfDeclaredMadeForKids: false,
|
selfDeclaredMadeForKids: false,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
exports.publishPlaybackToYoutube = onCall(
|
exports.publishPlaybackToYoutube = onCall(
|
||||||
{
|
{
|
||||||
timeoutSeconds: 540,
|
timeoutSeconds: 540,
|
||||||
memory: "1GiB",
|
memory: '1GiB',
|
||||||
cors: [
|
cors: [
|
||||||
"http://localhost:8081",
|
'http://localhost:8081',
|
||||||
"https://musicland-one.vercel.app/",
|
'https://musicland-one.vercel.app/',
|
||||||
"https://musicland-d33f9.firebaseapp.com",
|
'https://musicland-d33f9.firebaseapp.com',
|
||||||
],
|
],
|
||||||
// Secrets requis pour l’exécution (v2)
|
// Secrets requis pour l’exécution (v2)
|
||||||
secrets: [
|
secrets: [
|
||||||
@@ -176,98 +162,86 @@ exports.publishPlaybackToYoutube = onCall(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
async ({ data = {}, auth }) => {
|
async ({ data = {}, auth }) => {
|
||||||
const uid = auth?.uid;
|
const uid = auth?.uid
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
throw new HttpsError("unauthenticated", "Authentification requise");
|
throw new HttpsError('unauthenticated', 'Authentification requise')
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectId = data?.projectId;
|
const projectId = data?.projectId
|
||||||
if (!projectId || typeof projectId !== "string") {
|
if (!projectId || typeof projectId !== 'string') {
|
||||||
throw new HttpsError("invalid-argument", "Paramètre projectId requis");
|
throw new HttpsError('invalid-argument', 'Paramètre projectId requis')
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectSnap = await projectsRef.doc(projectId).get();
|
const projectSnap = await projectsRef.doc(projectId).get()
|
||||||
if (!projectSnap.exists) {
|
if (!projectSnap.exists) {
|
||||||
throw new HttpsError("not-found", "Projet introuvable");
|
throw new HttpsError('not-found', 'Projet introuvable')
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = projectSnap.data();
|
const project = projectSnap.data()
|
||||||
if (!project || project.userId !== uid) {
|
if (!project || project.userId !== uid) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('permission-denied', "Vous n'avez pas les droits sur ce projet")
|
||||||
"permission-denied",
|
|
||||||
"Vous n'avez pas les droits sur ce projet"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!project.playbackUrl) {
|
if (!project.playbackUrl) {
|
||||||
throw new HttpsError(
|
throw new HttpsError('failed-precondition', 'Aucun playback disponible pour la publication')
|
||||||
"failed-precondition",
|
|
||||||
"Aucun playback disponible pour la publication"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (project.youtubeStatus && YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)) {
|
||||||
project.youtubeStatus &&
|
|
||||||
YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)
|
|
||||||
) {
|
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"failed-precondition",
|
'failed-precondition',
|
||||||
"Une publication est déjà en cours pour ce projet"
|
'Une publication est déjà en cours pour ce projet'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let youtubeDefaults;
|
let youtubeDefaults
|
||||||
let youtubeClient;
|
let youtubeClient
|
||||||
try {
|
try {
|
||||||
youtubeDefaults = ensureYoutubeConfig();
|
youtubeDefaults = ensureYoutubeConfig()
|
||||||
youtubeClient = createYoutubeClient(youtubeDefaults);
|
youtubeClient = createYoutubeClient(youtubeDefaults)
|
||||||
await youtubeClient.oauth2Client.getAccessToken();
|
await youtubeClient.oauth2Client.getAccessToken()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("[publishPlaybackToYoutube] configuration invalide", {
|
logger.error('[publishPlaybackToYoutube] configuration invalide', {
|
||||||
error: error?.message,
|
error: error?.message,
|
||||||
});
|
})
|
||||||
throw new HttpsError(
|
throw new HttpsError('failed-precondition', 'Configuration YouTube invalide ou incomplète')
|
||||||
"failed-precondition",
|
|
||||||
"Configuration YouTube invalide ou incomplète"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "yt-upload-"));
|
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'yt-upload-'))
|
||||||
const videoPath = path.join(tmpDir, "playback.mp4");
|
const videoPath = path.join(tmpDir, 'playback.mp4')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await projectsRef.doc(projectId).set(
|
await projectsRef.doc(projectId).set(
|
||||||
{
|
{
|
||||||
youtubeStatus: "PUBLISHING",
|
youtubeStatus: 'PUBLISHING',
|
||||||
youtubePublished: false,
|
youtubePublished: false,
|
||||||
youtubeError: null,
|
youtubeError: null,
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true }
|
||||||
);
|
)
|
||||||
|
|
||||||
await downloadFile(project.playbackUrl, videoPath);
|
await downloadFile(project.playbackUrl, videoPath)
|
||||||
|
|
||||||
const metadata = buildVideoMetadata(project, youtubeDefaults);
|
const metadata = buildVideoMetadata(project, youtubeDefaults)
|
||||||
|
|
||||||
const uploadResponse = await youtubeClient.youtube.videos.insert({
|
const uploadResponse = await youtubeClient.youtube.videos.insert({
|
||||||
part: ["snippet", "status"].join(","),
|
part: ['snippet', 'status'].join(','),
|
||||||
requestBody: metadata,
|
requestBody: metadata,
|
||||||
media: {
|
media: {
|
||||||
body: fs.createReadStream(videoPath),
|
body: fs.createReadStream(videoPath),
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const videoId = uploadResponse?.data?.id;
|
const videoId = uploadResponse?.data?.id
|
||||||
if (!videoId) {
|
if (!videoId) {
|
||||||
throw new Error("ID de vidéo introuvable dans la réponse YouTube");
|
throw new Error('ID de vidéo introuvable dans la réponse YouTube')
|
||||||
}
|
}
|
||||||
|
|
||||||
const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`;
|
const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`
|
||||||
|
|
||||||
await projectsRef.doc(projectId).set(
|
await projectsRef.doc(projectId).set(
|
||||||
{
|
{
|
||||||
youtubeStatus: "PUBLISHED",
|
youtubeStatus: 'PUBLISHED',
|
||||||
youtubePublished: true,
|
youtubePublished: true,
|
||||||
youtubeUrl: youtubeLink,
|
youtubeUrl: youtubeLink,
|
||||||
youtubeVideoId: videoId,
|
youtubeVideoId: videoId,
|
||||||
@@ -276,45 +250,45 @@ exports.publishPlaybackToYoutube = onCall(
|
|||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true }
|
||||||
);
|
)
|
||||||
|
|
||||||
logger.info("[publishPlaybackToYoutube] publication réussie", {
|
logger.info('[publishPlaybackToYoutube] publication réussie', {
|
||||||
projectId,
|
projectId,
|
||||||
videoId,
|
videoId,
|
||||||
});
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
videoId,
|
videoId,
|
||||||
youtubeUrl: youtubeLink,
|
youtubeUrl: youtubeLink,
|
||||||
};
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("[publishPlaybackToYoutube] échec de publication", {
|
logger.error('[publishPlaybackToYoutube] échec de publication', {
|
||||||
projectId,
|
projectId,
|
||||||
error: error?.message,
|
error: error?.message,
|
||||||
});
|
})
|
||||||
|
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof HttpsError
|
error instanceof HttpsError
|
||||||
? error.message
|
? error.message
|
||||||
: error?.message || "Publication YouTube échouée";
|
: error?.message || 'Publication YouTube échouée'
|
||||||
|
|
||||||
await projectsRef.doc(projectId).set(
|
await projectsRef.doc(projectId).set(
|
||||||
{
|
{
|
||||||
youtubeStatus: "FAILED",
|
youtubeStatus: 'FAILED',
|
||||||
youtubePublished: false,
|
youtubePublished: false,
|
||||||
youtubeError: errorMessage,
|
youtubeError: errorMessage,
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true }
|
||||||
);
|
)
|
||||||
|
|
||||||
if (error instanceof HttpsError) {
|
if (error instanceof HttpsError) {
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new HttpsError("internal", errorMessage);
|
throw new HttpsError('internal', errorMessage)
|
||||||
} finally {
|
} finally {
|
||||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
await fsp.rm(tmpDir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { registerRootComponent } from "expo";
|
import { registerRootComponent } from 'expo'
|
||||||
import App from "./App";
|
import App from './App'
|
||||||
|
|
||||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||||
@@ -8,10 +8,10 @@ import App from "./App";
|
|||||||
function HeadlessCheck({ isHeadless }) {
|
function HeadlessCheck({ isHeadless }) {
|
||||||
if (isHeadless) {
|
if (isHeadless) {
|
||||||
// App has been launched in the background by iOS, ignore
|
// App has been launched in the background by iOS, ignore
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return <App />;
|
return <App />
|
||||||
}
|
}
|
||||||
|
|
||||||
registerRootComponent(App, () => HeadlessCheck);
|
registerRootComponent(App, () => HeadlessCheck)
|
||||||
|
|||||||
+19
-19
@@ -1,37 +1,37 @@
|
|||||||
import "@expo/metro-runtime";
|
import '@expo/metro-runtime'
|
||||||
import { registerRootComponent } from "expo";
|
import { registerRootComponent } from 'expo'
|
||||||
|
|
||||||
import App from "./App";
|
import App from './App'
|
||||||
|
|
||||||
// Inject minimal global CSS and a small setNativeProps polyfill for web
|
// Inject minimal global CSS and a small setNativeProps polyfill for web
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== 'undefined') {
|
||||||
document.documentElement?.setAttribute("translate", "no");
|
document.documentElement?.setAttribute('translate', 'no')
|
||||||
document.body?.setAttribute("translate", "no");
|
document.body?.setAttribute('translate', 'no')
|
||||||
|
|
||||||
const style = document.createElement("style");
|
const style = document.createElement('style')
|
||||||
style.setAttribute("data-inline-global", "true");
|
style.setAttribute('data-inline-global', 'true')
|
||||||
style.innerHTML = `
|
style.innerHTML = `
|
||||||
html, body, #root { height: 100%; }
|
html, body, #root { height: 100%; }
|
||||||
body { margin: 0; }
|
body { margin: 0; }
|
||||||
*:focus { outline: none; }
|
*:focus { outline: none; }
|
||||||
* { scrollbar-width: none; -ms-overflow-style: none; }
|
* { scrollbar-width: none; -ms-overflow-style: none; }
|
||||||
*::-webkit-scrollbar { display: none; }
|
*::-webkit-scrollbar { display: none; }
|
||||||
`;
|
`
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style)
|
||||||
|
|
||||||
const proto = window.HTMLElement && window.HTMLElement.prototype;
|
const proto = window.HTMLElement && window.HTMLElement.prototype
|
||||||
if (proto && typeof proto.setNativeProps !== "function") {
|
if (proto && typeof proto.setNativeProps !== 'function') {
|
||||||
proto.setNativeProps = function (nativeProps = {}) {
|
proto.setNativeProps = function (nativeProps = {}) {
|
||||||
try {
|
try {
|
||||||
const { style: s, pointerEvents, ...rest } = nativeProps || {};
|
const { style: s, pointerEvents, ...rest } = nativeProps || {}
|
||||||
if (pointerEvents != null) {
|
if (pointerEvents != null) {
|
||||||
this.style.pointerEvents = pointerEvents;
|
this.style.pointerEvents = pointerEvents
|
||||||
}
|
}
|
||||||
if (s && typeof s === "object") {
|
if (s && typeof s === 'object') {
|
||||||
for (const k in s) {
|
for (const k in s) {
|
||||||
if (Object.prototype.hasOwnProperty.call(s, k)) {
|
if (Object.prototype.hasOwnProperty.call(s, k)) {
|
||||||
try {
|
try {
|
||||||
this.style[k] = s[k];
|
this.style[k] = s[k]
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,13 +39,13 @@ if (typeof document !== "undefined") {
|
|||||||
for (const k in rest) {
|
for (const k in rest) {
|
||||||
if (Object.prototype.hasOwnProperty.call(rest, k)) {
|
if (Object.prototype.hasOwnProperty.call(rest, k)) {
|
||||||
try {
|
try {
|
||||||
this.style[k] = rest[k];
|
this.style[k] = rest[k]
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerRootComponent(App);
|
registerRootComponent(App)
|
||||||
|
|||||||
+8
-8
@@ -1,18 +1,18 @@
|
|||||||
const { getDefaultConfig } = require("@expo/metro-config");
|
const { getDefaultConfig } = require('@expo/metro-config')
|
||||||
|
|
||||||
const config = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname)
|
||||||
|
|
||||||
// Vérifier que .js est bien présent
|
// Vérifier que .js est bien présent
|
||||||
if (!config.resolver.sourceExts.includes("js")) {
|
if (!config.resolver.sourceExts.includes('js')) {
|
||||||
config.resolver.sourceExts.push("js");
|
config.resolver.sourceExts.push('js')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajouter ce dont tu as besoin
|
// Ajouter ce dont tu as besoin
|
||||||
config.resolver.sourceExts.push("cjs", "mjs");
|
config.resolver.sourceExts.push('cjs', 'mjs')
|
||||||
|
|
||||||
// Assurer que la plateforme web est bien prise en compte en priorité
|
// Assurer que la plateforme web est bien prise en compte en priorité
|
||||||
config.resolver.platforms = Array.from(
|
config.resolver.platforms = Array.from(
|
||||||
new Set(["web", ...((config.resolver && config.resolver.platforms) || [])])
|
new Set(['web', ...((config.resolver && config.resolver.platforms) || [])])
|
||||||
);
|
)
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config
|
||||||
|
|||||||
+2
-1
@@ -5,6 +5,7 @@
|
|||||||
"start": "expo start --dev-client",
|
"start": "expo start --dev-client",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios -d",
|
"ios": "expo run:ios -d",
|
||||||
|
"format": "prettier --write \"src/**/*.js\"",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"xcode": "open ios/musicland.xcworkspace",
|
"xcode": "open ios/musicland.xcworkspace",
|
||||||
"clean": "npx expo prebuild --clean",
|
"clean": "npx expo prebuild --clean",
|
||||||
@@ -111,7 +112,7 @@
|
|||||||
"imagemin-jpegtran": "~7.0.0",
|
"imagemin-jpegtran": "~7.0.0",
|
||||||
"imagemin-optipng": "~8.0.0",
|
"imagemin-optipng": "~8.0.0",
|
||||||
"imagemin-svgo": "~11.0.1",
|
"imagemin-svgo": "~11.0.1",
|
||||||
"prettier": "~3.2.5",
|
"prettier": "^3.7.4",
|
||||||
"typescript": "~5.3.3"
|
"typescript": "~5.3.3"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import moment from 'moment';
|
import moment from 'moment'
|
||||||
|
|
||||||
export const validateDate = (date = null) => {
|
export const validateDate = (date = null) => {
|
||||||
if (!date) {
|
if (!date) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
if (moment(date).isValid()) {
|
if (moment(date).isValid()) {
|
||||||
return date;
|
return date
|
||||||
}
|
}
|
||||||
if (typeof date?.toDate === 'function') {
|
if (typeof date?.toDate === 'function') {
|
||||||
return date?.toDate();
|
return date?.toDate()
|
||||||
}
|
}
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|
||||||
export function getAge(birthDate) {
|
export function getAge(birthDate) {
|
||||||
return moment().diff(birthDate.toDate(), 'years', false);
|
return moment().diff(birthDate.toDate(), 'years', false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
responsiveHeight as _responsiveHeight,
|
responsiveHeight as _responsiveHeight,
|
||||||
responsiveWidth as _responsiveWidth,
|
responsiveWidth as _responsiveWidth,
|
||||||
responsiveFontSize as _responsiveFontSize,
|
responsiveFontSize as _responsiveFontSize,
|
||||||
} from "react-native-responsive-dimensions";
|
} from 'react-native-responsive-dimensions'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isWeb,
|
isWeb,
|
||||||
@@ -10,31 +10,31 @@ import {
|
|||||||
isLargeDesktop,
|
isLargeDesktop,
|
||||||
isSmallDesktop,
|
isSmallDesktop,
|
||||||
isSuperSmallDesktop,
|
isSuperSmallDesktop,
|
||||||
} from "../hooks/useLayoutType";
|
} from '../hooks/useLayoutType'
|
||||||
|
|
||||||
export const reductionCoeff = (bypass) =>
|
export const reductionCoeff = (bypass) =>
|
||||||
bypass ? 1 : isLargeDesktop ? 3 : isSmallDesktop ? 2.2 : isDesktop ? 2.8 : 1;
|
bypass ? 1 : isLargeDesktop ? 3 : isSmallDesktop ? 2.2 : isDesktop ? 2.8 : 1
|
||||||
|
|
||||||
export const responsiveHeight = (height, bypass = false) => {
|
export const responsiveHeight = (height, bypass = false) => {
|
||||||
return _responsiveHeight(height) / reductionCoeff(bypass);
|
return _responsiveHeight(height) / reductionCoeff(bypass)
|
||||||
};
|
}
|
||||||
|
|
||||||
export const responsiveWidth = (width, bypass = false) => {
|
export const responsiveWidth = (width, bypass = false) => {
|
||||||
return _responsiveWidth(width) / reductionCoeff(bypass);
|
return _responsiveWidth(width) / reductionCoeff(bypass)
|
||||||
};
|
}
|
||||||
|
|
||||||
export const responsiveFontSize = (fontSize, bypass = false) => {
|
export const responsiveFontSize = (fontSize, bypass = false) => {
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
if (isSuperSmallDesktop) {
|
if (isSuperSmallDesktop) {
|
||||||
return fontSize * 6;
|
return fontSize * 6
|
||||||
} else if (isSmallDesktop) {
|
} else if (isSmallDesktop) {
|
||||||
return fontSize * 7;
|
return fontSize * 7
|
||||||
} else {
|
} else {
|
||||||
return fontSize * 7.5;
|
return fontSize * 7.5
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return _responsiveFontSize(fontSize) / reductionCoeff(bypass);
|
return _responsiveFontSize(fontSize) / reductionCoeff(bypass)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// test
|
// test
|
||||||
|
|||||||
@@ -1,86 +1,82 @@
|
|||||||
export function checkIfEmailIsValid({ email }) {
|
export function checkIfEmailIsValid({ email }) {
|
||||||
let regex = new RegExp(
|
let regex = new RegExp(
|
||||||
"([!#-'*+/-9=?A-Z^-~-]+(.[!#-'*+/-9=?A-Z^-~-]+)*|\"([]!#-[^-~ \t]|(\\[\t -~]))+\")@([!#-'*+/-9=?A-Z^-~-]+(.[!#-'*+/-9=?A-Z^-~-]+)*|[[\t -Z^-~]*])"
|
"([!#-'*+/-9=?A-Z^-~-]+(.[!#-'*+/-9=?A-Z^-~-]+)*|\"([]!#-[^-~ \t]|(\\[\t -~]))+\")@([!#-'*+/-9=?A-Z^-~-]+(.[!#-'*+/-9=?A-Z^-~-]+)*|[[\t -Z^-~]*])"
|
||||||
);
|
)
|
||||||
return regex.test(email);
|
return regex.test(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkIfPasswordIsStrongEnough({ password }) {
|
export function checkIfPasswordIsStrongEnough({ password }) {
|
||||||
const reg =
|
const reg = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{6,})/
|
||||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{6,})/;
|
return reg.test(password)
|
||||||
return reg.test(password);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatPhoneNumber = ({ phoneNumber = null }) => {
|
export const formatPhoneNumber = ({ phoneNumber = null }) => {
|
||||||
if (!phoneNumber) {
|
if (!phoneNumber) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
let newPhoneNumber = phoneNumber;
|
let newPhoneNumber = phoneNumber
|
||||||
|
|
||||||
newPhoneNumber = newPhoneNumber
|
newPhoneNumber = newPhoneNumber
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, "") // remove spaces
|
.replace(/\s+/g, '') // remove spaces
|
||||||
.replace(/\D/g, ""); // remove non digits
|
.replace(/\D/g, '') // remove non digits
|
||||||
|
|
||||||
if (newPhoneNumber.startsWith("330")) {
|
if (newPhoneNumber.startsWith('330')) {
|
||||||
newPhoneNumber = newPhoneNumber.substring(2);
|
newPhoneNumber = newPhoneNumber.substring(2)
|
||||||
}
|
}
|
||||||
if (newPhoneNumber.startsWith("06") || newPhoneNumber.startsWith("07")) {
|
if (newPhoneNumber.startsWith('06') || newPhoneNumber.startsWith('07')) {
|
||||||
newPhoneNumber = `+33${newPhoneNumber.slice(1)}`;
|
newPhoneNumber = `+33${newPhoneNumber.slice(1)}`
|
||||||
} else if (
|
} else if (newPhoneNumber.startsWith('336') || newPhoneNumber.startsWith('337')) {
|
||||||
newPhoneNumber.startsWith("336") ||
|
newPhoneNumber = `+${newPhoneNumber}`
|
||||||
newPhoneNumber.startsWith("337")
|
|
||||||
) {
|
|
||||||
newPhoneNumber = `+${newPhoneNumber}`;
|
|
||||||
} else {
|
} else {
|
||||||
newPhoneNumber = null;
|
newPhoneNumber = null
|
||||||
}
|
}
|
||||||
return newPhoneNumber;
|
return newPhoneNumber
|
||||||
};
|
}
|
||||||
|
|
||||||
export function handleFirebaseError(code = "") {
|
export function handleFirebaseError(code = '') {
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case "auth/user-not-found":
|
case 'auth/user-not-found':
|
||||||
return "Ce compte n'existe pas.";
|
return "Ce compte n'existe pas."
|
||||||
case "auth/user-disabled":
|
case 'auth/user-disabled':
|
||||||
return "Ce compte est désactivé. Contacte le support si besoin.";
|
return 'Ce compte est désactivé. Contacte le support si besoin.'
|
||||||
case "auth/invalid-verification-code":
|
case 'auth/invalid-verification-code':
|
||||||
return "Ton code de validation est incorrect.";
|
return 'Ton code de validation est incorrect.'
|
||||||
case "auth/provider-already-linked":
|
case 'auth/provider-already-linked':
|
||||||
return "Ce compte est déjà lié à un utilisateur.";
|
return 'Ce compte est déjà lié à un utilisateur.'
|
||||||
case "auth/invalid-credential":
|
case 'auth/invalid-credential':
|
||||||
case "auth/invalid-login-credential":
|
case 'auth/invalid-login-credential':
|
||||||
case "auth/invalid-login-credentials":
|
case 'auth/invalid-login-credentials':
|
||||||
return "Identifiants incorrects.";
|
return 'Identifiants incorrects.'
|
||||||
case "auth/credential-already-in-use":
|
case 'auth/credential-already-in-use':
|
||||||
return "Ce compte existe déjà ou est déjà lié.";
|
return 'Ce compte existe déjà ou est déjà lié.'
|
||||||
case "auth/operation-not-allowed":
|
case 'auth/operation-not-allowed':
|
||||||
return "Le fournisseur d'identité n'est pas disponible.";
|
return "Le fournisseur d'identité n'est pas disponible."
|
||||||
case "auth/invalid-email":
|
case 'auth/invalid-email':
|
||||||
return "Adresse e-mail invalide.";
|
return 'Adresse e-mail invalide.'
|
||||||
case "auth/wrong-password":
|
case 'auth/wrong-password':
|
||||||
return "Mot de passe incorrect.";
|
return 'Mot de passe incorrect.'
|
||||||
case "auth/invalid-verification-id":
|
case 'auth/invalid-verification-id':
|
||||||
return "Impossible de t'authentifier, réessaie dans quelques secondes.";
|
return "Impossible de t'authentifier, réessaie dans quelques secondes."
|
||||||
case "auth/invalid-phone-number":
|
case 'auth/invalid-phone-number':
|
||||||
return "Numéro de téléphone incorrect.";
|
return 'Numéro de téléphone incorrect.'
|
||||||
case "auth/too-many-requests":
|
case 'auth/too-many-requests':
|
||||||
return "Trop de tentatives. Réessaie dans quelques minutes.";
|
return 'Trop de tentatives. Réessaie dans quelques minutes.'
|
||||||
case "auth/email-already-in-use":
|
case 'auth/email-already-in-use':
|
||||||
return "Un compte avec cette adresse mail existe déjà.";
|
return 'Un compte avec cette adresse mail existe déjà.'
|
||||||
case "auth/missing-password":
|
case 'auth/missing-password':
|
||||||
return "Renseigne ton mot de passe.";
|
return 'Renseigne ton mot de passe.'
|
||||||
case "auth/weak-password":
|
case 'auth/weak-password':
|
||||||
return "Ton mot de passe est trop faible.";
|
return 'Ton mot de passe est trop faible.'
|
||||||
case "auth/network-request-failed":
|
case 'auth/network-request-failed':
|
||||||
return "Problème de connexion réseau. Vérifie ta connexion et réessaie.";
|
return 'Problème de connexion réseau. Vérifie ta connexion et réessaie.'
|
||||||
case "auth/invalid-action-code":
|
case 'auth/invalid-action-code':
|
||||||
case "auth/expired-action-code":
|
case 'auth/expired-action-code':
|
||||||
case "auth/missing-oob-code":
|
case 'auth/missing-oob-code':
|
||||||
return "Ce lien de réinitialisation n'est plus valide. Demande un nouveau mot de passe.";
|
return "Ce lien de réinitialisation n'est plus valide. Demande un nouveau mot de passe."
|
||||||
case "auth/missing-email":
|
case 'auth/missing-email':
|
||||||
return "Renseigne ton adresse e-mail.";
|
return 'Renseigne ton adresse e-mail.'
|
||||||
default:
|
default:
|
||||||
return "Une erreur est survenue. Réessaie dans quelques instants.";
|
return 'Une erreur est survenue. Réessaie dans quelques instants.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react'
|
||||||
import Svg, { Circle, Path } from "react-native-svg";
|
import Svg, { Circle, Path } from 'react-native-svg'
|
||||||
|
|
||||||
function EyeSVG(props) {
|
function EyeSVG(props) {
|
||||||
return (
|
return (
|
||||||
@@ -19,7 +19,7 @@ function EyeSVG(props) {
|
|||||||
<Path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
<Path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||||
<Circle cx={12} cy={12} r={3} />
|
<Circle cx={12} cy={12} r={3} />
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EyeSVG;
|
export default EyeSVG
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react'
|
||||||
import Svg, { Path } from "react-native-svg";
|
import Svg, { Path } from 'react-native-svg'
|
||||||
|
|
||||||
function EyeSlashSVG(props) {
|
function EyeSlashSVG(props) {
|
||||||
return (
|
return (
|
||||||
@@ -21,7 +21,7 @@ function EyeSlashSVG(props) {
|
|||||||
<Path d="M12 9a3 3 0 013 3" />
|
<Path d="M12 9a3 3 0 013 3" />
|
||||||
<Path d="M9.88 9.88A3 3 0 0012 15a3 3 0 002.12-.88" />
|
<Path d="M9.88 9.88A3 3 0 0012 15a3 3 0 002.12-.88" />
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EyeSlashSVG;
|
export default EyeSlashSVG
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react'
|
||||||
import { View } from "react-native";
|
import { View } from 'react-native'
|
||||||
import Svg, { ClipPath, Defs, G, Path, Rect } from "react-native-svg";
|
import Svg, { ClipPath, Defs, G, Path, Rect } from 'react-native-svg'
|
||||||
|
|
||||||
const RestartSpinnerIcon = ({
|
const RestartSpinnerIcon = ({
|
||||||
size = 30,
|
size = 30,
|
||||||
color = "#F94697",
|
color = '#F94697',
|
||||||
background = "transparent",
|
background = 'transparent',
|
||||||
style,
|
style,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
@@ -15,8 +15,8 @@ const RestartSpinnerIcon = ({
|
|||||||
width: size + 8,
|
width: size + 8,
|
||||||
height: size + 8,
|
height: size + 8,
|
||||||
borderRadius: size,
|
borderRadius: size,
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
backgroundColor: background,
|
backgroundColor: background,
|
||||||
},
|
},
|
||||||
style,
|
style,
|
||||||
@@ -55,7 +55,7 @@ const RestartSpinnerIcon = ({
|
|||||||
</Defs>
|
</Defs>
|
||||||
</Svg>
|
</Svg>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default RestartSpinnerIcon;
|
export default RestartSpinnerIcon
|
||||||
|
|||||||
+125
-127
@@ -1,110 +1,110 @@
|
|||||||
import addTab from "./UI/tabs/add.png";
|
import addTab from './UI/tabs/add.png'
|
||||||
import albums from "./UI/tabs/albums.png";
|
import albums from './UI/tabs/albums.png'
|
||||||
import chat from "./UI/tabs/chat.png";
|
import chat from './UI/tabs/chat.png'
|
||||||
import design from "./UI/tabs/design.png";
|
import design from './UI/tabs/design.png'
|
||||||
import home from "./UI/tabs/home.png";
|
import home from './UI/tabs/home.png'
|
||||||
import mic from "./UI/tabs/mic.png";
|
import mic from './UI/tabs/mic.png'
|
||||||
import person from "./UI/tabs/person.png";
|
import person from './UI/tabs/person.png'
|
||||||
import quotes from "./UI/tabs/quotes.png";
|
import quotes from './UI/tabs/quotes.png'
|
||||||
import ribbon from "./UI/tabs/ribbon.png";
|
import ribbon from './UI/tabs/ribbon.png'
|
||||||
import settings from "./UI/tabs/settings.png";
|
import settings from './UI/tabs/settings.png'
|
||||||
import tasks from "./UI/tabs/tasks.png";
|
import tasks from './UI/tabs/tasks.png'
|
||||||
|
|
||||||
import bell from "./UI/bell.png";
|
import bell from './UI/bell.png'
|
||||||
import sort from "./UI/sort.png";
|
import sort from './UI/sort.png'
|
||||||
|
|
||||||
import arrowRight from "./UI/arrowRight.png";
|
import arrowRight from './UI/arrowRight.png'
|
||||||
import chevronDown from "./UI/chevronDown.png";
|
import chevronDown from './UI/chevronDown.png'
|
||||||
import threeDots from "./UI/threeDots.png";
|
import threeDots from './UI/threeDots.png'
|
||||||
|
|
||||||
import thumbDown from "./UI/thumbDown.png";
|
import thumbDown from './UI/thumbDown.png'
|
||||||
import thumbUp from "./UI/thumbUp.png";
|
import thumbUp from './UI/thumbUp.png'
|
||||||
|
|
||||||
import add from "./UI/add.png";
|
import add from './UI/add.png'
|
||||||
import addFile from "./UI/addFile.png";
|
import addFile from './UI/addFile.png'
|
||||||
import check from "./UI/check.png";
|
import check from './UI/check.png'
|
||||||
import checkCircle from "./UI/checkCircle.png";
|
import checkCircle from './UI/checkCircle.png'
|
||||||
import deliveryTime from "./UI/deliveryTime.png";
|
import deliveryTime from './UI/deliveryTime.png'
|
||||||
import edit from "./UI/edit.png";
|
import edit from './UI/edit.png'
|
||||||
import eye from "./UI/eye.png";
|
import eye from './UI/eye.png'
|
||||||
import lock from "./UI/lock.png";
|
import lock from './UI/lock.png'
|
||||||
import message from "./UI/message.png";
|
import message from './UI/message.png'
|
||||||
import search from "./UI/search.png";
|
import search from './UI/search.png'
|
||||||
import send from "./UI/send.png";
|
import send from './UI/send.png'
|
||||||
import shoppingBag from "./UI/shoppingBag.png";
|
import shoppingBag from './UI/shoppingBag.png'
|
||||||
import tools from "./UI/tools.png";
|
import tools from './UI/tools.png'
|
||||||
import trash from "./UI/trash.png";
|
import trash from './UI/trash.png'
|
||||||
import undo from "./UI/undo.png";
|
import undo from './UI/undo.png'
|
||||||
|
|
||||||
import focus from "./UI/focus.png";
|
import focus from './UI/focus.png'
|
||||||
import picture from "./UI/picture.png";
|
import picture from './UI/picture.png'
|
||||||
import screenshot from "./UI/screenshot.png";
|
import screenshot from './UI/screenshot.png'
|
||||||
|
|
||||||
import android from "./UI/android.png";
|
import android from './UI/android.png'
|
||||||
import ios from "./UI/ios.png";
|
import ios from './UI/ios.png'
|
||||||
import web from "./UI/web.png";
|
import web from './UI/web.png'
|
||||||
|
|
||||||
import law from "./UI/law.png";
|
import law from './UI/law.png'
|
||||||
import support from "./UI/support.png";
|
import support from './UI/support.png'
|
||||||
import user from "./UI/user.png";
|
import user from './UI/user.png'
|
||||||
|
|
||||||
import attachment from "./UI/attachment.png";
|
import attachment from './UI/attachment.png'
|
||||||
|
|
||||||
import cloud from "./icons/cloud.png";
|
import cloud from './icons/cloud.png'
|
||||||
import dashboard from "./icons/dashboard.png";
|
import dashboard from './icons/dashboard.png'
|
||||||
import file from "./icons/file.png";
|
import file from './icons/file.png'
|
||||||
|
|
||||||
import algolia from "./icons/algolia.png";
|
import algolia from './icons/algolia.png'
|
||||||
import calendar from "./icons/calendar.png";
|
import calendar from './icons/calendar.png'
|
||||||
import chatBubble from "./icons/chatBubble.png";
|
import chatBubble from './icons/chatBubble.png'
|
||||||
import close from "./icons/close.png";
|
import close from './icons/close.png'
|
||||||
import coin from "./icons/coin.png";
|
import coin from './icons/coin.png'
|
||||||
import disk from "./icons/disk.png";
|
import disk from './icons/disk.png'
|
||||||
import figma from "./icons/figma.png";
|
import figma from './icons/figma.png'
|
||||||
import forward from "./icons/forward.png";
|
import forward from './icons/forward.png'
|
||||||
import gitlab from "./icons/gitlab.png";
|
import gitlab from './icons/gitlab.png'
|
||||||
import heart from "./icons/heart.png";
|
import heart from './icons/heart.png'
|
||||||
import heartOutline from "./icons/heartOutline.png";
|
import heartOutline from './icons/heartOutline.png'
|
||||||
import hitParadeLogo from "./icons/hitParadeLogo.png";
|
import hitParadeLogo from './icons/hitParadeLogo.png'
|
||||||
import more from "./icons/more.png";
|
import more from './icons/more.png'
|
||||||
import musicLandAccueil from "./icons/musicLandAccueil.png";
|
import musicLandAccueil from './icons/musicLandAccueil.png'
|
||||||
import musicLandLogo from "./icons/musicLandLogo.png";
|
import musicLandLogo from './icons/musicLandLogo.png'
|
||||||
import musicLandProduction from "./icons/musicLandProduction.png";
|
import musicLandProduction from './icons/musicLandProduction.png'
|
||||||
import musicLandStudio from "./icons/musicLandStudio.png";
|
import musicLandStudio from './icons/musicLandStudio.png'
|
||||||
import musicLandVideo from "./icons/musicLandVideo.png";
|
import musicLandVideo from './icons/musicLandVideo.png'
|
||||||
import musicLandWriting from "./icons/musicLandWriting.png";
|
import musicLandWriting from './icons/musicLandWriting.png'
|
||||||
import pause from "./icons/pause.png";
|
import pause from './icons/pause.png'
|
||||||
import play from "./icons/play.png";
|
import play from './icons/play.png'
|
||||||
import share from "./icons/share.png";
|
import share from './icons/share.png'
|
||||||
import stars from "./icons/stars.png";
|
import stars from './icons/stars.png'
|
||||||
|
|
||||||
import hitParadeBG from "./UI/hitParadeBG.png";
|
import hitParadeBG from './UI/hitParadeBG.png'
|
||||||
import homeBG from "./UI/homeBG.png";
|
import homeBG from './UI/homeBG.png'
|
||||||
import libraryBG from "./UI/libraryBG.png";
|
import libraryBG from './UI/libraryBG.png'
|
||||||
import libraryBG2 from "./UI/libraryBG2.png";
|
import libraryBG2 from './UI/libraryBG2.png'
|
||||||
import playbackBG from "./UI/playbackBG.png";
|
import playbackBG from './UI/playbackBG.png'
|
||||||
import playbackBG2 from "./UI/playbackBG2.png";
|
import playbackBG2 from './UI/playbackBG2.png'
|
||||||
import productionBG from "./UI/productionBG.png";
|
import productionBG from './UI/productionBG.png'
|
||||||
import productionBG2 from "./UI/productionBG2.png";
|
import productionBG2 from './UI/productionBG2.png'
|
||||||
import profileBG from "./UI/profileBG.png";
|
import profileBG from './UI/profileBG.png'
|
||||||
import studioBG from "./UI/studioBG.png";
|
import studioBG from './UI/studioBG.png'
|
||||||
import studioBG2 from "./UI/studioBG2.png";
|
import studioBG2 from './UI/studioBG2.png'
|
||||||
import writingBG from "./UI/writingBG.png";
|
import writingBG from './UI/writingBG.png'
|
||||||
|
|
||||||
import bena from "./UI/bena.png";
|
import bena from './UI/bena.png'
|
||||||
import john from "./UI/john.png";
|
import john from './UI/john.png'
|
||||||
import malik from "./UI/malik.png";
|
import malik from './UI/malik.png'
|
||||||
import nathalie from "./UI/nathalie.png";
|
import nathalie from './UI/nathalie.png'
|
||||||
import theo from "./UI/theo.png";
|
import theo from './UI/theo.png'
|
||||||
|
|
||||||
import { Platform } from "react-native";
|
import { Platform } from 'react-native'
|
||||||
import goodVibe from "./UI/goodVibe.png";
|
import goodVibe from './UI/goodVibe.png'
|
||||||
import placeholder from "./UI/placeholder.jpg";
|
import placeholder from './UI/placeholder.jpg'
|
||||||
import placeholder2 from "./UI/placeholder2.jpg";
|
import placeholder2 from './UI/placeholder2.jpg'
|
||||||
import placeholder3 from "./UI/placeholder3.png";
|
import placeholder3 from './UI/placeholder3.png'
|
||||||
import placeholder4 from "./UI/placeholder4.jpg";
|
import placeholder4 from './UI/placeholder4.jpg'
|
||||||
import profile from "./UI/profile.jpg";
|
import profile from './UI/profile.jpg'
|
||||||
import musiclandClub from "./UI/musiclandClub.png";
|
import musiclandClub from './UI/musiclandClub.png'
|
||||||
export const tabs = {
|
export const tabs = {
|
||||||
home,
|
home,
|
||||||
tasks,
|
tasks,
|
||||||
@@ -117,12 +117,12 @@ export const tabs = {
|
|||||||
ribbon,
|
ribbon,
|
||||||
mic,
|
mic,
|
||||||
person,
|
person,
|
||||||
};
|
}
|
||||||
|
|
||||||
export const icons = {
|
export const icons = {
|
||||||
bell,
|
bell,
|
||||||
sort,
|
sort,
|
||||||
dragDots: require("./icons/dragDots.png"),
|
dragDots: require('./icons/dragDots.png'),
|
||||||
|
|
||||||
chevronDown,
|
chevronDown,
|
||||||
arrowRight,
|
arrowRight,
|
||||||
@@ -186,13 +186,13 @@ export const icons = {
|
|||||||
hitParadeLogo,
|
hitParadeLogo,
|
||||||
calendar,
|
calendar,
|
||||||
coin,
|
coin,
|
||||||
club: require("./icons/club.png"),
|
club: require('./icons/club.png'),
|
||||||
clubIcon: require("./icons/clubIcon.png"),
|
clubIcon: require('./icons/clubIcon.png'),
|
||||||
};
|
}
|
||||||
|
|
||||||
export const background = {
|
export const background = {
|
||||||
writingBG,
|
writingBG,
|
||||||
writingBgWeb: require("./UI/writingBgWeb.png"),
|
writingBgWeb: require('./UI/writingBgWeb.png'),
|
||||||
studioBG,
|
studioBG,
|
||||||
studioBG2,
|
studioBG2,
|
||||||
productionBG,
|
productionBG,
|
||||||
@@ -200,21 +200,21 @@ export const background = {
|
|||||||
playbackBG,
|
playbackBG,
|
||||||
playbackBG2,
|
playbackBG2,
|
||||||
libraryBG,
|
libraryBG,
|
||||||
libraryBgWeb: require("./UI/libraryBgWeb.png"),
|
libraryBgWeb: require('./UI/libraryBgWeb.png'),
|
||||||
libraryBG2,
|
libraryBG2,
|
||||||
libraryBG2Web: require("./UI/libraryBG2Web.png"),
|
libraryBG2Web: require('./UI/libraryBG2Web.png'),
|
||||||
profileBG,
|
profileBG,
|
||||||
profileBgWeb: require("./UI/profileBgWeb.png"),
|
profileBgWeb: require('./UI/profileBgWeb.png'),
|
||||||
hitParadeBG,
|
hitParadeBG,
|
||||||
hitParadeBG2: require("./UI/hitparadeBG2.jpg"),
|
hitParadeBG2: require('./UI/hitparadeBG2.jpg'),
|
||||||
playbackWeb: require("./UI/playbackWeb.png"),
|
playbackWeb: require('./UI/playbackWeb.png'),
|
||||||
playbackMobile: require("./UI/playbackMobile.png"),
|
playbackMobile: require('./UI/playbackMobile.png'),
|
||||||
homeBG,
|
homeBG,
|
||||||
homeBGWeb: require("./UI/homeBGWeb.png"),
|
homeBGWeb: require('./UI/homeBGWeb.png'),
|
||||||
loginBgWeb: require("./UI/loginBgWeb.png"),
|
loginBgWeb: require('./UI/loginBgWeb.png'),
|
||||||
profileWebBG: require("./UI/profileWebBG.jpg"),
|
profileWebBG: require('./UI/profileWebBG.jpg'),
|
||||||
bgTrans: require("./UI/bgTrans.png"),
|
bgTrans: require('./UI/bgTrans.png'),
|
||||||
};
|
}
|
||||||
|
|
||||||
export const ai = {
|
export const ai = {
|
||||||
nathalie,
|
nathalie,
|
||||||
@@ -222,15 +222,13 @@ export const ai = {
|
|||||||
malik,
|
malik,
|
||||||
bena,
|
bena,
|
||||||
john,
|
john,
|
||||||
};
|
}
|
||||||
|
|
||||||
export const videos = {
|
export const videos = {
|
||||||
test:
|
test:
|
||||||
Platform.OS === "web"
|
Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'),
|
||||||
? require("./video/testVideoWeb.mp4")
|
club: require('./video/club.mp4'),
|
||||||
: require("./video/testVideo.mp4"),
|
}
|
||||||
club: require("./video/club.mp4"),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const img = {
|
export const img = {
|
||||||
placeholder,
|
placeholder,
|
||||||
@@ -240,17 +238,17 @@ export const img = {
|
|||||||
profile,
|
profile,
|
||||||
goodVibe,
|
goodVibe,
|
||||||
musiclandClub,
|
musiclandClub,
|
||||||
};
|
}
|
||||||
|
|
||||||
export const cardsImg = {
|
export const cardsImg = {
|
||||||
writing: require("./icons/writing.png"),
|
writing: require('./icons/writing.png'),
|
||||||
studio: require("./icons/studio.png"),
|
studio: require('./icons/studio.png'),
|
||||||
video: require("./icons/video.png"),
|
video: require('./icons/video.png'),
|
||||||
production: require("./icons/production.png"),
|
production: require('./icons/production.png'),
|
||||||
};
|
}
|
||||||
|
|
||||||
export const subBadges = {
|
export const subBadges = {
|
||||||
starter: require("./icons/starterBadge.png"),
|
starter: require('./icons/starterBadge.png'),
|
||||||
pro: require("./icons/proBadge.png"),
|
pro: require('./icons/proBadge.png'),
|
||||||
premium: require("./icons/premiumBadge.png"),
|
premium: require('./icons/premiumBadge.png'),
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import {Dimensions} from 'react-native';
|
import { Dimensions } from 'react-native'
|
||||||
import {resWidth} from '../styles';
|
import { resWidth } from '../styles'
|
||||||
|
|
||||||
const {width: DIMENSION_WIDTH, height: DIMENSION_HEIGHT} =
|
const { width: DIMENSION_WIDTH, height: DIMENSION_HEIGHT } = Dimensions.get('screen')
|
||||||
Dimensions.get('screen');
|
|
||||||
|
|
||||||
const BOTTOM_BAR_HEIGHT = resWidth(80);
|
const BOTTOM_BAR_HEIGHT = resWidth(80)
|
||||||
|
|
||||||
export {DIMENSION_WIDTH, DIMENSION_HEIGHT, BOTTOM_BAR_HEIGHT};
|
export { DIMENSION_WIDTH, DIMENSION_HEIGHT, BOTTOM_BAR_HEIGHT }
|
||||||
|
|||||||
+1
-1
@@ -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 ({
|
export default ({
|
||||||
message,
|
message,
|
||||||
defaultMessage = "Chargement...",
|
defaultMessage = 'Chargement...',
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
indicatorProps = {},
|
indicatorProps = {},
|
||||||
messageStyle = {},
|
messageStyle = {},
|
||||||
@@ -13,8 +13,8 @@ export default ({
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
containerStyle,
|
containerStyle,
|
||||||
]}
|
]}
|
||||||
@@ -26,5 +26,5 @@ export default ({
|
|||||||
indicatorProps={indicatorProps}
|
indicatorProps={indicatorProps}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
+63
-76
@@ -1,56 +1,48 @@
|
|||||||
import { PortalProvider } from "@gorhom/portal";
|
import { PortalProvider } from '@gorhom/portal'
|
||||||
import React, { useState } from "react";
|
import React, { useState } from 'react'
|
||||||
import { Alert, Platform, StyleSheet, Text, View } from "react-native";
|
import { Alert, Platform, StyleSheet, Text, View } from 'react-native'
|
||||||
|
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
import { Fonts, Palette, gutters } from "../styles";
|
import { Fonts, Palette, gutters } from '../styles'
|
||||||
import GradientButton from "./GradientButton";
|
import GradientButton from './GradientButton'
|
||||||
import { LinearGradient } from "./LinearGradient/LinearGradient";
|
import { LinearGradient } from './LinearGradient/LinearGradient'
|
||||||
import Overlay from "./Overlay";
|
import Overlay from './Overlay'
|
||||||
const WebAlertModal = ({ title, description, options }) => {
|
const WebAlertModal = ({ title, description, options }) => {
|
||||||
const [visible, setVisible] = useState(true);
|
const [visible, setVisible] = useState(true)
|
||||||
|
|
||||||
const confirmOption = options?.find(({ style }) => style !== "cancel");
|
const confirmOption = options?.find(({ style }) => style !== 'cancel')
|
||||||
const cancelOption = options?.find(({ style }) => style === "cancel");
|
const cancelOption = options?.find(({ style }) => style === 'cancel')
|
||||||
const hasSecondaryAction = Boolean(cancelOption);
|
const hasSecondaryAction = Boolean(cancelOption)
|
||||||
const buttonContainerStyle = hasSecondaryAction
|
const buttonContainerStyle = hasSecondaryAction ? styles.actionButton : styles.singleActionButton
|
||||||
? styles.actionButton
|
|
||||||
: styles.singleActionButton;
|
|
||||||
|
|
||||||
const onConfirm = () => {
|
const onConfirm = () => {
|
||||||
setVisible(false);
|
setVisible(false)
|
||||||
confirmOption?.onPress();
|
confirmOption?.onPress()
|
||||||
};
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
setVisible(false);
|
setVisible(false)
|
||||||
cancelOption?.onPress();
|
cancelOption?.onPress()
|
||||||
};
|
}
|
||||||
|
|
||||||
const renderDescription = () => {
|
const renderDescription = () => {
|
||||||
if (
|
if (typeof description === 'string' || typeof description === 'number') {
|
||||||
typeof description === "string" ||
|
return <Text style={styles.description}>{description}</Text>
|
||||||
typeof description === "number"
|
|
||||||
) {
|
|
||||||
return <Text style={styles.description}>{description}</Text>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!description) {
|
if (!description) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return <View style={styles.customDescription}>{description}</View>;
|
return <View style={styles.customDescription}>{description}</View>
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<Overlay
|
<Overlay isVisible={visible} contentContainerStyle={styles.overlayContent}>
|
||||||
isVisible={visible}
|
|
||||||
contentContainerStyle={styles.overlayContent}
|
|
||||||
>
|
|
||||||
<View style={styles.modalWrapper}>
|
<View style={styles.modalWrapper}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={["rgba(255,255,255,0.18)", "rgba(255,255,255,0.04)"]}
|
colors={['rgba(255,255,255,0.18)', 'rgba(255,255,255,0.04)']}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
style={styles.modalBorder}
|
style={styles.modalBorder}
|
||||||
@@ -59,24 +51,19 @@ const WebAlertModal = ({ title, description, options }) => {
|
|||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title}>{title}</Text>
|
||||||
{renderDescription()}
|
{renderDescription()}
|
||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
<View
|
<View style={hasSecondaryAction ? styles.actionsRow : styles.actions}>
|
||||||
style={hasSecondaryAction ? styles.actionsRow : styles.actions}
|
|
||||||
>
|
|
||||||
{cancelOption && (
|
{cancelOption && (
|
||||||
<GradientButton
|
<GradientButton
|
||||||
title={cancelOption.text}
|
title={cancelOption.text}
|
||||||
onPress={onCancel}
|
onPress={onCancel}
|
||||||
colors={[
|
colors={['rgba(255,255,255,0.16)', 'rgba(255,255,255,0.08)']}
|
||||||
"rgba(255,255,255,0.16)",
|
|
||||||
"rgba(255,255,255,0.08)",
|
|
||||||
]}
|
|
||||||
textStyle={styles.secondaryButtonText}
|
textStyle={styles.secondaryButtonText}
|
||||||
gradientStyle={styles.secondaryButtonGradient}
|
gradientStyle={styles.secondaryButtonGradient}
|
||||||
containerStyle={buttonContainerStyle}
|
containerStyle={buttonContainerStyle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<GradientButton
|
<GradientButton
|
||||||
title={confirmOption?.text || "OK"}
|
title={confirmOption?.text || 'OK'}
|
||||||
onPress={onConfirm}
|
onPress={onConfirm}
|
||||||
containerStyle={buttonContainerStyle}
|
containerStyle={buttonContainerStyle}
|
||||||
/>
|
/>
|
||||||
@@ -86,16 +73,16 @@ const WebAlertModal = ({ title, description, options }) => {
|
|||||||
</View>
|
</View>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
const alertPolyfill = (title, description, options, extra) => {
|
const alertPolyfill = (title, description, options, extra) => {
|
||||||
const rootDiv = document.createElement("div");
|
const rootDiv = document.createElement('div')
|
||||||
document.body.appendChild(rootDiv);
|
document.body.appendChild(rootDiv)
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
document.body.removeChild(rootDiv);
|
document.body.removeChild(rootDiv)
|
||||||
};
|
}
|
||||||
|
|
||||||
const WebAlertComponent = () => (
|
const WebAlertComponent = () => (
|
||||||
<WebAlertModal
|
<WebAlertModal
|
||||||
@@ -104,38 +91,38 @@ const alertPolyfill = (title, description, options, extra) => {
|
|||||||
options={options}
|
options={options}
|
||||||
onDismiss={closeModal}
|
onDismiss={closeModal}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
|
|
||||||
// Render the React component into the div
|
// Render the React component into the div
|
||||||
require("react-dom").render(<WebAlertComponent />, rootDiv);
|
require('react-dom').render(<WebAlertComponent />, rootDiv)
|
||||||
};
|
}
|
||||||
|
|
||||||
const customAlert = (title, description, options, extra) => {
|
const customAlert = (title, description, options, extra) => {
|
||||||
// Utilisation de la propriété userInterfaceStyle pour le mettre en mode sombre
|
// Utilisation de la propriété userInterfaceStyle pour le mettre en mode sombre
|
||||||
Alert.alert(title, description, options, {
|
Alert.alert(title, description, options, {
|
||||||
...extra,
|
...extra,
|
||||||
userInterfaceStyle: "dark",
|
userInterfaceStyle: 'dark',
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
const alert = Platform.OS === "web" ? alertPolyfill : customAlert;
|
const alert = Platform.OS === 'web' ? alertPolyfill : customAlert
|
||||||
|
|
||||||
export const showPremiumRequiredAlert = () =>
|
export const showPremiumRequiredAlert = () =>
|
||||||
alert(
|
alert(
|
||||||
"Accès premium requis",
|
'Accès premium requis',
|
||||||
"Vous devez payer pour créer une nouvelle musique."
|
'Vous devez payer pour créer une nouvelle musique.'
|
||||||
// [{ text: "OK", style: "cancel" }]
|
// [{ text: "OK", style: "cancel" }]
|
||||||
);
|
)
|
||||||
|
|
||||||
export default alert;
|
export default alert
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
overlayContent: {
|
overlayContent: {
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
modalWrapper: {
|
modalWrapper: {
|
||||||
width: "90%",
|
width: '90%',
|
||||||
maxWidth: 460,
|
maxWidth: 460,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
},
|
},
|
||||||
@@ -147,49 +134,49 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 23,
|
borderRadius: 23,
|
||||||
paddingHorizontal: gutters * 1.8,
|
paddingHorizontal: gutters * 1.8,
|
||||||
paddingVertical: gutters,
|
paddingVertical: gutters,
|
||||||
backgroundColor: "rgba(15, 12, 20, 0.9)",
|
backgroundColor: 'rgba(15, 12, 20, 0.9)',
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
gap: gutters * 0.75,
|
gap: gutters * 0.75,
|
||||||
},
|
},
|
||||||
title: Fonts({
|
title: Fonts({
|
||||||
type: "mainTitle",
|
type: 'mainTitle',
|
||||||
fontSize: 3,
|
fontSize: 3,
|
||||||
style: { textAlign: "center" },
|
style: { textAlign: 'center' },
|
||||||
}),
|
}),
|
||||||
description: Fonts({
|
description: Fonts({
|
||||||
type: "default",
|
type: 'default',
|
||||||
color: Palette.gray,
|
color: Palette.gray,
|
||||||
fontSize: 2,
|
fontSize: 2,
|
||||||
style: { lineHeight: 22, textAlign: "center" },
|
style: { lineHeight: 22, textAlign: 'center' },
|
||||||
}),
|
}),
|
||||||
divider: {
|
divider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: "rgba(255,255,255,0.08)",
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
marginVertical: 0,
|
marginVertical: 0,
|
||||||
},
|
},
|
||||||
customDescription: {
|
customDescription: {
|
||||||
width: "100%",
|
width: '100%',
|
||||||
},
|
},
|
||||||
actionsRow: {
|
actionsRow: {
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
gap: gutters,
|
gap: gutters,
|
||||||
width: "100%",
|
width: '100%',
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
width: "100%",
|
width: '100%',
|
||||||
gap: gutters,
|
gap: gutters,
|
||||||
},
|
},
|
||||||
actionButton: {
|
actionButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
singleActionButton: {
|
singleActionButton: {
|
||||||
width: "100%",
|
width: '100%',
|
||||||
},
|
},
|
||||||
secondaryButtonGradient: {
|
secondaryButtonGradient: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "rgba(255,255,255,0.2)",
|
borderColor: 'rgba(255,255,255,0.2)',
|
||||||
},
|
},
|
||||||
secondaryButtonText: {
|
secondaryButtonText: {
|
||||||
color: Palette.gray,
|
color: Palette.gray,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from 'react'
|
||||||
import { Animated, StyleSheet, useWindowDimensions, View } from "react-native";
|
import { Animated, StyleSheet, useWindowDimensions, View } from 'react-native'
|
||||||
import { Palette } from "../../styles";
|
import { Palette } from '../../styles'
|
||||||
|
|
||||||
const ORIENTATION = {
|
const ORIENTATION = {
|
||||||
HORIZONTAL: "horizontal",
|
HORIZONTAL: 'horizontal',
|
||||||
VERTICAL: "vertical",
|
VERTICAL: 'vertical',
|
||||||
};
|
}
|
||||||
|
|
||||||
const AnimatedPaginationDot = ({
|
const AnimatedPaginationDot = ({
|
||||||
data = [],
|
data = [],
|
||||||
@@ -16,44 +16,32 @@ const AnimatedPaginationDot = ({
|
|||||||
orientation = ORIENTATION.HORIZONTAL,
|
orientation = ORIENTATION.HORIZONTAL,
|
||||||
expandingDotSize = 20,
|
expandingDotSize = 20,
|
||||||
inactiveDotOpacity = 0.4,
|
inactiveDotOpacity = 0.4,
|
||||||
inactiveDotColor = "rgba(255,255,255,0.4)",
|
inactiveDotColor = 'rgba(255,255,255,0.4)',
|
||||||
activeDotColor = Palette.white,
|
activeDotColor = Palette.white,
|
||||||
baseDotSize = 10,
|
baseDotSize = 10,
|
||||||
}) => {
|
}) => {
|
||||||
const animatedValue = useMemo(
|
const animatedValue = useMemo(() => scrollValue || new Animated.Value(0), [scrollValue])
|
||||||
() => scrollValue || new Animated.Value(0),
|
|
||||||
[scrollValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { width, height } = useWindowDimensions();
|
const { width, height } = useWindowDimensions()
|
||||||
const distance = useMemo(() => {
|
const distance = useMemo(() => {
|
||||||
if (typeof itemDimension === "number" && itemDimension > 0) {
|
if (typeof itemDimension === 'number' && itemDimension > 0) {
|
||||||
return itemDimension;
|
return itemDimension
|
||||||
}
|
}
|
||||||
if (orientation === ORIENTATION.VERTICAL) {
|
if (orientation === ORIENTATION.VERTICAL) {
|
||||||
return Math.max(height, 1);
|
return Math.max(height, 1)
|
||||||
}
|
}
|
||||||
return Math.max(width, 1);
|
return Math.max(width, 1)
|
||||||
}, [height, itemDimension, orientation, width]);
|
}, [height, itemDimension, orientation, width])
|
||||||
|
|
||||||
const resolvedDotStyle = useMemo(
|
const resolvedDotStyle = useMemo(() => StyleSheet.flatten(dotStyle) || {}, [dotStyle])
|
||||||
() => StyleSheet.flatten(dotStyle) || {},
|
const defaultSize = Math.max(baseDotSize, 1)
|
||||||
[dotStyle]
|
|
||||||
);
|
|
||||||
const defaultSize = Math.max(baseDotSize, 1);
|
|
||||||
const baseWidth =
|
const baseWidth =
|
||||||
typeof resolvedDotStyle?.width === "number"
|
typeof resolvedDotStyle?.width === 'number' ? resolvedDotStyle.width : defaultSize
|
||||||
? resolvedDotStyle.width
|
|
||||||
: defaultSize;
|
|
||||||
const baseHeight =
|
const baseHeight =
|
||||||
typeof resolvedDotStyle?.height === "number"
|
typeof resolvedDotStyle?.height === 'number' ? resolvedDotStyle.height : defaultSize
|
||||||
? resolvedDotStyle.height
|
|
||||||
: defaultSize;
|
|
||||||
|
|
||||||
const containerOrientationStyle =
|
const containerOrientationStyle =
|
||||||
orientation === ORIENTATION.VERTICAL
|
orientation === ORIENTATION.VERTICAL ? styles.containerVertical : styles.containerHorizontal
|
||||||
? styles.containerVertical
|
|
||||||
: styles.containerHorizontal;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -61,28 +49,24 @@ const AnimatedPaginationDot = ({
|
|||||||
style={[styles.containerBase, containerOrientationStyle, containerStyle]}
|
style={[styles.containerBase, containerOrientationStyle, containerStyle]}
|
||||||
>
|
>
|
||||||
{data.map((_, index) => {
|
{data.map((_, index) => {
|
||||||
const inputRange = [
|
const inputRange = [(index - 1) * distance, index * distance, (index + 1) * distance]
|
||||||
(index - 1) * distance,
|
|
||||||
index * distance,
|
|
||||||
(index + 1) * distance,
|
|
||||||
];
|
|
||||||
|
|
||||||
const staticSizeStyle = {
|
const staticSizeStyle = {
|
||||||
width: baseWidth,
|
width: baseWidth,
|
||||||
height: baseHeight,
|
height: baseHeight,
|
||||||
};
|
}
|
||||||
|
|
||||||
const color = animatedValue.interpolate({
|
const color = animatedValue.interpolate({
|
||||||
inputRange,
|
inputRange,
|
||||||
outputRange: [inactiveDotColor, activeDotColor, inactiveDotColor],
|
outputRange: [inactiveDotColor, activeDotColor, inactiveDotColor],
|
||||||
extrapolate: "clamp",
|
extrapolate: 'clamp',
|
||||||
});
|
})
|
||||||
|
|
||||||
const opacity = animatedValue.interpolate({
|
const opacity = animatedValue.interpolate({
|
||||||
inputRange,
|
inputRange,
|
||||||
outputRange: [inactiveDotOpacity, 1, inactiveDotOpacity],
|
outputRange: [inactiveDotOpacity, 1, inactiveDotOpacity],
|
||||||
extrapolate: "clamp",
|
extrapolate: 'clamp',
|
||||||
});
|
})
|
||||||
|
|
||||||
const primarySize = animatedValue.interpolate({
|
const primarySize = animatedValue.interpolate({
|
||||||
inputRange,
|
inputRange,
|
||||||
@@ -91,13 +75,11 @@ const AnimatedPaginationDot = ({
|
|||||||
expandingDotSize,
|
expandingDotSize,
|
||||||
orientation === ORIENTATION.VERTICAL ? baseHeight : baseWidth,
|
orientation === ORIENTATION.VERTICAL ? baseHeight : baseWidth,
|
||||||
],
|
],
|
||||||
extrapolate: "clamp",
|
extrapolate: 'clamp',
|
||||||
});
|
})
|
||||||
|
|
||||||
const animatedSizeStyle =
|
const animatedSizeStyle =
|
||||||
orientation === ORIENTATION.VERTICAL
|
orientation === ORIENTATION.VERTICAL ? { height: primarySize } : { width: primarySize }
|
||||||
? { height: primarySize }
|
|
||||||
: { width: primarySize };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -110,30 +92,30 @@ const AnimatedPaginationDot = ({
|
|||||||
{ backgroundColor: color, opacity },
|
{ backgroundColor: color, opacity },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
AnimatedPaginationDot.orientation = ORIENTATION;
|
AnimatedPaginationDot.orientation = ORIENTATION
|
||||||
|
|
||||||
export default AnimatedPaginationDot;
|
export default AnimatedPaginationDot
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
containerBase: {
|
containerBase: {
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
containerHorizontal: {
|
containerHorizontal: {
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
},
|
},
|
||||||
containerVertical: {
|
containerVertical: {
|
||||||
flexDirection: "column",
|
flexDirection: 'column',
|
||||||
},
|
},
|
||||||
dotBase: {
|
dotBase: {
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
marginHorizontal: 4,
|
marginHorizontal: 4,
|
||||||
marginVertical: 4,
|
marginVertical: 4,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,31 +1,25 @@
|
|||||||
import { Portal } from "@gorhom/portal";
|
import { Portal } from '@gorhom/portal'
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
import { useCallback } from "react";
|
import { useCallback } from 'react'
|
||||||
import { Platform, Pressable, StyleSheet, View } from "react-native";
|
import { Platform, Pressable, StyleSheet, View } from 'react-native'
|
||||||
import ActionSheet, { SheetManager } from "react-native-actions-sheet";
|
import ActionSheet, { SheetManager } from 'react-native-actions-sheet'
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
import { isWeb } from "../hooks/useLayoutType";
|
import { isWeb } from '../hooks/useLayoutType'
|
||||||
import { gutters, Palette } from "../styles";
|
import { gutters, Palette } from '../styles'
|
||||||
|
|
||||||
const AppActionSheet = ({
|
const AppActionSheet = ({ id, children, webModal = false, onClose = () => {}, ...sheetProps }) => {
|
||||||
id,
|
const insets = useSafeAreaInsets()
|
||||||
children,
|
|
||||||
webModal = false,
|
|
||||||
onClose = () => {},
|
|
||||||
...sheetProps
|
|
||||||
}) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const handleRequestClose = useCallback(() => {
|
const handleRequestClose = useCallback(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
Promise.resolve(SheetManager.hide(id))
|
Promise.resolve(SheetManager.hide(id))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
onClose?.();
|
onClose?.()
|
||||||
});
|
})
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
onClose?.();
|
onClose?.()
|
||||||
}, [id, onClose]);
|
}, [id, onClose])
|
||||||
|
|
||||||
if (isWeb && webModal) {
|
if (isWeb && webModal) {
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +37,7 @@ const AppActionSheet = ({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Portal>
|
</Portal>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -62,7 +56,7 @@ const AppActionSheet = ({
|
|||||||
{...sheetProps}
|
{...sheetProps}
|
||||||
>
|
>
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={Platform.OS === "ios" || isWeb ? 20 : 10}
|
intensity={Platform.OS === 'ios' || isWeb ? 20 : 10}
|
||||||
style={{
|
style={{
|
||||||
paddingTop: 36,
|
paddingTop: 36,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
@@ -70,7 +64,7 @@ const AppActionSheet = ({
|
|||||||
backgroundColor: Palette.glass,
|
backgroundColor: Palette.glass,
|
||||||
borderTopLeftRadius: 20,
|
borderTopLeftRadius: 20,
|
||||||
borderTopRightRadius: 20,
|
borderTopRightRadius: 20,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
// experimentalBlurMethod={
|
// experimentalBlurMethod={
|
||||||
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
||||||
@@ -79,33 +73,33 @@ const AppActionSheet = ({
|
|||||||
{children}
|
{children}
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</ActionSheet>
|
</ActionSheet>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default AppActionSheet;
|
export default AppActionSheet
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
webOverlay: {
|
webOverlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
position: "fixed",
|
position: 'fixed',
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
},
|
},
|
||||||
webBackdrop: {
|
webBackdrop: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.55)",
|
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||||
},
|
},
|
||||||
webModalWrapper: {
|
webModalWrapper: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
padding: 24,
|
padding: 24,
|
||||||
},
|
},
|
||||||
webModalCard: {
|
webModalCard: {
|
||||||
width: 480,
|
width: 480,
|
||||||
maxWidth: "90%",
|
maxWidth: '90%',
|
||||||
borderRadius: 28,
|
borderRadius: 28,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
backgroundColor: Palette.glass,
|
backgroundColor: Palette.glass,
|
||||||
padding: 32,
|
padding: 32,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { View, Text, Pressable } from "react-native";
|
import { View, Text, Pressable } from 'react-native'
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Palette, Style } from "../styles";
|
import { Palette, Style } from '../styles'
|
||||||
import { size } from "../styles/Style";
|
import { size } from '../styles/Style'
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
import { FONT_FAMILY } from '../styles/Fonts'
|
||||||
|
|
||||||
const AppCheckbox = ({ onPress, selected, label }) => {
|
const AppCheckbox = ({ onPress, selected, label }) => {
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +42,7 @@ const AppCheckbox = ({ onPress, selected, label }) => {
|
|||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default AppCheckbox;
|
export default AppCheckbox
|
||||||
|
|||||||
@@ -1,93 +1,85 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import { Image, Linking, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||||
Image,
|
|
||||||
Linking,
|
|
||||||
Platform,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
|
|
||||||
import { icons } from "../assets";
|
import { icons } from '../assets'
|
||||||
import { appleAppStoreUrl, googlePlayStoreUrl } from "../data";
|
import { appleAppStoreUrl, googlePlayStoreUrl } from '../data'
|
||||||
import useLayoutType from "../hooks/useLayoutType";
|
import useLayoutType from '../hooks/useLayoutType'
|
||||||
import { Palette, gutters } from "../styles";
|
import { Palette, gutters } from '../styles'
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
import { FONT_FAMILY } from '../styles/Fonts'
|
||||||
|
|
||||||
const STORAGE_KEY = "appDownloadBanner:dismissed";
|
const STORAGE_KEY = 'appDownloadBanner:dismissed'
|
||||||
|
|
||||||
const AppDownloadBanner = () => {
|
const AppDownloadBanner = () => {
|
||||||
const { isMobileWeb } = useLayoutType();
|
const { isMobileWeb } = useLayoutType()
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
const [hasHydrated, setHasHydrated] = useState(false);
|
const [hasHydrated, setHasHydrated] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true
|
||||||
|
|
||||||
if (!isMobileWeb) {
|
if (!isMobileWeb) {
|
||||||
setIsVisible(false);
|
setIsVisible(false)
|
||||||
setHasHydrated(false);
|
setHasHydrated(false)
|
||||||
return undefined;
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncStorage.getItem(STORAGE_KEY)
|
AsyncStorage.getItem(STORAGE_KEY)
|
||||||
.then((value) => {
|
.then((value) => {
|
||||||
if (!mounted) return;
|
if (!mounted) return
|
||||||
setIsVisible(value !== "hidden");
|
setIsVisible(value !== 'hidden')
|
||||||
setHasHydrated(true);
|
setHasHydrated(true)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!mounted) return;
|
if (!mounted) return
|
||||||
setIsVisible(true);
|
setIsVisible(true)
|
||||||
setHasHydrated(true);
|
setHasHydrated(true)
|
||||||
});
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false
|
||||||
};
|
}
|
||||||
}, [isMobileWeb]);
|
}, [isMobileWeb])
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
setIsVisible(false);
|
setIsVisible(false)
|
||||||
AsyncStorage.setItem(STORAGE_KEY, "hidden").catch(() => {});
|
AsyncStorage.setItem(STORAGE_KEY, 'hidden').catch(() => {})
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const openLink = useCallback((url) => {
|
const openLink = useCallback((url) => {
|
||||||
if (typeof url !== "string") return;
|
if (typeof url !== 'string') return
|
||||||
const target = url.trim();
|
const target = url.trim()
|
||||||
if (!target) return;
|
if (!target) return
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === 'web') {
|
||||||
try {
|
try {
|
||||||
window.open(target, "_blank", "noopener,noreferrer");
|
window.open(target, '_blank', 'noopener,noreferrer')
|
||||||
return;
|
return
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("AppDownloadBanner: failed to open link in new tab", error);
|
console.warn('AppDownloadBanner: failed to open link in new tab', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Linking.openURL(target).catch((error) => {
|
Linking.openURL(target).catch((error) => {
|
||||||
console.warn("AppDownloadBanner: failed to open store link", error);
|
console.warn('AppDownloadBanner: failed to open store link', error)
|
||||||
});
|
})
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const handleOpenStore = useCallback(
|
const handleOpenStore = useCallback(
|
||||||
(store) => {
|
(store) => {
|
||||||
if (store === "ios") {
|
if (store === 'ios') {
|
||||||
openLink(appleAppStoreUrl);
|
openLink(appleAppStoreUrl)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
if (store === "android") {
|
if (store === 'android') {
|
||||||
openLink(googlePlayStoreUrl);
|
openLink(googlePlayStoreUrl)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[openLink],
|
[openLink]
|
||||||
);
|
)
|
||||||
|
|
||||||
if (!isMobileWeb || !isVisible || !hasHydrated) {
|
if (!isMobileWeb || !isVisible || !hasHydrated) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,8 +93,8 @@ const AppDownloadBanner = () => {
|
|||||||
<View style={styles.titleContent}>
|
<View style={styles.titleContent}>
|
||||||
<Text style={styles.title}>Télécharge l'app MusicLand</Text>
|
<Text style={styles.title}>Télécharge l'app MusicLand</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
Pour une expérience mobile plus fluide, utilise l'application
|
Pour une expérience mobile plus fluide, utilise l'application native et retrouve
|
||||||
native et retrouve toutes les fonctionnalités.
|
toutes les fonctionnalités.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -119,38 +111,38 @@ const AppDownloadBanner = () => {
|
|||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cta, styles.appStoreCta]}
|
style={[styles.cta, styles.appStoreCta]}
|
||||||
onPress={() => handleOpenStore("ios")}
|
onPress={() => handleOpenStore('ios')}
|
||||||
>
|
>
|
||||||
<Text style={styles.ctaText}>App Store</Text>
|
<Text style={styles.ctaText}>App Store</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cta, styles.playStoreCta]}
|
style={[styles.cta, styles.playStoreCta]}
|
||||||
onPress={() => handleOpenStore("android")}
|
onPress={() => handleOpenStore('android')}
|
||||||
>
|
>
|
||||||
<Text style={styles.ctaText}>Google Play</Text>
|
<Text style={styles.ctaText}>Google Play</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
position: "fixed",
|
position: 'fixed',
|
||||||
bottom: gutters,
|
bottom: gutters,
|
||||||
left: gutters,
|
left: gutters,
|
||||||
right: gutters,
|
right: gutters,
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
zIndex: 80,
|
zIndex: 80,
|
||||||
},
|
},
|
||||||
banner: {
|
banner: {
|
||||||
width: "100%",
|
width: '100%',
|
||||||
maxWidth: 520,
|
maxWidth: 520,
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
backgroundColor: "rgba(12, 10, 16, 0.95)",
|
backgroundColor: 'rgba(12, 10, 16, 0.95)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: Palette.ultraLightWhite,
|
borderColor: Palette.ultraLightWhite,
|
||||||
shadowColor: Palette.black,
|
shadowColor: Palette.black,
|
||||||
@@ -160,29 +152,29 @@ const styles = StyleSheet.create({
|
|||||||
elevation: 10,
|
elevation: 10,
|
||||||
},
|
},
|
||||||
headerRow: {
|
headerRow: {
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
alignItems: "flex-start",
|
alignItems: 'flex-start',
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
titleRow: {
|
titleRow: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
logoWrapper: {
|
logoWrapper: {
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.06)",
|
backgroundColor: 'rgba(255, 255, 255, 0.06)',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: Palette.ultraLightWhite,
|
borderColor: Palette.ultraLightWhite,
|
||||||
},
|
},
|
||||||
logo: {
|
logo: {
|
||||||
width: "80%",
|
width: '80%',
|
||||||
height: "80%",
|
height: '80%',
|
||||||
resizeMode: "contain",
|
resizeMode: 'contain',
|
||||||
},
|
},
|
||||||
titleContent: {
|
titleContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -210,16 +202,16 @@ const styles = StyleSheet.create({
|
|||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
justifyContent: "space-between",
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
cta: {
|
cta: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 46,
|
height: 46,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: Palette.ultraLightWhite,
|
borderColor: Palette.ultraLightWhite,
|
||||||
},
|
},
|
||||||
@@ -237,6 +229,6 @@ const styles = StyleSheet.create({
|
|||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
export default AppDownloadBanner;
|
export default AppDownloadBanner
|
||||||
|
|||||||
+20
-20
@@ -1,31 +1,31 @@
|
|||||||
import { Text, View } from "react-native";
|
import { Text, View } from 'react-native'
|
||||||
import { Image } from "expo-image";
|
import { Image } from 'expo-image'
|
||||||
import { responsiveWidth } from "../actions/responsiveSizes.js";
|
import { responsiveWidth } from '../actions/responsiveSizes.js'
|
||||||
import { formatImageURL, getInitials } from "../helpers";
|
import { formatImageURL, getInitials } from '../helpers'
|
||||||
import { Fonts, Palette, Style } from "../styles";
|
import { Fonts, Palette, Style } from '../styles'
|
||||||
import Badge from "./Badge.js";
|
import Badge from './Badge.js'
|
||||||
|
|
||||||
export default ({
|
export default ({
|
||||||
name = "",
|
name = '',
|
||||||
url = null,
|
url = null,
|
||||||
size = responsiveWidth(7),
|
size = responsiveWidth(7),
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
forceRawImage = false,
|
forceRawImage = false,
|
||||||
badge = {},
|
badge = {},
|
||||||
}) => {
|
}) => {
|
||||||
const uniqColorById = (uniqId = "test") => {
|
const uniqColorById = (uniqId = 'test') => {
|
||||||
// Calculer un nombre unique à partir de l'ID de l'employé
|
// Calculer un nombre unique à partir de l'ID de l'employé
|
||||||
let uniqueNumber = 0;
|
let uniqueNumber = 0
|
||||||
|
|
||||||
for (let i = 0; i < uniqId?.length; i++) {
|
for (let i = 0; i < uniqId?.length; i++) {
|
||||||
uniqueNumber += uniqId.charCodeAt(i);
|
uniqueNumber += uniqId.charCodeAt(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
// génère des couleurs pastels claires
|
// génère des couleurs pastels claires
|
||||||
const color = `hsl(${uniqueNumber % 360}, 100%, 90%)`;
|
const color = `hsl(${uniqueNumber % 360}, 100%, 90%)`
|
||||||
|
|
||||||
return color;
|
return color
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -39,14 +39,14 @@ export default ({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
...Style.containerRound,
|
...Style.containerRound,
|
||||||
width: "100%",
|
width: '100%',
|
||||||
height: "100%",
|
height: '100%',
|
||||||
backgroundColor: url ? "transparent" : uniqColorById(name),
|
backgroundColor: url ? 'transparent' : uniqColorById(name),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{url ? (
|
{url ? (
|
||||||
<Image
|
<Image
|
||||||
cachePolicy={"memory"}
|
cachePolicy={'memory'}
|
||||||
source={{
|
source={{
|
||||||
uri: forceRawImage ? url : formatImageURL({ url, size: 200 }),
|
uri: forceRawImage ? url : formatImageURL({ url, size: 200 }),
|
||||||
}}
|
}}
|
||||||
@@ -59,7 +59,7 @@ export default ({
|
|||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={Fonts({
|
style={Fonts({
|
||||||
type: "section",
|
type: 'section',
|
||||||
color: Palette.darkPurple,
|
color: Palette.darkPurple,
|
||||||
style: { fontSize: size / 3 },
|
style: { fontSize: size / 3 },
|
||||||
})}
|
})}
|
||||||
@@ -70,5 +70,5 @@ export default ({
|
|||||||
</View>
|
</View>
|
||||||
<Badge {...badge} />
|
<Badge {...badge} />
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Image, View } from 'react-native'
|
||||||
|
import { subBadges } from '../../assets'
|
||||||
|
export default function SubscriptionAvatar({ user }) {
|
||||||
|
const subscriptionLevel = user.premiumLevel
|
||||||
|
|
||||||
|
switch (subscriptionLevel) {
|
||||||
|
case 'starter':
|
||||||
|
return <Image source={subBadges.starter} />
|
||||||
|
case 'pro':
|
||||||
|
return <Image source={subBadges.pro} />
|
||||||
|
case 'premium':
|
||||||
|
return <Image source={subBadges.premium} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-15
@@ -1,24 +1,19 @@
|
|||||||
import { Motion } from "@legendapp/motion";
|
import { Motion } from '@legendapp/motion'
|
||||||
|
|
||||||
import { Fonts, Palette, Style } from "../styles";
|
import { Fonts, Palette, Style } from '../styles'
|
||||||
|
|
||||||
const Badge = ({
|
const Badge = ({ count = 0, size = 20, customContent = null, backgroundColor = null } = {}) => {
|
||||||
count = 0,
|
|
||||||
size = 20,
|
|
||||||
customContent = null,
|
|
||||||
backgroundColor = null,
|
|
||||||
} = {}) => {
|
|
||||||
if (!count && !customContent) {
|
if (!count && !customContent) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Motion.View
|
<Motion.View
|
||||||
animate={{ scale: 1 }}
|
animate={{ scale: 1 }}
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
transition={{ type: "tween", duration: 0.5 }}
|
transition={{ type: 'tween', duration: 0.5 }}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: -size / 4,
|
top: -size / 4,
|
||||||
right: -size / 4,
|
right: -size / 4,
|
||||||
backgroundColor: backgroundColor || Palette.red,
|
backgroundColor: backgroundColor || Palette.red,
|
||||||
@@ -34,7 +29,7 @@ const Badge = ({
|
|||||||
<Motion.Text
|
<Motion.Text
|
||||||
animate={{ scale: 1 }}
|
animate={{ scale: 1 }}
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
transition={{ type: "tween", duration: 0.5 }}
|
transition={{ type: 'tween', duration: 0.5 }}
|
||||||
style={{
|
style={{
|
||||||
...Fonts({ color: Palette.white }),
|
...Fonts({ color: Palette.white }),
|
||||||
}}
|
}}
|
||||||
@@ -43,7 +38,7 @@ const Badge = ({
|
|||||||
</Motion.Text>
|
</Motion.Text>
|
||||||
)}
|
)}
|
||||||
</Motion.View>
|
</Motion.View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Badge;
|
export default Badge
|
||||||
|
|||||||
@@ -1,40 +1,33 @@
|
|||||||
import { Image, Pressable, Text, View } from "react-native";
|
import { Image, Pressable, Text, View } from 'react-native'
|
||||||
import React from "reactn";
|
import React from 'reactn'
|
||||||
import { responsiveWidth } from "../actions/responsiveSizes.js";
|
import { responsiveWidth } from '../actions/responsiveSizes.js'
|
||||||
|
|
||||||
import { icons } from "../assets";
|
import { icons } from '../assets'
|
||||||
import { Fonts, Style, gutters } from "../styles";
|
import { Fonts, Style, gutters } from '../styles'
|
||||||
|
|
||||||
import { Routes } from "../navigation";
|
import { Routes } from '../navigation'
|
||||||
import { navigate } from "../navigation/NavigationService";
|
import { navigate } from '../navigation/NavigationService'
|
||||||
|
|
||||||
import useLayoutType, { sidebarWidth } from "../hooks/useLayoutType.js";
|
import useLayoutType, { sidebarWidth } from '../hooks/useLayoutType.js'
|
||||||
import useNotifications from "../hooks/useNotifications";
|
import useNotifications from '../hooks/useNotifications'
|
||||||
|
|
||||||
export default ({ title = "", containerStyle = {}, bell = false }) => {
|
export default ({ title = '', containerStyle = {}, bell = false }) => {
|
||||||
const { isDesktop } = useLayoutType();
|
const { isDesktop } = useLayoutType()
|
||||||
const notificationsContext = useNotifications();
|
const notificationsContext = useNotifications()
|
||||||
const unreadCount = notificationsContext?.unreadCount || 0;
|
const unreadCount = notificationsContext?.unreadCount || 0
|
||||||
const hasUnread = unreadCount > 0;
|
const hasUnread = unreadCount > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View style={[Style.containerSpaceBetween, { marginBottom: 20, ...containerStyle }]}>
|
||||||
style={[
|
|
||||||
Style.containerSpaceBetween,
|
|
||||||
{ marginBottom: 20, ...containerStyle },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={Style.containerRow}>
|
<View style={Style.containerRow}>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{
|
style={{
|
||||||
...Fonts({
|
...Fonts({
|
||||||
type: "mainTitle",
|
type: 'mainTitle',
|
||||||
style: {
|
style: {
|
||||||
marginRight: 10,
|
marginRight: 10,
|
||||||
maxWidth: isDesktop
|
maxWidth: isDesktop ? sidebarWidth - 4 * gutters : responsiveWidth(70),
|
||||||
? sidebarWidth - 4 * gutters
|
|
||||||
: responsiveWidth(70),
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
@@ -65,5 +58,5 @@ export default ({ title = "", containerStyle = {}, bell = false }) => {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import {
|
import { View, Text, Pressable, StyleSheet, Image, Platform } from 'react-native'
|
||||||
View,
|
import React from 'react'
|
||||||
Text,
|
import { BlurView } from 'expo-blur'
|
||||||
Pressable,
|
import { Palette, Style } from '../styles'
|
||||||
StyleSheet,
|
import { FONT_FAMILY } from '../styles/Fonts'
|
||||||
Image,
|
import { icons } from '../assets'
|
||||||
Platform,
|
import { size } from '../styles/Style'
|
||||||
} from "react-native";
|
|
||||||
import React from "react";
|
|
||||||
import { BlurView } from "expo-blur";
|
|
||||||
import { Palette, Style } from "../styles";
|
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
|
||||||
import { icons } from "../assets";
|
|
||||||
import { size } from "../styles/Style";
|
|
||||||
|
|
||||||
const BlurItemButton = ({ onPress, title = "" }) => {
|
const BlurItemButton = ({ onPress, title = '' }) => {
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress}>
|
<Pressable onPress={onPress}>
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={Platform.OS === "ios" ? 20 : 10}
|
intensity={Platform.OS === 'ios' ? 20 : 10}
|
||||||
style={styles.buttonContainer}
|
style={styles.buttonContainer}
|
||||||
// experimentalBlurMethod={
|
// experimentalBlurMethod={
|
||||||
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
||||||
@@ -28,16 +21,16 @@ const BlurItemButton = ({ onPress, title = "" }) => {
|
|||||||
source={icons.chevronDown}
|
source={icons.chevronDown}
|
||||||
style={{
|
style={{
|
||||||
...size({ size: 15 }),
|
...size({ size: 15 }),
|
||||||
transform: [{ rotate: "-90deg" }],
|
transform: [{ rotate: '-90deg' }],
|
||||||
}}
|
}}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default BlurItemButton;
|
export default BlurItemButton
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
buttonContainer: {
|
buttonContainer: {
|
||||||
@@ -45,7 +38,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
height: 56,
|
height: 56,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
backgroundColor: Palette.glass,
|
backgroundColor: Palette.glass,
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
@@ -53,4 +46,4 @@ const styles = StyleSheet.create({
|
|||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from 'react-native'
|
||||||
|
|
||||||
import { GradientBorderView } from "../GradientBorderView";
|
import { GradientBorderView } from '../GradientBorderView'
|
||||||
|
|
||||||
const BorderGradient = ({
|
const BorderGradient = ({ children, contentContainerStyle, gradientProps, ...props }) => {
|
||||||
children,
|
|
||||||
contentContainerStyle,
|
|
||||||
gradientProps,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<GradientBorderView
|
<GradientBorderView
|
||||||
gradientProps={{
|
gradientProps={{
|
||||||
@@ -26,13 +21,13 @@ const BorderGradient = ({
|
|||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
</GradientBorderView>
|
</GradientBorderView>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default BorderGradient;
|
export default BorderGradient
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
innerContainer: {
|
innerContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from 'react-native'
|
||||||
import { useState } from "react";
|
import { useState } from 'react'
|
||||||
|
|
||||||
const BorderGradient = ({ children, gradientProps, ...props }) => {
|
const BorderGradient = ({ children, gradientProps, ...props }) => {
|
||||||
const defaultGradientProps = {
|
const defaultGradientProps = {
|
||||||
@@ -15,10 +15,9 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
|
|||||||
colors: [],
|
colors: [],
|
||||||
useAngle: false,
|
useAngle: false,
|
||||||
angle: 0,
|
angle: 0,
|
||||||
};
|
}
|
||||||
|
|
||||||
const { locations, end, start, useAngle, angle, onLayout, colors } =
|
const { locations, end, start, useAngle, angle, onLayout, colors } = gradientProps
|
||||||
gradientProps;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
style,
|
style,
|
||||||
@@ -32,29 +31,29 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
|
|||||||
borderLeftWidth,
|
borderLeftWidth,
|
||||||
borderRightWidth,
|
borderRightWidth,
|
||||||
borderBottomWidth,
|
borderBottomWidth,
|
||||||
} = props;
|
} = props
|
||||||
|
|
||||||
const propStart = start ?? defaultGradientProps?.start;
|
const propStart = start ?? defaultGradientProps?.start
|
||||||
const propEnd = end ?? defaultGradientProps?.end;
|
const propEnd = end ?? defaultGradientProps?.end
|
||||||
|
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
});
|
})
|
||||||
|
|
||||||
const measure = (event) => {
|
const measure = (event) => {
|
||||||
setState({
|
setState({
|
||||||
width: event.nativeEvent.layout.width,
|
width: event.nativeEvent.layout.width,
|
||||||
height: event.nativeEvent.layout.height,
|
height: event.nativeEvent.layout.height,
|
||||||
});
|
})
|
||||||
if (onLayout) {
|
if (onLayout) {
|
||||||
onLayout(event);
|
onLayout(event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const getAngle = () => {
|
const getAngle = () => {
|
||||||
if (useAngle) {
|
if (useAngle) {
|
||||||
return angle + "deg";
|
return angle + 'deg'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Math.atan2 handles Infinity
|
// Math.atan2 handles Infinity
|
||||||
@@ -63,26 +62,26 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
|
|||||||
state.width * (propEnd.y - propStart.y),
|
state.width * (propEnd.y - propStart.y),
|
||||||
state.height * (propEnd.x - propStart.x)
|
state.height * (propEnd.x - propStart.x)
|
||||||
) +
|
) +
|
||||||
Math.PI / 2;
|
Math.PI / 2
|
||||||
return _angle + "rad";
|
return _angle + 'rad'
|
||||||
};
|
}
|
||||||
|
|
||||||
const getColors = () =>
|
const getColors = () =>
|
||||||
colors
|
colors
|
||||||
.map((color, index) => {
|
.map((color, index) => {
|
||||||
const location = locations?.[index] ?? defaultGradientProps.locations;
|
const location = locations?.[index] ?? defaultGradientProps.locations
|
||||||
let locationStyle = "";
|
let locationStyle = ''
|
||||||
if (location) {
|
if (location) {
|
||||||
locationStyle = " " + location * 100 + "%";
|
locationStyle = ' ' + location * 100 + '%'
|
||||||
}
|
}
|
||||||
return color + locationStyle;
|
return color + locationStyle
|
||||||
})
|
})
|
||||||
.join(",");
|
.join(',')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -100,35 +99,34 @@ const BorderGradient = ({ children, gradientProps, ...props }) => {
|
|||||||
borderLeftWidth,
|
borderLeftWidth,
|
||||||
borderRightWidth,
|
borderRightWidth,
|
||||||
borderBottomWidth,
|
borderBottomWidth,
|
||||||
borderStyle: "solid",
|
borderStyle: 'solid',
|
||||||
borderColor: "transparent",
|
borderColor: 'transparent',
|
||||||
// borderImage: `linear-gradient(${getAngle()},${getColors()}) 1`,
|
// borderImage: `linear-gradient(${getAngle()},${getColors()}) 1`,
|
||||||
background: `linear-gradient(${getAngle()},${getColors()}) border-box`,
|
background: `linear-gradient(${getAngle()},${getColors()}) border-box`,
|
||||||
WebkitMask:
|
WebkitMask: 'linear-gradient(#fff 0 0) padding-box,linear-gradient(#fff 0 0)',
|
||||||
"linear-gradient(#fff 0 0) padding-box,linear-gradient(#fff 0 0)",
|
WebkitMaskComposite: 'xor',
|
||||||
WebkitMaskComposite: "xor",
|
maskComposite: 'exclude',
|
||||||
maskComposite: "exclude",
|
overflow: 'hidden',
|
||||||
overflow: "hidden",
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
></View>
|
></View>
|
||||||
<View style={styles.innerContainer}>{children}</View>
|
<View style={styles.innerContainer}>{children}</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default BorderGradient;
|
export default BorderGradient
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
innerContainer: {
|
innerContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
right: 0,
|
right: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,40 +1,39 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Image, Pressable, Text, View } from "react-native";
|
import { Image, Pressable, Text, View } from 'react-native'
|
||||||
import { Palette, Style } from "../styles";
|
import { Palette, Style } from '../styles'
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
import { FONT_FAMILY } from '../styles/Fonts'
|
||||||
import { size as sizeStyle } from "../styles/Style";
|
import { size as sizeStyle } from '../styles/Style'
|
||||||
import BorderGradient from "./BorderGradient/BorderGradient";
|
import BorderGradient from './BorderGradient/BorderGradient'
|
||||||
|
|
||||||
const HEIGHT_BY_SIZE = {
|
const HEIGHT_BY_SIZE = {
|
||||||
small: 40,
|
small: 40,
|
||||||
medium: 50,
|
medium: 50,
|
||||||
large: 58,
|
large: 58,
|
||||||
};
|
}
|
||||||
|
|
||||||
const FONT_SIZE_BY_SIZE = {
|
const FONT_SIZE_BY_SIZE = {
|
||||||
small: 13,
|
small: 13,
|
||||||
medium: 15,
|
medium: 15,
|
||||||
large: 17,
|
large: 17,
|
||||||
};
|
}
|
||||||
|
|
||||||
const BorderGradientButton = ({
|
const BorderGradientButton = ({
|
||||||
title = "J’ai déjà mes paroles",
|
title = 'J’ai déjà mes paroles',
|
||||||
onPress,
|
onPress,
|
||||||
icon,
|
icon,
|
||||||
titleStyle,
|
titleStyle,
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
tint = "dark",
|
tint = 'dark',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
maxWidth = null,
|
maxWidth = null,
|
||||||
size = "medium",
|
size = 'medium',
|
||||||
height = null,
|
height = null,
|
||||||
}) => {
|
}) => {
|
||||||
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
|
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : 'medium'
|
||||||
const buttonHeight =
|
const buttonHeight = typeof height === 'number' ? height : HEIGHT_BY_SIZE[resolvedSize]
|
||||||
typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize];
|
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]
|
||||||
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
|
const iconSize = resolvedSize === 'small' ? 14 : 16
|
||||||
const iconSize = resolvedSize === "small" ? 14 : 16;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -51,7 +50,7 @@ const BorderGradientButton = ({
|
|||||||
>
|
>
|
||||||
<BorderGradient
|
<BorderGradient
|
||||||
gradientProps={{
|
gradientProps={{
|
||||||
colors: ["#F94697", "#7023F7"],
|
colors: ['#F94697', '#7023F7'],
|
||||||
start: { x: 0, y: 0 },
|
start: { x: 0, y: 0 },
|
||||||
end: { x: 1, y: 0 },
|
end: { x: 1, y: 0 },
|
||||||
locations: [0, 1],
|
locations: [0, 1],
|
||||||
@@ -66,27 +65,25 @@ const BorderGradientButton = ({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
backgroundColor: "#73737324",
|
backgroundColor: '#73737324',
|
||||||
width: "100%",
|
width: '100%',
|
||||||
height: "100%",
|
height: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={40}
|
intensity={40}
|
||||||
tint={tint}
|
tint={tint}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: '100%',
|
||||||
height: "100%",
|
height: '100%',
|
||||||
...Style.containerCenter,
|
...Style.containerCenter,
|
||||||
...Style.containerRow,
|
...Style.containerRow,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
gap: 11,
|
gap: 11,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{icon && (
|
{icon && <Image source={icon} style={sizeStyle({ size: iconSize })} />}
|
||||||
<Image source={icon} style={sizeStyle({ size: iconSize })} />
|
|
||||||
)}
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize,
|
fontSize,
|
||||||
@@ -101,7 +98,7 @@ const BorderGradientButton = ({
|
|||||||
</View>
|
</View>
|
||||||
</BorderGradient>
|
</BorderGradient>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default BorderGradientButton;
|
export default BorderGradientButton
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Image, Pressable, Text, View } from "react-native";
|
import { Image, Pressable, Text, View } from 'react-native'
|
||||||
import { Palette, Style } from "../styles";
|
import { Palette, Style } from '../styles'
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
import { FONT_FAMILY } from '../styles/Fonts'
|
||||||
import { size } from "../styles/Style";
|
import { size } from '../styles/Style'
|
||||||
import RotationBorder from "./RotationBorder/RotationBorder";
|
import RotationBorder from './RotationBorder/RotationBorder'
|
||||||
|
|
||||||
const BorderGradientButton = ({
|
const BorderGradientButton = ({
|
||||||
title = "J’ai déjà mes paroles",
|
title = 'J’ai déjà mes paroles',
|
||||||
onPress,
|
onPress,
|
||||||
icon,
|
icon,
|
||||||
titleStyle,
|
titleStyle,
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
tint = "dark",
|
tint = 'dark',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
maxWidth = null,
|
maxWidth = null,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -20,35 +20,35 @@ const BorderGradientButton = ({
|
|||||||
...(maxWidth ? { maxWidth } : {}),
|
...(maxWidth ? { maxWidth } : {}),
|
||||||
...containerStyle,
|
...containerStyle,
|
||||||
opacity: disabled ? 0.6 : 1,
|
opacity: disabled ? 0.6 : 1,
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress} disabled={disabled} style={pressableStyle}>
|
<Pressable onPress={onPress} disabled={disabled} style={pressableStyle}>
|
||||||
<RotationBorder
|
<RotationBorder
|
||||||
borderWidth={2}
|
borderWidth={2}
|
||||||
borderRadius={14}
|
borderRadius={14}
|
||||||
colors={["#F94697", "#7023F7"]}
|
colors={['#F94697', '#7023F7']}
|
||||||
style={{
|
style={{
|
||||||
height: 50,
|
height: 50,
|
||||||
width: "100%",
|
width: '100%',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
backgroundColor: "#000000b8",
|
backgroundColor: '#000000b8',
|
||||||
width: "100%",
|
width: '100%',
|
||||||
height: "100%",
|
height: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={40}
|
intensity={40}
|
||||||
tint={tint}
|
tint={tint}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: '100%',
|
||||||
height: "100%",
|
height: '100%',
|
||||||
...Style.containerCenter,
|
...Style.containerCenter,
|
||||||
...Style.containerRow,
|
...Style.containerRow,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
@@ -70,7 +70,7 @@ const BorderGradientButton = ({
|
|||||||
</View>
|
</View>
|
||||||
</RotationBorder>
|
</RotationBorder>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default BorderGradientButton;
|
export default BorderGradientButton
|
||||||
|
|||||||
@@ -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 React, { useImperativeHandle, useState, useRef } from 'react'
|
||||||
import { View, TouchableOpacity, ScrollView } from "react-native";
|
import { View, TouchableOpacity, ScrollView } from 'react-native'
|
||||||
import { Portal } from "@gorhom/portal";
|
import { Portal } from '@gorhom/portal'
|
||||||
import { Motion } from "@legendapp/motion";
|
import { Motion } from '@legendapp/motion'
|
||||||
|
|
||||||
import { Palette } from "../../styles";
|
import { Palette } from '../../styles'
|
||||||
import {
|
import { isDesktop, isLargeDesktop, sidebarWidth } from '../../hooks/useLayoutType'
|
||||||
isDesktop,
|
|
||||||
isLargeDesktop,
|
|
||||||
sidebarWidth,
|
|
||||||
} from "../../hooks/useLayoutType";
|
|
||||||
|
|
||||||
export const SheetScrollView = ScrollView;
|
export const SheetScrollView = ScrollView
|
||||||
export const SheetBackdrop = View;
|
export const SheetBackdrop = View
|
||||||
|
|
||||||
const BottomSheet = React.forwardRef((props, ref) => {
|
const BottomSheet = React.forwardRef((props, ref) => {
|
||||||
const [showSheet, setShowSheet] = useState(false);
|
const [showSheet, setShowSheet] = useState(false)
|
||||||
|
|
||||||
const bottomSheetRef = useRef();
|
const bottomSheetRef = useRef()
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
snapToIndex: () => {
|
snapToIndex: () => {
|
||||||
setShowSheet(true);
|
setShowSheet(true)
|
||||||
},
|
},
|
||||||
expand: () => {
|
expand: () => {
|
||||||
setShowSheet(true);
|
setShowSheet(true)
|
||||||
},
|
},
|
||||||
collapse: () => closeBottomSheet(),
|
collapse: () => closeBottomSheet(),
|
||||||
close: () => closeBottomSheet(),
|
close: () => closeBottomSheet(),
|
||||||
}));
|
}))
|
||||||
|
|
||||||
const closeBottomSheet = () => {
|
const closeBottomSheet = () => {
|
||||||
setShowSheet(false);
|
setShowSheet(false)
|
||||||
props.onChange(-1);
|
props.onChange(-1)
|
||||||
};
|
}
|
||||||
|
|
||||||
if (!showSheet) {
|
if (!showSheet) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,51 +40,49 @@ const BottomSheet = React.forwardRef((props, ref) => {
|
|||||||
initial={{ right: -500, opacity: 0 }}
|
initial={{ right: -500, opacity: 0 }}
|
||||||
animate={{ right: 0, opacity: 1 }}
|
animate={{ right: 0, opacity: 1 }}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: 'fixed',
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
zIndex: 1000000,
|
zIndex: 1000000,
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={closeBottomSheet}
|
onPress={closeBottomSheet}
|
||||||
ref={bottomSheetRef}
|
ref={bottomSheetRef}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "rgba(0,0,0,0.4)",
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
height: "100%",
|
height: '100%',
|
||||||
width: isDesktop
|
width: isDesktop ? sidebarWidth * (isLargeDesktop ? 2 : 1.5) : '100%',
|
||||||
? sidebarWidth * (isLargeDesktop ? 2 : 1.5)
|
|
||||||
: "100%",
|
|
||||||
backgroundColor: Palette.lightPurple,
|
backgroundColor: Palette.lightPurple,
|
||||||
overflow: "scroll",
|
overflow: 'scroll',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</View>
|
</View>
|
||||||
</Motion.View>
|
</Motion.View>
|
||||||
</Portal>
|
</Portal>
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
// TODO une croix pour fermer sur mobile web
|
// TODO une croix pour fermer sur mobile web
|
||||||
|
|
||||||
export default BottomSheet;
|
export default BottomSheet
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
import { AnimatePresence, Motion } from "@legendapp/motion";
|
import { AnimatePresence, Motion } from '@legendapp/motion'
|
||||||
import { useKeyboard } from "@react-native-community/hooks";
|
import { useKeyboard } from '@react-native-community/hooks'
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Keyboard, StyleSheet } from "react-native";
|
import { Keyboard, StyleSheet } from 'react-native'
|
||||||
|
|
||||||
import BottomSheet from "./BottomSheet";
|
import BottomSheet from './BottomSheet'
|
||||||
|
|
||||||
import useLayoutType from "../hooks/useLayoutType";
|
import useLayoutType from '../hooks/useLayoutType'
|
||||||
import { Palette } from "../styles";
|
import { Palette } from '../styles'
|
||||||
|
|
||||||
export default ({
|
export default ({
|
||||||
children,
|
children,
|
||||||
bottomSheetRef,
|
bottomSheetRef,
|
||||||
snapPoints = ["25%", "50%"],
|
snapPoints = ['25%', '50%'],
|
||||||
handleStyle = {},
|
handleStyle = {},
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const [currentSnapPointIndex, setCurrentSnapPointIndex] = useState(0);
|
const [currentSnapPointIndex, setCurrentSnapPointIndex] = useState(0)
|
||||||
|
|
||||||
const { keyboardShown = false } = useKeyboard();
|
const { keyboardShown = false } = useKeyboard()
|
||||||
const { isWeb } = useLayoutType();
|
const { isWeb } = useLayoutType()
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index) => {
|
const handleSheetChanges = useCallback((index) => {
|
||||||
setCurrentSnapPointIndex(index);
|
setCurrentSnapPointIndex(index)
|
||||||
|
|
||||||
if (index <= 0) {
|
if (index <= 0) {
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss()
|
||||||
}
|
}
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bottomSheetRef.current && !isWeb && currentSnapPointIndex > 0) {
|
if (bottomSheetRef.current && !isWeb && currentSnapPointIndex > 0) {
|
||||||
if (keyboardShown) {
|
if (keyboardShown) {
|
||||||
bottomSheetRef.current.expand();
|
bottomSheetRef.current.expand()
|
||||||
} else {
|
} else {
|
||||||
bottomSheetRef.current.snapToIndex(1);
|
bottomSheetRef.current.snapToIndex(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [keyboardShown]);
|
}, [keyboardShown])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -47,7 +47,7 @@ export default ({
|
|||||||
onPress={() => bottomSheetRef?.current?.close()}
|
onPress={() => bottomSheetRef?.current?.close()}
|
||||||
>
|
>
|
||||||
<Motion.View
|
<Motion.View
|
||||||
key={"A"}
|
key={'A'}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: Palette.black,
|
backgroundColor: Palette.black,
|
||||||
@@ -57,10 +57,10 @@ export default ({
|
|||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{
|
transition={{
|
||||||
default: {
|
default: {
|
||||||
type: "spring",
|
type: 'spring',
|
||||||
},
|
},
|
||||||
opacity: {
|
opacity: {
|
||||||
type: "timing",
|
type: 'timing',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
></Motion.View>
|
></Motion.View>
|
||||||
@@ -83,5 +83,5 @@ export default ({
|
|||||||
{children}
|
{children}
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
+33
-36
@@ -1,17 +1,17 @@
|
|||||||
import { Motion } from "@legendapp/motion";
|
import { Motion } from '@legendapp/motion'
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from 'expo-haptics'
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from 'react-native'
|
||||||
|
|
||||||
import { isDesktop, isMobile, isNative } from "../hooks/useLayoutType";
|
import { isDesktop, isMobile, isNative } from '../hooks/useLayoutType'
|
||||||
import { Fonts, Palette } from "../styles";
|
import { Fonts, Palette } from '../styles'
|
||||||
import Style, { gutters, mainBorderRadius } from "../styles/Style";
|
import Style, { gutters, mainBorderRadius } from '../styles/Style'
|
||||||
|
|
||||||
export default ({
|
export default ({
|
||||||
type = "primary",
|
type = 'primary',
|
||||||
theme = "default", // "default" | "radioactiv"
|
theme = 'default', // "default" | "radioactiv"
|
||||||
|
|
||||||
text,
|
text,
|
||||||
onPress = () => console.log("null"),
|
onPress = () => console.log('null'),
|
||||||
isAbsoluteBottom = false,
|
isAbsoluteBottom = false,
|
||||||
|
|
||||||
alternateAction = {},
|
alternateAction = {},
|
||||||
@@ -23,39 +23,39 @@ export default ({
|
|||||||
|
|
||||||
isMainDesktopPanel = false,
|
isMainDesktopPanel = false,
|
||||||
}) => {
|
}) => {
|
||||||
const buttonWidth = alternateAction?.text ? "49%" : "100%";
|
const buttonWidth = alternateAction?.text ? '49%' : '100%'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
justifyContent: alternateAction?.text ? "space-between" : "center",
|
justifyContent: alternateAction?.text ? 'space-between' : 'center',
|
||||||
...(isAbsoluteBottom
|
...(isAbsoluteBottom
|
||||||
? {
|
? {
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
bottom: gutters * (isMobile ? 2 : 1),
|
bottom: gutters * (isMobile ? 2 : 1),
|
||||||
alignItems: "flex-end",
|
alignItems: 'flex-end',
|
||||||
...(isDesktop && !isMainDesktopPanel
|
...(isDesktop && !isMainDesktopPanel
|
||||||
? {
|
? {
|
||||||
maxWidth: 600,
|
maxWidth: 600,
|
||||||
minWidth: 400,
|
minWidth: 400,
|
||||||
alignSelf: "center",
|
alignSelf: 'center',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
right: gutters,
|
right: gutters,
|
||||||
left: gutters,
|
left: gutters,
|
||||||
alignSelf: "center",
|
alignSelf: 'center',
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
width: "100%",
|
width: '100%',
|
||||||
}),
|
}),
|
||||||
...contentContainerStyle,
|
...contentContainerStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{alternateAction?.text && alternateAction?.onPress ? (
|
{alternateAction?.text && alternateAction?.onPress ? (
|
||||||
<BaseButton
|
<BaseButton
|
||||||
type={"secondary"}
|
type={'secondary'}
|
||||||
theme={alternateAction?.theme || theme}
|
theme={alternateAction?.theme || theme}
|
||||||
text={alternateAction.text}
|
text={alternateAction.text}
|
||||||
onPress={alternateAction.onPress}
|
onPress={alternateAction.onPress}
|
||||||
@@ -83,45 +83,42 @@ export default ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export const BaseButton = ({
|
export const BaseButton = ({
|
||||||
type = "primary",
|
type = 'primary',
|
||||||
theme = "default",
|
theme = 'default',
|
||||||
text,
|
text,
|
||||||
onPress = () => console.log("null"),
|
onPress = () => console.log('null'),
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
textStyle = {},
|
textStyle = {},
|
||||||
hasShadow = false,
|
hasShadow = false,
|
||||||
}) => {
|
}) => {
|
||||||
const primaryColor =
|
const primaryColor = theme === 'radioactiv' ? Palette.radioactivGreen : Palette.primary
|
||||||
theme === "radioactiv" ? Palette.radioactivGreen : Palette.primary;
|
|
||||||
const primaryTransparentColor =
|
const primaryTransparentColor =
|
||||||
theme === "radioactiv"
|
theme === 'radioactiv' ? Palette.transparentRadioactivGreen : Palette.transparentPrimary
|
||||||
? Palette.transparentRadioactivGreen
|
|
||||||
: Palette.transparentPrimary;
|
|
||||||
|
|
||||||
const textColor = type === "secondary" ? primaryColor : Palette.darkPurple;
|
const textColor = type === 'secondary' ? primaryColor : Palette.darkPurple
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Motion.Pressable
|
<Motion.Pressable
|
||||||
whileTap={{ scale: 0.8 }}
|
whileTap={{ scale: 0.8 }}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
||||||
}
|
}
|
||||||
onPress();
|
onPress()
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: '100%',
|
||||||
height: 50,
|
height: 50,
|
||||||
marginTop: gutters / 2,
|
marginTop: gutters / 2,
|
||||||
...Style.containerRow,
|
...Style.containerRow,
|
||||||
...Style.containerCenter,
|
...Style.containerCenter,
|
||||||
backgroundColor: primaryColor,
|
backgroundColor: primaryColor,
|
||||||
borderRadius: mainBorderRadius,
|
borderRadius: mainBorderRadius,
|
||||||
...(type === "secondary"
|
...(type === 'secondary'
|
||||||
? {
|
? {
|
||||||
backgroundColor: primaryTransparentColor,
|
backgroundColor: primaryTransparentColor,
|
||||||
}
|
}
|
||||||
@@ -134,7 +131,7 @@ export const BaseButton = ({
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
...Fonts({
|
...Fonts({
|
||||||
type: "default",
|
type: 'default',
|
||||||
color: textColor,
|
color: textColor,
|
||||||
}),
|
}),
|
||||||
...textStyle,
|
...textStyle,
|
||||||
@@ -143,5 +140,5 @@ export const BaseButton = ({
|
|||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
</Motion.Pressable>
|
</Motion.Pressable>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
+36
-36
@@ -1,32 +1,32 @@
|
|||||||
import { useState, useEffect } from "reactn";
|
import { useState, useEffect } from 'reactn'
|
||||||
import { Pressable, TextInput, View, Image, Platform } from "react-native";
|
import { Pressable, TextInput, View, Image, Platform } from 'react-native'
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
|
|
||||||
import { Fonts, gutters, Palette } from "../styles";
|
import { Fonts, gutters, Palette } from '../styles'
|
||||||
import Style from "../styles/Style";
|
import Style from '../styles/Style'
|
||||||
|
|
||||||
import { chatsRef } from "../config/firebase";
|
import { chatsRef } from '../config/firebase'
|
||||||
|
|
||||||
import { icons } from "../assets";
|
import { icons } from '../assets'
|
||||||
|
|
||||||
import { isWeb } from "../hooks/useLayoutType.js";
|
import { isWeb } from '../hooks/useLayoutType.js'
|
||||||
|
|
||||||
import DocumentDropZone from "./DocumentDropZone.js";
|
import DocumentDropZone from './DocumentDropZone.js'
|
||||||
import FilesPreview from "./FilesPreview.js";
|
import FilesPreview from './FilesPreview.js'
|
||||||
|
|
||||||
const ChatInput = ({
|
const ChatInput = ({
|
||||||
message,
|
message,
|
||||||
setMessage,
|
setMessage,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
placeholder = "",
|
placeholder = '',
|
||||||
chatID = null,
|
chatID = null,
|
||||||
}) => {
|
}) => {
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFiles([]);
|
setFiles([])
|
||||||
}, [chatID]);
|
}, [chatID])
|
||||||
|
|
||||||
const handleMessageObject = () => {
|
const handleMessageObject = () => {
|
||||||
if (message.length > 0 || files.length > 0) {
|
if (message.length > 0 || files.length > 0) {
|
||||||
@@ -34,37 +34,37 @@ const ChatInput = ({
|
|||||||
customPayload: {
|
customPayload: {
|
||||||
files,
|
files,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
setMessage("");
|
setMessage('')
|
||||||
setFiles([]);
|
setFiles([])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (e) => {
|
const handleKeyPress = (e) => {
|
||||||
if (e?.nativeEvent?.key?.toLowerCase() === "enter") {
|
if (e?.nativeEvent?.key?.toLowerCase() === 'enter') {
|
||||||
handleMessageObject();
|
handleMessageObject()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
...Style.containerItem,
|
...Style.containerItem,
|
||||||
backgroundColor: Palette.transparentDarkPurple,
|
backgroundColor: Palette.transparentDarkPurple,
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: Palette.ultraLightWhite,
|
borderColor: Palette.ultraLightWhite,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
width: "100%",
|
width: '100%',
|
||||||
height: "auto",
|
height: 'auto',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
...containerStyle,
|
...containerStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={Platform.OS !== "ios" ? 10 : 30}
|
intensity={Platform.OS !== 'ios' ? 10 : 30}
|
||||||
tint="dark"
|
tint="dark"
|
||||||
style={{ flex: 1, justifyContent: "center" }}
|
style={{ flex: 1, justifyContent: 'center' }}
|
||||||
// experimentalBlurMethod={
|
// experimentalBlurMethod={
|
||||||
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
||||||
// }
|
// }
|
||||||
@@ -101,13 +101,13 @@ const ChatInput = ({
|
|||||||
value={message}
|
value={message}
|
||||||
onChangeText={setMessage}
|
onChangeText={setMessage}
|
||||||
style={{
|
style={{
|
||||||
width: "85%",
|
width: '85%',
|
||||||
...Fonts({ type: "default", style: {} }),
|
...Fonts({ type: 'default', style: {} }),
|
||||||
}}
|
}}
|
||||||
keyboardAppearance="dark"
|
keyboardAppearance="dark"
|
||||||
{...(!isWeb
|
{...(!isWeb
|
||||||
? {
|
? {
|
||||||
returnKeyType: "send",
|
returnKeyType: 'send',
|
||||||
onSubmitEditing: onSendMessage,
|
onSubmitEditing: onSendMessage,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -118,13 +118,13 @@ const ChatInput = ({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleMessageObject}
|
onPress={handleMessageObject}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: 50,
|
width: 50,
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -138,7 +138,7 @@ const ChatInput = ({
|
|||||||
</View>
|
</View>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ChatInput;
|
export default ChatInput
|
||||||
|
|||||||
@@ -1,78 +1,66 @@
|
|||||||
import { useState, useRef, useEffect, useGlobal, getGlobal } from "reactn";
|
import { useState, useRef, useEffect, useGlobal, getGlobal } from 'reactn'
|
||||||
import {
|
import { Pressable, TextInput, View, Image, Text, Keyboard, FlatList } from 'react-native'
|
||||||
Pressable,
|
import { responsiveHeight } from '../actions/responsiveSizes.js'
|
||||||
TextInput,
|
import { useDataFromRef } from 'react-native-minuit/src/hooks'
|
||||||
View,
|
import { useKeyboard } from '@react-native-community/hooks'
|
||||||
Image,
|
import moment from 'moment'
|
||||||
Text,
|
|
||||||
Keyboard,
|
|
||||||
FlatList,
|
|
||||||
} from "react-native";
|
|
||||||
import { responsiveHeight } from "../actions/responsiveSizes.js";
|
|
||||||
import { useDataFromRef } from "react-native-minuit/src/hooks";
|
|
||||||
import { useKeyboard } from "@react-native-community/hooks";
|
|
||||||
import moment from "moment";
|
|
||||||
|
|
||||||
import { Fonts, gutters, Palette } from "../styles";
|
import { Fonts, gutters, Palette } from '../styles'
|
||||||
import Style, { bubbleStyle } from "../styles/Style";
|
import Style, { bubbleStyle } from '../styles/Style'
|
||||||
|
|
||||||
import firebase, { chatsRef } from "../config/firebase";
|
import firebase, { chatsRef } from '../config/firebase'
|
||||||
|
|
||||||
import { formatNameForConfidentiality } from "../helpers/index.js";
|
import { formatNameForConfidentiality } from '../helpers/index.js'
|
||||||
import useLayoutType from "../hooks/useLayoutType.js";
|
import useLayoutType from '../hooks/useLayoutType.js'
|
||||||
|
|
||||||
import TypingLoader from "./TypingLoader";
|
import TypingLoader from './TypingLoader'
|
||||||
import Avatar from "./Avatar";
|
import Avatar from './Avatar'
|
||||||
import RenderChatFile from "./RenderChatFile.js";
|
import RenderChatFile from './RenderChatFile.js'
|
||||||
import HyperlinkContainer from "./HyperlinkContainer.js";
|
import HyperlinkContainer from './HyperlinkContainer.js'
|
||||||
|
|
||||||
export default ({
|
export default ({
|
||||||
chatID = null,
|
chatID = null,
|
||||||
|
|
||||||
layout = "default", // default | taskSideBar
|
layout = 'default', // default | taskSideBar
|
||||||
|
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
messageListContainerStyle = {},
|
messageListContainerStyle = {},
|
||||||
}) => {
|
}) => {
|
||||||
const [currentUID] = useGlobal("currentUID");
|
const [currentUID] = useGlobal('currentUID')
|
||||||
const [currentProjectData] = useGlobal("currentProjectData");
|
const [currentProjectData] = useGlobal('currentProjectData')
|
||||||
|
|
||||||
const [isTyping] = useState(false);
|
const [isTyping] = useState(false)
|
||||||
|
|
||||||
const flatListRef = useRef();
|
const flatListRef = useRef()
|
||||||
|
|
||||||
const { isNative } = useLayoutType();
|
const { isNative } = useLayoutType()
|
||||||
const { keyboardShown = false } = useKeyboard();
|
const { keyboardShown = false } = useKeyboard()
|
||||||
|
|
||||||
const { data: messageList } = useDataFromRef({
|
const { data: messageList } = useDataFromRef({
|
||||||
ref: chatID
|
ref: chatID
|
||||||
? chatsRef
|
? chatsRef.doc(chatID).collection('messages').orderBy('createdAt', 'desc').limit(50)
|
||||||
.doc(chatID)
|
|
||||||
.collection("messages")
|
|
||||||
.orderBy("createdAt", "desc")
|
|
||||||
.limit(50)
|
|
||||||
: null,
|
: null,
|
||||||
simpleRef: false,
|
simpleRef: false,
|
||||||
listener: true,
|
listener: true,
|
||||||
condition: chatID,
|
condition: chatID,
|
||||||
refreshArray: [chatID],
|
refreshArray: [chatID],
|
||||||
documentID: "messageID",
|
documentID: 'messageID',
|
||||||
});
|
})
|
||||||
|
|
||||||
let conversation = [
|
let conversation = [
|
||||||
isTyping ? { senderID: "minuit.ai", userTyping: true } : null,
|
isTyping ? { senderID: 'minuit.ai', userTyping: true } : null,
|
||||||
...(messageList || []),
|
...(messageList || []),
|
||||||
].filter((item) => item);
|
].filter((item) => item)
|
||||||
|
|
||||||
if (layout === "default") {
|
if (layout === 'default') {
|
||||||
conversation = conversation.reverse();
|
conversation = conversation.reverse()
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (flatListRef?.current && isNative && keyboardShown) {
|
if (flatListRef?.current && isNative && keyboardShown) {
|
||||||
flatListRef?.current?.scrollToEnd?.({ animated: true });
|
flatListRef?.current?.scrollToEnd?.({ animated: true })
|
||||||
}
|
}
|
||||||
}, [keyboardShown, isNative]);
|
}, [keyboardShown, isNative])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -92,33 +80,30 @@ export default ({
|
|||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
keyExtractor={(item, index) =>
|
keyExtractor={(item, index) =>
|
||||||
item?.messageID
|
item?.messageID ? `${item?.messageID?.toString()}-${index}` : `no-messageID-${index}`
|
||||||
? `${item?.messageID?.toString()}-${index}`
|
|
||||||
: `no-messageID-${index}`
|
|
||||||
}
|
}
|
||||||
renderItem={({
|
renderItem={({
|
||||||
item: {
|
item: {
|
||||||
createdAt = null,
|
createdAt = null,
|
||||||
senderID,
|
senderID,
|
||||||
senderName = "",
|
senderName = '',
|
||||||
senderProfilePicture = null,
|
senderProfilePicture = null,
|
||||||
text = "",
|
text = '',
|
||||||
userTyping = false,
|
userTyping = false,
|
||||||
files = [],
|
files = [],
|
||||||
},
|
},
|
||||||
index,
|
index,
|
||||||
}) => {
|
}) => {
|
||||||
const isCurrentUser = senderID === currentUID;
|
const isCurrentUser = senderID === currentUID
|
||||||
const isChatbot = senderID === "minuit.ai";
|
const isChatbot = senderID === 'minuit.ai'
|
||||||
|
|
||||||
const senderData =
|
const senderData = currentProjectData?.teamMembers?.[senderID] || {}
|
||||||
currentProjectData?.teamMembers?.[senderID] || {};
|
|
||||||
|
|
||||||
const isDayChange =
|
const isDayChange =
|
||||||
moment(createdAt?.toDate()).format("DD/MM/YYYY") !==
|
moment(createdAt?.toDate()).format('DD/MM/YYYY') !==
|
||||||
moment(
|
moment(conversation[index - 1]?.createdAt?.toDate() || new Date()).format(
|
||||||
conversation[index - 1]?.createdAt?.toDate() || new Date()
|
'DD/MM/YYYY'
|
||||||
).format("DD/MM/YYYY") || !conversation[index - 1];
|
) || !conversation[index - 1]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -132,21 +117,19 @@ export default ({
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
...Fonts({
|
...Fonts({
|
||||||
type: "default",
|
type: 'default',
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
style: { textAlign: "center", opacity: 0.5 },
|
style: { textAlign: 'center', opacity: 0.5 },
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{moment(createdAt?.toDate()).format(
|
{moment(createdAt?.toDate()).format('[Le] DD/MM/YYYY [à] HH:mm')}
|
||||||
"[Le] DD/MM/YYYY [à] HH:mm"
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
...Style.separatorHorizontal,
|
...Style.separatorHorizontal,
|
||||||
width: "100%",
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -156,9 +139,9 @@ export default ({
|
|||||||
style={[
|
style={[
|
||||||
Style.containerRow,
|
Style.containerRow,
|
||||||
{
|
{
|
||||||
flexDirection: isCurrentUser ? "row-reverse" : "row",
|
flexDirection: isCurrentUser ? 'row-reverse' : 'row',
|
||||||
alignItems: "flex-end",
|
alignItems: 'flex-end',
|
||||||
width: "100%",
|
width: '100%',
|
||||||
marginBottom: gutters / 2,
|
marginBottom: gutters / 2,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -175,21 +158,15 @@ export default ({
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
marginRight: gutters / 2,
|
marginRight: gutters / 2,
|
||||||
backgroundColor: "transparent",
|
backgroundColor: 'transparent',
|
||||||
borderColor: Palette.primary,
|
borderColor: Palette.primary,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={
|
name={isChatbot ? 'm' : senderData?.name || senderName || ''}
|
||||||
isChatbot ? "m" : senderData?.name || senderName || ""
|
url={senderData?.profilePictureURL || senderProfilePicture || null}
|
||||||
}
|
|
||||||
url={
|
|
||||||
senderData?.profilePictureURL ||
|
|
||||||
senderProfilePicture ||
|
|
||||||
null
|
|
||||||
}
|
|
||||||
size={35}
|
size={35}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -197,7 +174,7 @@ export default ({
|
|||||||
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
alignItems: isCurrentUser ? "flex-end" : "flex-start",
|
alignItems: isCurrentUser ? 'flex-end' : 'flex-start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{files?.map((props, index) => (
|
{files?.map((props, index) => (
|
||||||
@@ -232,11 +209,11 @@ export default ({
|
|||||||
<HyperlinkContainer>
|
<HyperlinkContainer>
|
||||||
<Text
|
<Text
|
||||||
style={Fonts({
|
style={Fonts({
|
||||||
type: "default",
|
type: 'default',
|
||||||
style: {
|
style: {
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
textAlign: isCurrentUser ? "right" : "left",
|
textAlign: isCurrentUser ? 'right' : 'left',
|
||||||
width: "100%",
|
width: '100%',
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@@ -249,30 +226,29 @@ export default ({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export const onSendMessage = async ({
|
export const onSendMessage = async ({
|
||||||
chatID = null,
|
chatID = null,
|
||||||
projectID = null,
|
projectID = null,
|
||||||
message = "",
|
message = '',
|
||||||
setMessage = () => {},
|
setMessage = () => {},
|
||||||
setIsTyping = () => {},
|
setIsTyping = () => {},
|
||||||
customPayload = {},
|
customPayload = {},
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const currentUID = getGlobal()?.currentUID || null;
|
const currentUID = getGlobal()?.currentUID || null
|
||||||
const { name = "", profilePictureURL = null } =
|
const { name = '', profilePictureURL = null } = getGlobal()?.currentUserData || {}
|
||||||
getGlobal()?.currentUserData || {};
|
|
||||||
|
|
||||||
if (message.length > 0 || customPayload?.files?.length > 0) {
|
if (message.length > 0 || customPayload?.files?.length > 0) {
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss()
|
||||||
setMessage("");
|
setMessage('')
|
||||||
|
|
||||||
const messageData = {
|
const messageData = {
|
||||||
projectID,
|
projectID,
|
||||||
@@ -282,13 +258,13 @@ export const onSendMessage = async ({
|
|||||||
senderProfilePicture: profilePictureURL || null,
|
senderProfilePicture: profilePictureURL || null,
|
||||||
text: message,
|
text: message,
|
||||||
...customPayload,
|
...customPayload,
|
||||||
};
|
}
|
||||||
|
|
||||||
await chatsRef.doc(chatID).collection("messages").add(messageData);
|
await chatsRef.doc(chatID).collection('messages').add(messageData)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsTyping(false);
|
setIsTyping(false)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Image } from "react-native";
|
import { Image } from 'react-native'
|
||||||
import { icons } from "../assets";
|
import { icons } from '../assets'
|
||||||
|
|
||||||
const CoinIcon = ({ size = 22, style }) => {
|
const CoinIcon = ({ size = 22, style }) => {
|
||||||
return (
|
return (
|
||||||
@@ -10,12 +10,12 @@ const CoinIcon = ({ size = 22, style }) => {
|
|||||||
{
|
{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
resizeMode: "contain",
|
resizeMode: 'contain',
|
||||||
},
|
},
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default CoinIcon;
|
export default CoinIcon
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Pressable, Text } from "react-native";
|
import { Pressable, Text } from 'react-native'
|
||||||
import { Routes } from "../navigation";
|
import { Routes } from '../navigation'
|
||||||
import { navigate } from "../navigation/NavigationService";
|
import { navigate } from '../navigation/NavigationService'
|
||||||
import { Palette } from "../styles";
|
import { Palette } from '../styles'
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
import { FONT_FAMILY } from '../styles/Fonts'
|
||||||
export default function ConnectBtn({ style }) {
|
export default function ConnectBtn({ style }) {
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
navigate(Routes.Login);
|
navigate(Routes.Login)
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -22,9 +22,9 @@ export default function ConnectBtn({ style }) {
|
|||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 5,
|
paddingVertical: 5,
|
||||||
borderRadius: 15,
|
borderRadius: 15,
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
@@ -35,9 +35,9 @@ export default function ConnectBtn({ style }) {
|
|||||||
marginRight: 5,
|
marginRight: 5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{"Se connecter"}
|
{'Se connecter'}
|
||||||
</Text>
|
</Text>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
import React from "react";
|
import React from 'react'
|
||||||
import { StyleSheet, Text, View } from "react-native";
|
import { StyleSheet, Text, View } from 'react-native'
|
||||||
import CoinIcon from "./CoinIcon";
|
import CoinIcon from './CoinIcon'
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
import { FONT_FAMILY } from '../styles/Fonts'
|
||||||
|
|
||||||
const defaultFormatOptions = {
|
const defaultFormatOptions = {
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
};
|
}
|
||||||
|
|
||||||
const formatAmount = (value, options = defaultFormatOptions) => {
|
const formatAmount = (value, options = defaultFormatOptions) => {
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat('fr-FR', {
|
||||||
...defaultFormatOptions,
|
...defaultFormatOptions,
|
||||||
...options,
|
...options,
|
||||||
}).format(value);
|
}).format(value)
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return `${value}`;
|
return `${value}`
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const extractNumericValue = (value) => {
|
const extractNumericValue = (value) => {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
return value;
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "string") {
|
if (typeof value === 'string') {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value)
|
||||||
if (Number.isFinite(parsed)) {
|
if (Number.isFinite(parsed)) {
|
||||||
return parsed;
|
return parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|
||||||
const CreditAmount = ({
|
const CreditAmount = ({
|
||||||
value,
|
value,
|
||||||
@@ -44,78 +44,62 @@ const CreditAmount = ({
|
|||||||
textStyle,
|
textStyle,
|
||||||
iconSize = 18,
|
iconSize = 18,
|
||||||
iconStyle,
|
iconStyle,
|
||||||
iconPosition = "right",
|
iconPosition = 'right',
|
||||||
gap = 6,
|
gap = 6,
|
||||||
showPlus = false,
|
showPlus = false,
|
||||||
formatterOptions,
|
formatterOptions,
|
||||||
accessibilityLabel,
|
accessibilityLabel,
|
||||||
}) => {
|
}) => {
|
||||||
const numericValue = React.useMemo(
|
const numericValue = React.useMemo(() => extractNumericValue(value), [value])
|
||||||
() => extractNumericValue(value),
|
|
||||||
[value],
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolvedValue =
|
const resolvedValue = numericValue !== null ? numericValue : (value ?? 0)
|
||||||
numericValue !== null
|
|
||||||
? numericValue
|
|
||||||
: value ?? 0;
|
|
||||||
|
|
||||||
const formattedValue =
|
const formattedValue =
|
||||||
numericValue !== null
|
numericValue !== null
|
||||||
? formatAmount(resolvedValue, formatterOptions) ?? `${resolvedValue}`
|
? (formatAmount(resolvedValue, formatterOptions) ?? `${resolvedValue}`)
|
||||||
: typeof resolvedValue === "string"
|
: typeof resolvedValue === 'string'
|
||||||
? resolvedValue
|
? resolvedValue
|
||||||
: `${resolvedValue}`;
|
: `${resolvedValue}`
|
||||||
|
|
||||||
const prefix =
|
const prefix = numericValue !== null && showPlus && numericValue > 0 ? '+' : ''
|
||||||
numericValue !== null && showPlus && numericValue > 0 ? "+" : "";
|
|
||||||
|
|
||||||
const a11yLabel =
|
const a11yLabel = accessibilityLabel || `${prefix}${formattedValue} pièces`
|
||||||
accessibilityLabel || `${prefix}${formattedValue} pièces`;
|
|
||||||
|
|
||||||
const containerStyles = Array.isArray(style)
|
const containerStyles = Array.isArray(style)
|
||||||
? [styles.container, { gap }, ...style]
|
? [styles.container, { gap }, ...style]
|
||||||
: [styles.container, { gap }, style];
|
: [styles.container, { gap }, style]
|
||||||
|
|
||||||
const textStyles = Array.isArray(textStyle)
|
const textStyles = Array.isArray(textStyle)
|
||||||
? [styles.value, ...textStyle]
|
? [styles.value, ...textStyle]
|
||||||
: [styles.value, textStyle];
|
: [styles.value, textStyle]
|
||||||
|
|
||||||
const iconStyles = Array.isArray(iconStyle)
|
const iconStyles = Array.isArray(iconStyle)
|
||||||
? [styles.icon, ...iconStyle]
|
? [styles.icon, ...iconStyle]
|
||||||
: [styles.icon, iconStyle];
|
: [styles.icon, iconStyle]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View style={containerStyles} accessibilityRole="text" accessibilityLabel={a11yLabel}>
|
||||||
style={containerStyles}
|
{iconPosition === 'left' ? <CoinIcon size={iconSize} style={iconStyles} /> : null}
|
||||||
accessibilityRole="text"
|
|
||||||
accessibilityLabel={a11yLabel}
|
|
||||||
>
|
|
||||||
{iconPosition === "left" ? (
|
|
||||||
<CoinIcon size={iconSize} style={iconStyles} />
|
|
||||||
) : null}
|
|
||||||
<Text style={textStyles}>{`${prefix}${formattedValue}`}</Text>
|
<Text style={textStyles}>{`${prefix}${formattedValue}`}</Text>
|
||||||
{iconPosition === "right" ? (
|
{iconPosition === 'right' ? <CoinIcon size={iconSize} style={iconStyles} /> : null}
|
||||||
<CoinIcon size={iconSize} style={iconStyles} />
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
value: {
|
value: {
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: "#fff",
|
color: '#fff',
|
||||||
},
|
},
|
||||||
icon: {
|
icon: {
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
export default CreditAmount;
|
export default CreditAmount
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import Dialog from "react-native-dialog";
|
import Dialog from 'react-native-dialog'
|
||||||
|
|
||||||
export const Container = Dialog.Container;
|
|
||||||
export const Button = Dialog.Button;
|
|
||||||
export const Title = Dialog.Title;
|
|
||||||
export const Input = Dialog.Input;
|
|
||||||
export const Description = Dialog.Description;
|
|
||||||
|
|
||||||
|
export const Container = Dialog.Container
|
||||||
|
export const Button = Dialog.Button
|
||||||
|
export const Title = Dialog.Title
|
||||||
|
export const Input = Dialog.Input
|
||||||
|
export const Description = Dialog.Description
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from 'react-native'
|
||||||
|
|
||||||
import { Input as MinuitInput } from "../Input";
|
import { Input as MinuitInput } from '../Input'
|
||||||
import { BaseButton as MinuitButton } from "../Button";
|
import { BaseButton as MinuitButton } from '../Button'
|
||||||
import Overlay from "../Overlay";
|
import Overlay from '../Overlay'
|
||||||
|
|
||||||
import { Fonts, Palette, gutters } from "../../styles";
|
import { Fonts, Palette, gutters } from '../../styles'
|
||||||
import Style from "../../styles/Style";
|
import Style from '../../styles/Style'
|
||||||
|
|
||||||
export const Container = ({ visible, setVisible = () => null, children }) => {
|
export const Container = ({ visible, setVisible = () => null, children }) => {
|
||||||
return (
|
return (
|
||||||
<Overlay isVisible={visible}>
|
<Overlay isVisible={visible}>
|
||||||
<View style={{ ...Style.containerModal }}>{children}</View>
|
<View style={{ ...Style.containerModal }}>{children}</View>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export const Title = ({ children }) => {
|
export const Title = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
...Fonts({
|
...Fonts({
|
||||||
type: "title",
|
type: 'title',
|
||||||
style: {
|
style: {
|
||||||
textAlign: "center",
|
textAlign: 'center',
|
||||||
marginBottom: gutters,
|
marginBottom: gutters,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -31,17 +31,17 @@ export const Title = ({ children }) => {
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export const Description = ({ children }) => {
|
export const Description = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
...Fonts({
|
...Fonts({
|
||||||
type: "default",
|
type: 'default',
|
||||||
style: {
|
style: {
|
||||||
textAlign: "center",
|
textAlign: 'center',
|
||||||
marginBottom: gutters / 2,
|
marginBottom: gutters / 2,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -49,29 +49,24 @@ export const Description = ({ children }) => {
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export const Button = ({
|
export const Button = ({ label, onPress, type = 'primary', containerStyle = {} }) => {
|
||||||
label,
|
|
||||||
onPress,
|
|
||||||
type = "primary",
|
|
||||||
containerStyle = {},
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<MinuitButton
|
<MinuitButton
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
width: "100%",
|
width: '100%',
|
||||||
alignSelf: "center",
|
alignSelf: 'center',
|
||||||
...containerStyle,
|
...containerStyle,
|
||||||
}}
|
}}
|
||||||
text={label}
|
text={label}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
type={type}
|
type={type}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export const Input = (props) => {
|
export const Input = (props) => {
|
||||||
return <MinuitInput {...props} setValue={props.onChangeText} />;
|
return <MinuitInput {...props} setValue={props.onChangeText} />
|
||||||
};
|
}
|
||||||
|
|||||||
+154
-156
@@ -1,27 +1,27 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from '@expo/react-native-action-sheet'
|
||||||
import * as DocumentPicker from "expo-document-picker";
|
import * as DocumentPicker from 'expo-document-picker'
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from 'expo-image-picker'
|
||||||
import moment from "moment";
|
import moment from 'moment'
|
||||||
import { Image, Pressable, Text, View } from "react-native";
|
import { Image, Pressable, Text, View } from 'react-native'
|
||||||
import Compressor from "react-native-compressor";
|
import Compressor from 'react-native-compressor'
|
||||||
import useMinuit from "react-native-minuit/src/hooks/useMinuit";
|
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "reactn";
|
import React, { useCallback, useEffect, useRef, useState } from 'reactn'
|
||||||
|
|
||||||
import { arrayUnion } from "../config/firebase";
|
import { arrayUnion } from '../config/firebase'
|
||||||
|
|
||||||
import { icons } from "../assets";
|
import { icons } from '../assets'
|
||||||
import { Fonts, Palette } from "../styles";
|
import { Fonts, Palette } from '../styles'
|
||||||
import Style, { gutterConstant, mainBorderRadius } from "../styles/Style";
|
import Style, { gutterConstant, mainBorderRadius } from '../styles/Style'
|
||||||
|
|
||||||
import { uploadFileToFirebase } from "../helpers/uploadToFirebase";
|
import { uploadFileToFirebase } from '../helpers/uploadToFirebase'
|
||||||
import useLayoutType from "../hooks/useLayoutType";
|
import useLayoutType from '../hooks/useLayoutType'
|
||||||
|
|
||||||
const NATIVE_OPTIONS = [
|
const NATIVE_OPTIONS = [
|
||||||
"Importer depuis la galerie",
|
'Importer depuis la galerie',
|
||||||
"Ajouter un fichier",
|
'Ajouter un fichier',
|
||||||
"Prendre une photo ou une vidéo",
|
'Prendre une photo ou une vidéo',
|
||||||
"Annuler",
|
'Annuler',
|
||||||
];
|
]
|
||||||
|
|
||||||
const DocumentDropZone = ({
|
const DocumentDropZone = ({
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
@@ -35,162 +35,162 @@ const DocumentDropZone = ({
|
|||||||
customElement = null,
|
customElement = null,
|
||||||
shouldReturnObject = false,
|
shouldReturnObject = false,
|
||||||
}) => {
|
}) => {
|
||||||
const dropRef = useRef(null);
|
const dropRef = useRef(null)
|
||||||
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
const { setIsLoading, setTooltip } = useMinuit();
|
const { setIsLoading, setTooltip } = useMinuit()
|
||||||
const { isDesktop, isWeb, isNative } = useLayoutType();
|
const { isDesktop, isWeb, isNative } = useLayoutType()
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb && dropRef.current) {
|
if (isWeb && dropRef.current) {
|
||||||
const el = dropRef.current;
|
const el = dropRef.current
|
||||||
|
|
||||||
const handleDragIn = (e) => {
|
const handleDragIn = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
setIsDragging(true);
|
setIsDragging(true)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleDragOut = (e) => {
|
const handleDragOut = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
if (!dropRef.current.contains(e.relatedTarget)) {
|
if (!dropRef.current.contains(e.relatedTarget)) {
|
||||||
console.log("drag left");
|
console.log('drag left')
|
||||||
setIsDragging(false);
|
setIsDragging(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e) => {
|
const handleDrop = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
setIsDragging(false);
|
setIsDragging(false)
|
||||||
|
|
||||||
let files = [...e.dataTransfer.files];
|
let files = [...e.dataTransfer.files]
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
handleWebFile({ file });
|
handleWebFile({ file })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
el.addEventListener("dragenter", handleDragIn);
|
el.addEventListener('dragenter', handleDragIn)
|
||||||
el.addEventListener("dragleave", handleDragOut);
|
el.addEventListener('dragleave', handleDragOut)
|
||||||
el.addEventListener("dragover", (e) => e.preventDefault());
|
el.addEventListener('dragover', (e) => e.preventDefault())
|
||||||
el.addEventListener("drop", handleDrop);
|
el.addEventListener('drop', handleDrop)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener("dragenter", handleDragIn);
|
el.removeEventListener('dragenter', handleDragIn)
|
||||||
el.removeEventListener("dragleave", handleDragOut);
|
el.removeEventListener('dragleave', handleDragOut)
|
||||||
el.removeEventListener("dragover", (e) => e.preventDefault());
|
el.removeEventListener('dragover', (e) => e.preventDefault())
|
||||||
el.removeEventListener("drop", handleDrop);
|
el.removeEventListener('drop', handleDrop)
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}, [dropRef?.current]);
|
}
|
||||||
|
}, [dropRef?.current])
|
||||||
|
|
||||||
const handleWebFile = ({ file }) => {
|
const handleWebFile = ({ file }) => {
|
||||||
try {
|
try {
|
||||||
const { type = "" } = file;
|
const { type = '' } = file
|
||||||
const reader = new FileReader();
|
const reader = new FileReader()
|
||||||
|
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
const base64 = reader.result.split(",")[1]; // Vous pourriez avoir besoin de cette valeur base64 pour un upload direct
|
const base64 = reader.result.split(',')[1] // Vous pourriez avoir besoin de cette valeur base64 pour un upload direct
|
||||||
const uri = reader.result; // URI en base64 du fichier
|
const uri = reader.result // URI en base64 du fichier
|
||||||
|
|
||||||
console.log("file", file);
|
console.log('file', file)
|
||||||
|
|
||||||
onUploadDocument({ files: [{ name: file.name, uri, type }] });
|
onUploadDocument({ files: [{ name: file.name, uri, type }] })
|
||||||
};
|
}
|
||||||
|
|
||||||
reader.onerror = (err) => {
|
reader.onerror = (err) => {
|
||||||
console.error("FileReader error", err);
|
console.error('FileReader error', err)
|
||||||
};
|
}
|
||||||
|
|
||||||
reader.readAsDataURL(file); // Lire le fichier et déclencher reader.onloadend lorsque c'est fait
|
reader.readAsDataURL(file) // Lire le fichier et déclencher reader.onloadend lorsque c'est fait
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error)
|
||||||
setTooltip({ text: error.message, type: "error" });
|
setTooltip({ text: error.message, type: 'error' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const onAddFile = async ({} = {}) => {
|
const onAddFile = async ({} = {}) => {
|
||||||
try {
|
try {
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
const cancelButtonIndex = NATIVE_OPTIONS.length - 1;
|
const cancelButtonIndex = NATIVE_OPTIONS.length - 1
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
options: NATIVE_OPTIONS,
|
options: NATIVE_OPTIONS,
|
||||||
cancelButtonIndex,
|
cancelButtonIndex,
|
||||||
userInterfaceStyle: "dark",
|
userInterfaceStyle: 'dark',
|
||||||
...Style.actionSheet,
|
...Style.actionSheet,
|
||||||
},
|
},
|
||||||
async (selectedIndex) => {
|
async (selectedIndex) => {
|
||||||
if (selectedIndex !== cancelButtonIndex) {
|
if (selectedIndex !== cancelButtonIndex) {
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
onChooseLibrary();
|
onChooseLibrary()
|
||||||
break;
|
break
|
||||||
case 1:
|
case 1:
|
||||||
onChooseDocumentPicker();
|
onChooseDocumentPicker()
|
||||||
break;
|
break
|
||||||
case 2:
|
case 2:
|
||||||
onTakePicture();
|
onTakePicture()
|
||||||
break;
|
break
|
||||||
default:
|
default:
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
} else {
|
} else {
|
||||||
onChooseDocumentPicker();
|
onChooseDocumentPicker()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error)
|
||||||
setTooltip({ text: error.message, type: "error" });
|
setTooltip({ text: error.message, type: 'error' })
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const onChooseLibrary = async ({} = {}) => {
|
const onChooseLibrary = async ({} = {}) => {
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||||
allowsEditing: false,
|
allowsEditing: false,
|
||||||
quality: 3,
|
quality: 3,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (result?.assets?.[0]?.uri) {
|
if (result?.assets?.[0]?.uri) {
|
||||||
const { uri, fileName = "" } = result?.assets?.[0];
|
const { uri, fileName = '' } = result?.assets?.[0]
|
||||||
|
|
||||||
onUploadDocument({
|
onUploadDocument({
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
name: getAssetName({ fileName, uri }),
|
name: getAssetName({ fileName, uri }),
|
||||||
uri,
|
uri,
|
||||||
type: "IMAGE",
|
type: 'IMAGE',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Aucune image sélectionnée");
|
throw new Error('Aucune image sélectionnée')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const onChooseDocumentPicker = async ({} = {}) => {
|
const onChooseDocumentPicker = async ({} = {}) => {
|
||||||
const result = await DocumentPicker.getDocumentAsync({
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
type: "*/*",
|
type: '*/*',
|
||||||
copyToCacheDirectory: false,
|
copyToCacheDirectory: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (result?.assets?.length > 0) {
|
if (result?.assets?.length > 0) {
|
||||||
const { name = "", uri = null } = result?.assets[0] || {};
|
const { name = '', uri = null } = result?.assets[0] || {}
|
||||||
|
|
||||||
if (!uri) {
|
if (!uri) {
|
||||||
throw new Error("Erreur lors de l'ajout du document");
|
throw new Error("Erreur lors de l'ajout du document")
|
||||||
}
|
}
|
||||||
|
|
||||||
onUploadDocument({
|
onUploadDocument({
|
||||||
@@ -200,117 +200,117 @@ const DocumentDropZone = ({
|
|||||||
uri,
|
uri,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
} else {
|
} else {
|
||||||
console.log(result);
|
console.log(result)
|
||||||
throw new Error("Erreur lors de l'ajout du document");
|
throw new Error("Erreur lors de l'ajout du document")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const onTakePicture = useCallback(async () => {
|
const onTakePicture = useCallback(async () => {
|
||||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
const { status } = await ImagePicker.requestCameraPermissionsAsync()
|
||||||
|
|
||||||
if (status !== "granted") {
|
if (status !== 'granted') {
|
||||||
throw new Error("Permissions caméra non accordées");
|
throw new Error('Permissions caméra non accordées')
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await ImagePicker.launchCameraAsync({
|
const result = await ImagePicker.launchCameraAsync({
|
||||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||||
allowsEditing: true,
|
allowsEditing: true,
|
||||||
quality: 1,
|
quality: 1,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (result?.assets?.[0]?.uri) {
|
if (result?.assets?.[0]?.uri) {
|
||||||
const { uri, fileName = "" } = result?.assets?.[0];
|
const { uri, fileName = '' } = result?.assets?.[0]
|
||||||
|
|
||||||
onUploadDocument({
|
onUploadDocument({
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
name: getAssetName({ fileName, uri }),
|
name: getAssetName({ fileName, uri }),
|
||||||
uri,
|
uri,
|
||||||
type: "IMAGE",
|
type: 'IMAGE',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Aucune image sélectionnée");
|
throw new Error('Aucune image sélectionnée')
|
||||||
}
|
}
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const getDefaultFileName = () => {
|
const getDefaultFileName = () => {
|
||||||
const randomID = Math.random().toString(36).substring(7);
|
const randomID = Math.random().toString(36).substring(7)
|
||||||
return `${moment().format(`DD_MM_YYYY_HH_mm_ss`)}_${randomID}`;
|
return `${moment().format(`DD_MM_YYYY_HH_mm_ss`)}_${randomID}`
|
||||||
};
|
}
|
||||||
|
|
||||||
const getAssetName = ({ fileName = "", uri = "" }) => {
|
const getAssetName = ({ fileName = '', uri = '' }) => {
|
||||||
let name = fileName;
|
let name = fileName
|
||||||
|
|
||||||
if (!name && uri?.startsWith("file://")) {
|
if (!name && uri?.startsWith('file://')) {
|
||||||
const splittedArray = uri?.split("/") || [];
|
const splittedArray = uri?.split('/') || []
|
||||||
name = splittedArray?.[splittedArray?.length - 1] || "";
|
name = splittedArray?.[splittedArray?.length - 1] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name?.length) {
|
if (!name?.length) {
|
||||||
let extension = uri?.split(";")?.[0]?.split("/")?.[1] || "";
|
let extension = uri?.split(';')?.[0]?.split('/')?.[1] || ''
|
||||||
|
|
||||||
const defaultFileName = getDefaultFileName();
|
const defaultFileName = getDefaultFileName()
|
||||||
|
|
||||||
if (extension?.length) {
|
if (extension?.length) {
|
||||||
name = `${defaultFileName}.${extension}`;
|
name = `${defaultFileName}.${extension}`
|
||||||
} else {
|
} else {
|
||||||
name = `${defaultFileName}`;
|
name = `${defaultFileName}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return name;
|
return name
|
||||||
};
|
}
|
||||||
|
|
||||||
const onUploadDocument = async ({ files = [] } = {}) => {
|
const onUploadDocument = async ({ files = [] } = {}) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true)
|
||||||
|
|
||||||
const filesToUpdate = [];
|
const filesToUpdate = []
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
files.map(async (file) => {
|
files.map(async (file) => {
|
||||||
const { name = "", uri = null } = file;
|
const { name = '', uri = null } = file
|
||||||
|
|
||||||
console.log(file);
|
console.log(file)
|
||||||
|
|
||||||
if (!uri) {
|
if (!uri) {
|
||||||
throw new Error("Erreur lors de l'ajout du document");
|
throw new Error("Erreur lors de l'ajout du document")
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = name || getDefaultFileName();
|
const fileName = name || getDefaultFileName()
|
||||||
let type = "FILE";
|
let type = 'FILE'
|
||||||
|
|
||||||
if (name?.toLowerCase()?.match(/\.(jpeg|jpg|gif|png)$/) != null) {
|
if (name?.toLowerCase()?.match(/\.(jpeg|jpg|gif|png)$/) != null) {
|
||||||
type = "IMAGE";
|
type = 'IMAGE'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name?.toLowerCase()?.match(/\.(mp4|mov|avi|mkv)$/) != null) {
|
if (name?.toLowerCase()?.match(/\.(mp4|mov|avi|mkv)$/) != null) {
|
||||||
type = "VIDEO";
|
type = 'VIDEO'
|
||||||
}
|
}
|
||||||
|
|
||||||
let compressedURI = uri;
|
let compressedURI = uri
|
||||||
let thumbnailURI = null;
|
let thumbnailURI = null
|
||||||
|
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
if (type === "IMAGE") {
|
if (type === 'IMAGE') {
|
||||||
compressedURI = await Compressor.Image.compress(uri, {
|
compressedURI = await Compressor.Image.compress(uri, {
|
||||||
compressionMethod: "manual",
|
compressionMethod: 'manual',
|
||||||
maxWidth: 1000,
|
maxWidth: 1000,
|
||||||
quality: 0.8,
|
quality: 0.8,
|
||||||
});
|
})
|
||||||
} else if (type === "VIDEO") {
|
} else if (type === 'VIDEO') {
|
||||||
compressedURI = await Compressor.Video.compress(uri);
|
compressedURI = await Compressor.Video.compress(uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { resultURI = null } = await uploadFileToFirebase({
|
const { resultURI = null } = await uploadFileToFirebase({
|
||||||
uri: compressedURI,
|
uri: compressedURI,
|
||||||
path: `documents/${documentID}/files/${fileName}`,
|
path: `documents/${documentID}/files/${fileName}`,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (resultURI) {
|
if (resultURI) {
|
||||||
if (shouldReturnObject) {
|
if (shouldReturnObject) {
|
||||||
@@ -319,39 +319,39 @@ const DocumentDropZone = ({
|
|||||||
uri: resultURI,
|
uri: resultURI,
|
||||||
type,
|
type,
|
||||||
thumbnailURI,
|
thumbnailURI,
|
||||||
});
|
})
|
||||||
} else {
|
} else {
|
||||||
filesToUpdate.push(resultURI);
|
filesToUpdate.push(resultURI)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("resultURI is null");
|
console.log('resultURI is null')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
if (filesToUpdate.length > 0) {
|
if (filesToUpdate.length > 0) {
|
||||||
if (documentExists) {
|
if (documentExists) {
|
||||||
await collectionRef.doc(documentID).update({
|
await collectionRef.doc(documentID).update({
|
||||||
files: arrayUnion(...filesToUpdate),
|
files: arrayUnion(...filesToUpdate),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setFiles) {
|
if (setFiles) {
|
||||||
setFiles((prev) => [...prev, ...filesToUpdate]);
|
setFiles((prev) => [...prev, ...filesToUpdate])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTooltip({ text: "Document(s) ajouté(s) avec succès" });
|
setTooltip({ text: 'Document(s) ajouté(s) avec succès' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error)
|
||||||
setTooltip({ text: error.message, type: "error" });
|
setTooltip({ text: error.message, type: 'error' })
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (!documentID) {
|
if (!documentID) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -361,11 +361,9 @@ const DocumentDropZone = ({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
...Style.containerCenter,
|
...Style.containerCenter,
|
||||||
backgroundColor: isDragging
|
backgroundColor: isDragging ? Palette.transparentGreen : Palette.transparentPrimary,
|
||||||
? Palette.transparentGreen
|
|
||||||
: Palette.transparentPrimary,
|
|
||||||
borderRadius: mainBorderRadius,
|
borderRadius: mainBorderRadius,
|
||||||
borderStyle: "dashed",
|
borderStyle: 'dashed',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: isDragging ? Palette.green : Palette.primary,
|
borderColor: isDragging ? Palette.green : Palette.primary,
|
||||||
...containerStyle,
|
...containerStyle,
|
||||||
@@ -380,20 +378,20 @@ const DocumentDropZone = ({
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
...Fonts({ type: "default" }),
|
...Fonts({ type: 'default' }),
|
||||||
textAlign: "center",
|
textAlign: 'center',
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isWeb && isDesktop
|
{isWeb && isDesktop
|
||||||
? `Glissez-déposez ici\nles fichiers, images et vidéos\nà ajouter.`
|
? `Glissez-déposez ici\nles fichiers, images et vidéos\nà ajouter.`
|
||||||
: "Ajouter une image,\nun fichier ou une vidéo."}
|
: 'Ajouter une image,\nun fichier ou une vidéo.'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default DocumentDropZone;
|
export default DocumentDropZone
|
||||||
|
|||||||
@@ -1,40 +1,34 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from 'react'
|
||||||
import { View } from "react-native";
|
import { View } from 'react-native'
|
||||||
import { Image } from "expo-image";
|
import { Image } from 'expo-image'
|
||||||
|
|
||||||
const DynamicImage = ({ uri, children, ...props }) => {
|
const DynamicImage = ({ uri, children, ...props }) => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true)
|
||||||
const [numRetries, setNumRetries] = useState(0);
|
const [numRetries, setNumRetries] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (numRetries < 15) {
|
if (numRetries < 15) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setLoading(true);
|
setLoading(true)
|
||||||
}, 2500);
|
}, 2500)
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [numRetries]);
|
}, [numRetries])
|
||||||
|
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
setNumRetries(numRetries + 1);
|
setNumRetries(numRetries + 1)
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
{loading && numRetries < 15 && uri ? (
|
{loading && numRetries < 15 && uri ? (
|
||||||
<Image
|
<Image source={uri} contentFit="cover" transition={500} {...props} onError={handleError} />
|
||||||
source={uri}
|
|
||||||
contentFit="cover"
|
|
||||||
transition={500}
|
|
||||||
{...props}
|
|
||||||
onError={handleError}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<View {...props}>{children}</View>
|
<View {...props}>{children}</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default DynamicImage;
|
export default DynamicImage
|
||||||
|
|||||||
@@ -1,60 +1,54 @@
|
|||||||
import React, { useGlobal } from "reactn";
|
import React, { useGlobal } from 'reactn'
|
||||||
import { View, Text } from "react-native";
|
import { View, Text } from 'react-native'
|
||||||
|
|
||||||
import useLayoutType from "../hooks/useLayoutType";
|
import useLayoutType from '../hooks/useLayoutType'
|
||||||
|
|
||||||
import Button from "./Button";
|
import Button from './Button'
|
||||||
|
|
||||||
import { Style, Fonts } from "../styles";
|
import { Style, Fonts } from '../styles'
|
||||||
import { responsiveScreenHeight } from "react-native-responsive-dimensions";
|
import { responsiveScreenHeight } from 'react-native-responsive-dimensions'
|
||||||
|
|
||||||
const EmptyFlashListPlaceholder = ({
|
const EmptyFlashListPlaceholder = ({ loading = false, text = '-', buttonData = {} }) => {
|
||||||
loading = false,
|
const [, setShowSearch] = useGlobal('showSearch')
|
||||||
text = "-",
|
|
||||||
buttonData = {},
|
|
||||||
}) => {
|
|
||||||
const [, setShowSearch] = useGlobal("showSearch");
|
|
||||||
|
|
||||||
const { isMobile } = useLayoutType;
|
const { isMobile } = useLayoutType
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Text style={Fonts({ type: "default", style: { textAlign: "center" } })}>
|
<Text style={Fonts({ type: 'default', style: { textAlign: 'center' } })}>
|
||||||
Chargement en cours...
|
Chargement en cours...
|
||||||
</Text>
|
</Text>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
...Style.containerCenter,
|
...Style.containerCenter,
|
||||||
alignSelf: "center",
|
alignSelf: 'center',
|
||||||
width: isMobile ? "100%" : "50%",
|
width: isMobile ? '100%' : '50%',
|
||||||
height: responsiveScreenHeight(50),
|
height: responsiveScreenHeight(50),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={Fonts({ type: "default", style: { textAlign: "center" } })}>
|
<Text style={Fonts({ type: 'default', style: { textAlign: 'center' } })}>{text}</Text>
|
||||||
{text}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{buttonData?.text?.length > 0 && (
|
{buttonData?.text?.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
text={buttonData?.text || "Ajouter une tâche"}
|
text={buttonData?.text || 'Ajouter une tâche'}
|
||||||
type="secondary"
|
type="secondary"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setShowSearch(false);
|
setShowSearch(false)
|
||||||
|
|
||||||
buttonData?.onPress?.() || (() => console.log("null"));
|
buttonData?.onPress?.() || (() => console.log('null'))
|
||||||
}}
|
}}
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
alignSelf: "center",
|
alignSelf: 'center',
|
||||||
width: "100%",
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default EmptyFlashListPlaceholder;
|
export default EmptyFlashListPlaceholder
|
||||||
|
|||||||
@@ -1,54 +1,41 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
import React, {
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
useCallback,
|
import { Animated, FlatList, Platform, StyleSheet, View, useWindowDimensions } from 'react-native'
|
||||||
useEffect,
|
import { responsiveHeight } from '../../actions/responsiveSizes'
|
||||||
useMemo,
|
import { ai, cardsImg } from '../../assets'
|
||||||
useRef,
|
import { gutters } from '../../styles'
|
||||||
useState,
|
import { getCreationStageStates } from '../../utils/projectStages'
|
||||||
} from "react";
|
import AnimatedPaginationDot from '../AnimatedPaginationDot/AnimatedPaginationDot'
|
||||||
import {
|
import PersonaCard from '../cards/PersonaCard/PersonaCard'
|
||||||
Animated,
|
|
||||||
FlatList,
|
|
||||||
Platform,
|
|
||||||
StyleSheet,
|
|
||||||
View,
|
|
||||||
useWindowDimensions,
|
|
||||||
} from "react-native";
|
|
||||||
import { responsiveHeight } from "../../actions/responsiveSizes";
|
|
||||||
import { ai, cardsImg } from "../../assets";
|
|
||||||
import { gutters } from "../../styles";
|
|
||||||
import { getCreationStageStates } from "../../utils/projectStages";
|
|
||||||
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
|
|
||||||
import PersonaCard from "../cards/PersonaCard/PersonaCard";
|
|
||||||
|
|
||||||
const STAGE_CARD_CONTENT = [
|
const STAGE_CARD_CONTENT = [
|
||||||
{
|
{
|
||||||
key: "songwriter",
|
key: 'songwriter',
|
||||||
title: "Céline",
|
title: 'Céline',
|
||||||
description: "Let’s write lyrics together !",
|
description: 'Let’s write lyrics together !',
|
||||||
image: ai.leftIcon,
|
image: ai.leftIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "beatmaker",
|
key: 'beatmaker',
|
||||||
title: "Theo",
|
title: 'Theo',
|
||||||
description: "Come back, when you'll have lyrics!",
|
description: "Come back, when you'll have lyrics!",
|
||||||
image: ai.rightIcon,
|
image: ai.rightIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "director",
|
key: 'director',
|
||||||
title: "Theo",
|
title: 'Theo',
|
||||||
description: "Theo t'accompagne pour créer ton playback.",
|
description: "Theo t'accompagne pour créer ton playback.",
|
||||||
image: ai.rightIcon,
|
image: ai.rightIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "publisher",
|
key: 'publisher',
|
||||||
title: "Publication",
|
title: 'Publication',
|
||||||
description: "Ta vidéo est prête ? Direction YouTube !",
|
description: 'Ta vidéo est prête ? Direction YouTube !',
|
||||||
image: cardsImg.production,
|
image: cardsImg.production,
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const WEB_SCROLL_INACTIVE_DELTA = 0.05;
|
const WEB_SCROLL_INACTIVE_DELTA = 0.05
|
||||||
|
|
||||||
const FeatureCarousel = ({
|
const FeatureCarousel = ({
|
||||||
style,
|
style,
|
||||||
@@ -59,231 +46,225 @@ const FeatureCarousel = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const stageStates = useMemo(() => {
|
const stageStates = useMemo(() => {
|
||||||
if (stageStatesProp) {
|
if (stageStatesProp) {
|
||||||
return stageStatesProp;
|
return stageStatesProp
|
||||||
}
|
}
|
||||||
return getCreationStageStates(selectedProject);
|
return getCreationStageStates(selectedProject)
|
||||||
}, [stageStatesProp, selectedProject]);
|
}, [stageStatesProp, selectedProject])
|
||||||
|
|
||||||
const stageStatesByKey = useMemo(() => {
|
const stageStatesByKey = useMemo(() => {
|
||||||
if (!Array.isArray(stageStates)) {
|
if (!Array.isArray(stageStates)) {
|
||||||
return {};
|
return {}
|
||||||
}
|
}
|
||||||
return stageStates.reduce((acc, stage) => {
|
return stageStates.reduce((acc, stage) => {
|
||||||
if (stage?.key) {
|
if (stage?.key) {
|
||||||
acc[stage.key] = stage;
|
acc[stage.key] = stage
|
||||||
}
|
}
|
||||||
return acc;
|
return acc
|
||||||
}, {});
|
}, {})
|
||||||
}, [stageStates]);
|
}, [stageStates])
|
||||||
|
|
||||||
const carouselItems = useMemo(
|
const carouselItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
STAGE_CARD_CONTENT.map((item) => {
|
STAGE_CARD_CONTENT.map((item) => {
|
||||||
const state = stageStatesByKey[item.key];
|
const state = stageStatesByKey[item.key]
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
isLocked: state?.isLocked ?? true,
|
isLocked: state?.isLocked ?? true,
|
||||||
description: state?.description ?? item.description,
|
description: state?.description ?? item.description,
|
||||||
};
|
}
|
||||||
}),
|
}),
|
||||||
[stageStatesByKey]
|
[stageStatesByKey]
|
||||||
);
|
)
|
||||||
|
|
||||||
const { height: windowHeight } = useWindowDimensions();
|
const { height: windowHeight } = useWindowDimensions()
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === 'web'
|
||||||
|
|
||||||
const [viewportHeight, setViewportHeight] = useState(() =>
|
const [viewportHeight, setViewportHeight] = useState(() => Math.max(windowHeight, 1))
|
||||||
Math.max(windowHeight, 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSnapHeight = useCallback((height) => {
|
const updateSnapHeight = useCallback((height) => {
|
||||||
if (!height || Number.isNaN(height)) {
|
if (!height || Number.isNaN(height)) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
setViewportHeight((prev) => {
|
setViewportHeight((prev) => {
|
||||||
if (prev == null || Math.abs(prev - height) > 0.5) {
|
if (prev == null || Math.abs(prev - height) > 0.5) {
|
||||||
return height;
|
return height
|
||||||
}
|
}
|
||||||
return prev;
|
return prev
|
||||||
});
|
})
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateSnapHeight(Math.max(windowHeight, 1));
|
updateSnapHeight(Math.max(windowHeight, 1))
|
||||||
}, [updateSnapHeight, windowHeight]);
|
}, [updateSnapHeight, windowHeight])
|
||||||
|
|
||||||
const itemHeight = Math.max(viewportHeight, 1);
|
const itemHeight = Math.max(viewportHeight, 1)
|
||||||
|
|
||||||
const listRef = useRef(null);
|
const listRef = useRef(null)
|
||||||
const pendingScrollRef = useRef(false);
|
const pendingScrollRef = useRef(false)
|
||||||
const alignTimeoutRef = useRef(null);
|
const alignTimeoutRef = useRef(null)
|
||||||
const activeIndexRef = useRef(
|
const activeIndexRef = useRef(typeof activeIndex === 'number' ? activeIndex : 0)
|
||||||
typeof activeIndex === "number" ? activeIndex : 0
|
const onActiveIndexChangeRef = useRef(onActiveIndexChange)
|
||||||
);
|
const scrollY = useRef(new Animated.Value(0)).current
|
||||||
const onActiveIndexChangeRef = useRef(onActiveIndexChange);
|
|
||||||
const scrollY = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onActiveIndexChangeRef.current = onActiveIndexChange;
|
onActiveIndexChangeRef.current = onActiveIndexChange
|
||||||
}, [onActiveIndexChange]);
|
}, [onActiveIndexChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof activeIndex === "number") {
|
if (typeof activeIndex === 'number') {
|
||||||
activeIndexRef.current = activeIndex;
|
activeIndexRef.current = activeIndex
|
||||||
}
|
}
|
||||||
}, [activeIndex]);
|
}, [activeIndex])
|
||||||
|
|
||||||
const clampIndex = useCallback(
|
const clampIndex = useCallback(
|
||||||
(index) => {
|
(index) => {
|
||||||
if (!carouselItems.length) {
|
if (!carouselItems.length) {
|
||||||
return 0;
|
return 0
|
||||||
}
|
}
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return 0;
|
return 0
|
||||||
}
|
}
|
||||||
if (index >= carouselItems.length) {
|
if (index >= carouselItems.length) {
|
||||||
return carouselItems.length - 1;
|
return carouselItems.length - 1
|
||||||
}
|
}
|
||||||
return index;
|
return index
|
||||||
},
|
},
|
||||||
[carouselItems.length]
|
[carouselItems.length]
|
||||||
);
|
)
|
||||||
|
|
||||||
const clearPendingAlignment = useCallback(() => {
|
const clearPendingAlignment = useCallback(() => {
|
||||||
if (alignTimeoutRef.current != null) {
|
if (alignTimeoutRef.current != null) {
|
||||||
globalThis.clearTimeout(alignTimeoutRef.current);
|
globalThis.clearTimeout(alignTimeoutRef.current)
|
||||||
alignTimeoutRef.current = null;
|
alignTimeoutRef.current = null
|
||||||
}
|
}
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]);
|
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment])
|
||||||
|
|
||||||
const scrollToIndex = useCallback(
|
const scrollToIndex = useCallback(
|
||||||
(index, animated = true, heightOverride) => {
|
(index, animated = true, heightOverride) => {
|
||||||
const ref = listRef.current;
|
const ref = listRef.current
|
||||||
if (!ref) {
|
if (!ref) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const clamped = clampIndex(index);
|
const clamped = clampIndex(index)
|
||||||
const height =
|
const height = heightOverride && heightOverride > 0 ? heightOverride : itemHeight
|
||||||
heightOverride && heightOverride > 0 ? heightOverride : itemHeight;
|
|
||||||
|
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
if (!height) {
|
if (!height) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
updateSnapHeight(height);
|
updateSnapHeight(height)
|
||||||
try {
|
try {
|
||||||
ref.scrollToOffset({ offset: clamped * height, animated });
|
ref.scrollToOffset({ offset: clamped * height, animated })
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Ignore scroll errors when list is not ready yet.
|
// Ignore scroll errors when list is not ready yet.
|
||||||
}
|
}
|
||||||
activeIndexRef.current = clamped;
|
activeIndexRef.current = clamped
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
pendingScrollRef.current = !!animated;
|
pendingScrollRef.current = !!animated
|
||||||
ref.scrollToIndex({ index: clamped, animated });
|
ref.scrollToIndex({ index: clamped, animated })
|
||||||
activeIndexRef.current = clamped;
|
activeIndexRef.current = clamped
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
pendingScrollRef.current = false;
|
pendingScrollRef.current = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clampIndex, isWeb, itemHeight, updateSnapHeight]
|
[clampIndex, isWeb, itemHeight, updateSnapHeight]
|
||||||
);
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
listRef.current == null ||
|
listRef.current == null ||
|
||||||
typeof activeIndex !== "number" ||
|
typeof activeIndex !== 'number' ||
|
||||||
activeIndex < 0 ||
|
activeIndex < 0 ||
|
||||||
activeIndex >= carouselItems.length ||
|
activeIndex >= carouselItems.length ||
|
||||||
(isWeb && !itemHeight)
|
(isWeb && !itemHeight)
|
||||||
) {
|
) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
scrollToIndex(activeIndex);
|
scrollToIndex(activeIndex)
|
||||||
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (listRef.current == null || (isWeb && !itemHeight)) {
|
if (listRef.current == null || (isWeb && !itemHeight)) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
scrollToIndex(activeIndexRef.current, false);
|
scrollToIndex(activeIndexRef.current, false)
|
||||||
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex])
|
||||||
|
|
||||||
const alignToOffset = useCallback(
|
const alignToOffset = useCallback(
|
||||||
(offset, layoutHeight) => {
|
(offset, layoutHeight) => {
|
||||||
const height =
|
const height = layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight
|
||||||
layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight;
|
|
||||||
if (!height) {
|
if (!height) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSnapHeight(height);
|
updateSnapHeight(height)
|
||||||
|
|
||||||
const currentIndex = activeIndexRef.current;
|
const currentIndex = activeIndexRef.current
|
||||||
const rawIndex = height ? offset / height : currentIndex;
|
const rawIndex = height ? offset / height : currentIndex
|
||||||
|
|
||||||
let nextIndex = currentIndex;
|
let nextIndex = currentIndex
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
const delta = rawIndex - currentIndex;
|
const delta = rawIndex - currentIndex
|
||||||
if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) {
|
if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) {
|
||||||
if (Math.abs(delta) <= 1) {
|
if (Math.abs(delta) <= 1) {
|
||||||
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1));
|
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1))
|
||||||
} else {
|
} else {
|
||||||
nextIndex = clampIndex(currentIndex + Math.round(delta));
|
nextIndex = clampIndex(currentIndex + Math.round(delta))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
nextIndex = clampIndex(Math.round(rawIndex));
|
nextIndex = clampIndex(Math.round(rawIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasChanged = nextIndex !== activeIndexRef.current;
|
const hasChanged = nextIndex !== activeIndexRef.current
|
||||||
|
|
||||||
if (hasChanged) {
|
if (hasChanged) {
|
||||||
activeIndexRef.current = nextIndex;
|
activeIndexRef.current = nextIndex
|
||||||
const callback = onActiveIndexChangeRef.current;
|
const callback = onActiveIndexChangeRef.current
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback(nextIndex);
|
callback(nextIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWeb || hasChanged) {
|
if (isWeb || hasChanged) {
|
||||||
const shouldAnimate = isWeb ? true : !isWeb;
|
const shouldAnimate = isWeb ? true : !isWeb
|
||||||
scrollToIndex(nextIndex, shouldAnimate, height);
|
scrollToIndex(nextIndex, shouldAnimate, height)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight]
|
[clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight]
|
||||||
);
|
)
|
||||||
|
|
||||||
const handleScrollEnd = useCallback(
|
const handleScrollEnd = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
clearPendingAlignment();
|
clearPendingAlignment()
|
||||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
|
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
|
||||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
|
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
|
||||||
alignToOffset(offsetY, layoutHeight);
|
alignToOffset(offsetY, layoutHeight)
|
||||||
},
|
},
|
||||||
[alignToOffset, clearPendingAlignment]
|
[alignToOffset, clearPendingAlignment]
|
||||||
);
|
)
|
||||||
|
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
if (!isWeb) {
|
if (!isWeb) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
|
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
|
||||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
|
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
|
||||||
clearPendingAlignment();
|
clearPendingAlignment()
|
||||||
alignTimeoutRef.current = globalThis.setTimeout(() => {
|
alignTimeoutRef.current = globalThis.setTimeout(() => {
|
||||||
alignToOffset(offsetY, layoutHeight);
|
alignToOffset(offsetY, layoutHeight)
|
||||||
alignTimeoutRef.current = null;
|
alignTimeoutRef.current = null
|
||||||
}, 80);
|
}, 80)
|
||||||
},
|
},
|
||||||
[alignToOffset, clearPendingAlignment, isWeb]
|
[alignToOffset, clearPendingAlignment, isWeb]
|
||||||
);
|
)
|
||||||
|
|
||||||
const animatedScrollHandler = useMemo(
|
const animatedScrollHandler = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -292,31 +273,26 @@ const FeatureCarousel = ({
|
|||||||
listener: isWeb ? handleScroll : undefined,
|
listener: isWeb ? handleScroll : undefined,
|
||||||
}),
|
}),
|
||||||
[handleScroll, isWeb, scrollY]
|
[handleScroll, isWeb, scrollY]
|
||||||
);
|
)
|
||||||
|
|
||||||
const handleLayout = useCallback(
|
const handleLayout = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0;
|
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0
|
||||||
if (layoutHeight > 0) {
|
if (layoutHeight > 0) {
|
||||||
updateSnapHeight(layoutHeight);
|
updateSnapHeight(layoutHeight)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateSnapHeight]
|
[updateSnapHeight]
|
||||||
);
|
)
|
||||||
|
|
||||||
const keyExtractor = useCallback((item) => item.key, []);
|
const keyExtractor = useCallback((item) => item.key, [])
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }) => (
|
({ item, index }) => (
|
||||||
<PersonaCard
|
<PersonaCard item={item} index={index} isLock={item.isLocked} height={itemHeight} />
|
||||||
item={item}
|
|
||||||
index={index}
|
|
||||||
isLock={item.isLocked}
|
|
||||||
height={itemHeight}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
[itemHeight]
|
[itemHeight]
|
||||||
);
|
)
|
||||||
|
|
||||||
const getItemLayout = useCallback(
|
const getItemLayout = useCallback(
|
||||||
(_data, index) => ({
|
(_data, index) => ({
|
||||||
@@ -325,50 +301,44 @@ const FeatureCarousel = ({
|
|||||||
index,
|
index,
|
||||||
}),
|
}),
|
||||||
[itemHeight]
|
[itemHeight]
|
||||||
);
|
)
|
||||||
|
|
||||||
const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 });
|
const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 })
|
||||||
|
|
||||||
const handleViewableItemsChangedRef = useRef();
|
const handleViewableItemsChangedRef = useRef()
|
||||||
if (!handleViewableItemsChangedRef.current) {
|
if (!handleViewableItemsChangedRef.current) {
|
||||||
handleViewableItemsChangedRef.current = ({ viewableItems }) => {
|
handleViewableItemsChangedRef.current = ({ viewableItems }) => {
|
||||||
if (!viewableItems?.length) return;
|
if (!viewableItems?.length) return
|
||||||
const firstVisible = viewableItems.find((item) => item?.isViewable);
|
const firstVisible = viewableItems.find((item) => item?.isViewable)
|
||||||
if (!firstVisible || firstVisible.index == null) return;
|
if (!firstVisible || firstVisible.index == null) return
|
||||||
if (pendingScrollRef.current) {
|
if (pendingScrollRef.current) {
|
||||||
if (firstVisible.index === activeIndexRef.current) {
|
if (firstVisible.index === activeIndexRef.current) {
|
||||||
pendingScrollRef.current = false;
|
pendingScrollRef.current = false
|
||||||
}
|
}
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
const callback = onActiveIndexChangeRef.current;
|
const callback = onActiveIndexChangeRef.current
|
||||||
if (callback && firstVisible.index !== activeIndexRef.current) {
|
if (callback && firstVisible.index !== activeIndexRef.current) {
|
||||||
callback(firstVisible.index);
|
callback(firstVisible.index)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapOffsets = useMemo(() => {
|
const snapOffsets = useMemo(() => {
|
||||||
if (!itemHeight || !isWeb) {
|
if (!itemHeight || !isWeb) {
|
||||||
return undefined;
|
return undefined
|
||||||
}
|
}
|
||||||
return carouselItems.map((_, index) => index * itemHeight);
|
return carouselItems.map((_, index) => index * itemHeight)
|
||||||
}, [carouselItems, isWeb, itemHeight]);
|
}, [carouselItems, isWeb, itemHeight])
|
||||||
|
|
||||||
const blurIntensity = isWeb ? 80 : 30;
|
const blurIntensity = isWeb ? 80 : 30
|
||||||
const dotsWrapperStyle = useMemo(
|
const dotsWrapperStyle = useMemo(
|
||||||
() => [
|
() => [styles.dotsWrapperBase, isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative],
|
||||||
styles.dotsWrapperBase,
|
|
||||||
isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative,
|
|
||||||
],
|
|
||||||
[isWeb]
|
[isWeb]
|
||||||
);
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View style={[styles.container, isWeb && styles.containerWeb, style]} onLayout={handleLayout}>
|
||||||
style={[styles.container, isWeb && styles.containerWeb, style]}
|
|
||||||
onLayout={handleLayout}
|
|
||||||
>
|
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
data={carouselItems}
|
data={carouselItems}
|
||||||
@@ -385,11 +355,11 @@ const FeatureCarousel = ({
|
|||||||
maxToRenderPerBatch={2}
|
maxToRenderPerBatch={2}
|
||||||
windowSize={3}
|
windowSize={3}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
snapToAlignment={isWeb ? undefined : "start"}
|
snapToAlignment={isWeb ? undefined : 'start'}
|
||||||
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
|
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
|
||||||
snapToOffsets={snapOffsets}
|
snapToOffsets={snapOffsets}
|
||||||
disableIntervalMomentum={!isWeb}
|
disableIntervalMomentum={!isWeb}
|
||||||
decelerationRate={!isWeb ? "fast" : undefined}
|
decelerationRate={!isWeb ? 'fast' : undefined}
|
||||||
style={styles.list}
|
style={styles.list}
|
||||||
onScroll={animatedScrollHandler}
|
onScroll={animatedScrollHandler}
|
||||||
onMomentumScrollEnd={handleScrollEnd}
|
onMomentumScrollEnd={handleScrollEnd}
|
||||||
@@ -397,7 +367,7 @@ const FeatureCarousel = ({
|
|||||||
/>
|
/>
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={blurIntensity}
|
intensity={blurIntensity}
|
||||||
tint={Platform.OS === "web" ? undefined : "dark"}
|
tint={Platform.OS === 'web' ? undefined : 'dark'}
|
||||||
style={dotsWrapperStyle}
|
style={dotsWrapperStyle}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
>
|
>
|
||||||
@@ -414,16 +384,16 @@ const FeatureCarousel = ({
|
|||||||
/>
|
/>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default FeatureCarousel;
|
export default FeatureCarousel
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: "100%",
|
width: '100%',
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
paddingBottom: responsiveHeight(5),
|
paddingBottom: responsiveHeight(5),
|
||||||
paddingHorizontal: gutters,
|
paddingHorizontal: gutters,
|
||||||
},
|
},
|
||||||
@@ -434,28 +404,28 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
dotsWrapperBase: {
|
dotsWrapperBase: {
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
backgroundColor: "rgba(18, 18, 18, 0.2)",
|
backgroundColor: 'rgba(18, 18, 18, 0.2)',
|
||||||
},
|
},
|
||||||
dotsWrapperWeb: {
|
dotsWrapperWeb: {
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
right: 12,
|
right: 12,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
maxHeight: "75%",
|
maxHeight: '75%',
|
||||||
alignSelf: "center",
|
alignSelf: 'center',
|
||||||
},
|
},
|
||||||
dotsWrapperNative: {
|
dotsWrapperNative: {
|
||||||
marginLeft: 12,
|
marginLeft: 12,
|
||||||
alignSelf: "center",
|
alignSelf: 'center',
|
||||||
},
|
},
|
||||||
dotsContainer: {
|
dotsContainer: {
|
||||||
flexDirection: "column",
|
flexDirection: 'column',
|
||||||
},
|
},
|
||||||
dot: {
|
dot: {
|
||||||
width: 8,
|
width: 8,
|
||||||
@@ -463,6 +433,6 @@ const styles = StyleSheet.create({
|
|||||||
marginHorizontal: 0,
|
marginHorizontal: 0,
|
||||||
marginVertical: 6,
|
marginVertical: 6,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
backgroundColor: "rgba(255,255,255,0.4)",
|
backgroundColor: 'rgba(255,255,255,0.4)',
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
import React, {
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
ImageBackground,
|
ImageBackground,
|
||||||
@@ -13,42 +7,42 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from "react-native";
|
} from 'react-native'
|
||||||
import { ai, cardsImg } from "../../assets";
|
import { ai, cardsImg } from '../../assets'
|
||||||
import { getCreationStageStates } from "../../utils/projectStages";
|
import { getCreationStageStates } from '../../utils/projectStages'
|
||||||
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
|
import AnimatedPaginationDot from '../AnimatedPaginationDot/AnimatedPaginationDot'
|
||||||
import PersonaCard from "../cards/PersonaCard/PersonaCard";
|
import PersonaCard from '../cards/PersonaCard/PersonaCard'
|
||||||
import { LinearGradient } from "../LinearGradient/LinearGradient";
|
import { LinearGradient } from '../LinearGradient/LinearGradient'
|
||||||
|
|
||||||
const STAGE_CARD_CONTENT = [
|
const STAGE_CARD_CONTENT = [
|
||||||
{
|
{
|
||||||
key: "songwriter",
|
key: 'songwriter',
|
||||||
title: "Céline",
|
title: 'Céline',
|
||||||
description: "Let’s write lyrics together !",
|
description: 'Let’s write lyrics together !',
|
||||||
image: ai.leftIcon,
|
image: ai.leftIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "beatmaker",
|
key: 'beatmaker',
|
||||||
title: "Theo",
|
title: 'Theo',
|
||||||
description: "Come back, when you'll have lyrics!",
|
description: "Come back, when you'll have lyrics!",
|
||||||
image: ai.rightIcon,
|
image: ai.rightIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "director",
|
key: 'director',
|
||||||
title: "Theo",
|
title: 'Theo',
|
||||||
description: "Theo t'accompagne pour créer ton playback.",
|
description: "Theo t'accompagne pour créer ton playback.",
|
||||||
image: ai.rightIcon,
|
image: ai.rightIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "publisher",
|
key: 'publisher',
|
||||||
title: "Publication",
|
title: 'Publication',
|
||||||
description: "Ta vidéo est prête ? Direction YouTube !",
|
description: 'Ta vidéo est prête ? Direction YouTube !',
|
||||||
image: cardsImg.production,
|
image: cardsImg.production,
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const WEB_SCROLL_INACTIVE_DELTA = 0.05;
|
const WEB_SCROLL_INACTIVE_DELTA = 0.05
|
||||||
const HOME_BACKGROUND_COLOR = "#425B87"; // Derived from the bottom color of the home hero image
|
const HOME_BACKGROUND_COLOR = '#425B87' // Derived from the bottom color of the home hero image
|
||||||
const FeatureCarousel = ({
|
const FeatureCarousel = ({
|
||||||
style,
|
style,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
@@ -60,257 +54,245 @@ const FeatureCarousel = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const stageStates = useMemo(() => {
|
const stageStates = useMemo(() => {
|
||||||
if (stageStatesProp) {
|
if (stageStatesProp) {
|
||||||
return stageStatesProp;
|
return stageStatesProp
|
||||||
}
|
}
|
||||||
return getCreationStageStates(selectedProject);
|
return getCreationStageStates(selectedProject)
|
||||||
}, [stageStatesProp, selectedProject]);
|
}, [stageStatesProp, selectedProject])
|
||||||
|
|
||||||
const stageStatesByKey = useMemo(() => {
|
const stageStatesByKey = useMemo(() => {
|
||||||
if (!Array.isArray(stageStates)) {
|
if (!Array.isArray(stageStates)) {
|
||||||
return {};
|
return {}
|
||||||
}
|
}
|
||||||
return stageStates.reduce((acc, stage) => {
|
return stageStates.reduce((acc, stage) => {
|
||||||
if (stage?.key) {
|
if (stage?.key) {
|
||||||
acc[stage.key] = stage;
|
acc[stage.key] = stage
|
||||||
}
|
}
|
||||||
return acc;
|
return acc
|
||||||
}, {});
|
}, {})
|
||||||
}, [stageStates]);
|
}, [stageStates])
|
||||||
|
|
||||||
const carouselItems = useMemo(
|
const carouselItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
STAGE_CARD_CONTENT.map((item) => {
|
STAGE_CARD_CONTENT.map((item) => {
|
||||||
const state = stageStatesByKey[item.key];
|
const state = stageStatesByKey[item.key]
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
isLocked: state?.isLocked ?? true,
|
isLocked: state?.isLocked ?? true,
|
||||||
description: state?.description ?? item.description,
|
description: state?.description ?? item.description,
|
||||||
};
|
}
|
||||||
}),
|
}),
|
||||||
[stageStatesByKey]
|
[stageStatesByKey]
|
||||||
);
|
)
|
||||||
|
|
||||||
const { height: windowHeight } = useWindowDimensions();
|
const { height: windowHeight } = useWindowDimensions()
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === 'web'
|
||||||
|
|
||||||
const [viewportHeight, setViewportHeight] = useState(() =>
|
const [viewportHeight, setViewportHeight] = useState(() => Math.max(windowHeight, 1))
|
||||||
Math.max(windowHeight, 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSnapHeight = useCallback((height) => {
|
const updateSnapHeight = useCallback((height) => {
|
||||||
if (!height || Number.isNaN(height)) {
|
if (!height || Number.isNaN(height)) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
setViewportHeight((prev) => {
|
setViewportHeight((prev) => {
|
||||||
if (prev == null || Math.abs(prev - height) > 0.5) {
|
if (prev == null || Math.abs(prev - height) > 0.5) {
|
||||||
return height;
|
return height
|
||||||
}
|
}
|
||||||
return prev;
|
return prev
|
||||||
});
|
})
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateSnapHeight(Math.max(windowHeight, 1));
|
updateSnapHeight(Math.max(windowHeight, 1))
|
||||||
}, [updateSnapHeight, windowHeight]);
|
}, [updateSnapHeight, windowHeight])
|
||||||
|
|
||||||
const itemHeight = Math.max(viewportHeight, 1);
|
const itemHeight = Math.max(viewportHeight, 1)
|
||||||
|
|
||||||
const scrollViewRef = useRef(null);
|
const scrollViewRef = useRef(null)
|
||||||
const alignTimeoutRef = useRef(null);
|
const alignTimeoutRef = useRef(null)
|
||||||
const activeIndexRef = useRef(
|
const activeIndexRef = useRef(typeof activeIndex === 'number' ? activeIndex : 0)
|
||||||
typeof activeIndex === "number" ? activeIndex : 0
|
const onActiveIndexChangeRef = useRef(onActiveIndexChange)
|
||||||
);
|
const scrollY = useRef(new Animated.Value(0)).current
|
||||||
const onActiveIndexChangeRef = useRef(onActiveIndexChange);
|
|
||||||
const scrollY = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onActiveIndexChangeRef.current = onActiveIndexChange;
|
onActiveIndexChangeRef.current = onActiveIndexChange
|
||||||
}, [onActiveIndexChange]);
|
}, [onActiveIndexChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof activeIndex === "number") {
|
if (typeof activeIndex === 'number') {
|
||||||
activeIndexRef.current = activeIndex;
|
activeIndexRef.current = activeIndex
|
||||||
}
|
}
|
||||||
}, [activeIndex]);
|
}, [activeIndex])
|
||||||
|
|
||||||
const clampIndex = useCallback(
|
const clampIndex = useCallback(
|
||||||
(index) => {
|
(index) => {
|
||||||
if (!carouselItems.length) {
|
if (!carouselItems.length) {
|
||||||
return 0;
|
return 0
|
||||||
}
|
}
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return 0;
|
return 0
|
||||||
}
|
}
|
||||||
if (index >= carouselItems.length) {
|
if (index >= carouselItems.length) {
|
||||||
return carouselItems.length - 1;
|
return carouselItems.length - 1
|
||||||
}
|
}
|
||||||
return index;
|
return index
|
||||||
},
|
},
|
||||||
[carouselItems.length]
|
[carouselItems.length]
|
||||||
);
|
)
|
||||||
|
|
||||||
const clearPendingAlignment = useCallback(() => {
|
const clearPendingAlignment = useCallback(() => {
|
||||||
if (alignTimeoutRef.current != null) {
|
if (alignTimeoutRef.current != null) {
|
||||||
globalThis.clearTimeout(alignTimeoutRef.current);
|
globalThis.clearTimeout(alignTimeoutRef.current)
|
||||||
alignTimeoutRef.current = null;
|
alignTimeoutRef.current = null
|
||||||
}
|
}
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]);
|
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment])
|
||||||
|
|
||||||
const getScrollNode = useCallback(() => {
|
const getScrollNode = useCallback(() => {
|
||||||
const node = scrollViewRef.current;
|
const node = scrollViewRef.current
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
if (typeof node.scrollTo === "function") {
|
if (typeof node.scrollTo === 'function') {
|
||||||
return node;
|
return node
|
||||||
}
|
}
|
||||||
if (typeof node.getNode === "function") {
|
if (typeof node.getNode === 'function') {
|
||||||
return node.getNode();
|
return node.getNode()
|
||||||
}
|
}
|
||||||
return null;
|
return null
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const scrollToIndex = useCallback(
|
const scrollToIndex = useCallback(
|
||||||
(index, animated = true, heightOverride) => {
|
(index, animated = true, heightOverride) => {
|
||||||
const target = getScrollNode();
|
const target = getScrollNode()
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const clamped = clampIndex(index);
|
const clamped = clampIndex(index)
|
||||||
const height =
|
const height = heightOverride && heightOverride > 0 ? heightOverride : itemHeight
|
||||||
heightOverride && heightOverride > 0 ? heightOverride : itemHeight;
|
|
||||||
|
|
||||||
if (!height) {
|
if (!height) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSnapHeight(height);
|
updateSnapHeight(height)
|
||||||
const offset = clamped * height;
|
const offset = clamped * height
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (typeof target.scrollTo === "function") {
|
if (typeof target.scrollTo === 'function') {
|
||||||
target.scrollTo({ y: offset, animated });
|
target.scrollTo({ y: offset, animated })
|
||||||
} else if (typeof target.scrollToOffset === "function") {
|
} else if (typeof target.scrollToOffset === 'function') {
|
||||||
target.scrollToOffset({ offset, animated });
|
target.scrollToOffset({ offset, animated })
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// ScrollView not ready yet, ignore.
|
// ScrollView not ready yet, ignore.
|
||||||
}
|
}
|
||||||
activeIndexRef.current = clamped;
|
activeIndexRef.current = clamped
|
||||||
},
|
},
|
||||||
[clampIndex, getScrollNode, itemHeight, updateSnapHeight]
|
[clampIndex, getScrollNode, itemHeight, updateSnapHeight]
|
||||||
);
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
scrollViewRef.current == null ||
|
scrollViewRef.current == null ||
|
||||||
typeof activeIndex !== "number" ||
|
typeof activeIndex !== 'number' ||
|
||||||
activeIndex < 0 ||
|
activeIndex < 0 ||
|
||||||
activeIndex >= carouselItems.length ||
|
activeIndex >= carouselItems.length ||
|
||||||
(isWeb && !itemHeight)
|
(isWeb && !itemHeight)
|
||||||
) {
|
) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
scrollToIndex(activeIndex);
|
scrollToIndex(activeIndex)
|
||||||
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollViewRef.current == null || (isWeb && !itemHeight)) {
|
if (scrollViewRef.current == null || (isWeb && !itemHeight)) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
scrollToIndex(activeIndexRef.current, false);
|
scrollToIndex(activeIndexRef.current, false)
|
||||||
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWeb || !isFocused) {
|
if (!isWeb || !isFocused) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
if (!itemHeight) {
|
if (!itemHeight) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
clearPendingAlignment();
|
clearPendingAlignment()
|
||||||
scrollToIndex(activeIndexRef.current, false);
|
scrollToIndex(activeIndexRef.current, false)
|
||||||
}, [
|
}, [clearPendingAlignment, isFocused, isWeb, itemHeight, scrollToIndex])
|
||||||
clearPendingAlignment,
|
|
||||||
isFocused,
|
|
||||||
isWeb,
|
|
||||||
itemHeight,
|
|
||||||
scrollToIndex,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const alignToOffset = useCallback(
|
const alignToOffset = useCallback(
|
||||||
(offset, layoutHeight, options = {}) => {
|
(offset, layoutHeight, options = {}) => {
|
||||||
const { forceSnap = false } = options || {};
|
const { forceSnap = false } = options || {}
|
||||||
const height =
|
const height = layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight
|
||||||
layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight;
|
|
||||||
if (!height) {
|
if (!height) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSnapHeight(height);
|
updateSnapHeight(height)
|
||||||
|
|
||||||
const currentIndex = activeIndexRef.current;
|
const currentIndex = activeIndexRef.current
|
||||||
const rawIndex = height ? offset / height : currentIndex;
|
const rawIndex = height ? offset / height : currentIndex
|
||||||
|
|
||||||
let nextIndex = currentIndex;
|
let nextIndex = currentIndex
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
const delta = rawIndex - currentIndex;
|
const delta = rawIndex - currentIndex
|
||||||
if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) {
|
if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) {
|
||||||
if (Math.abs(delta) <= 1) {
|
if (Math.abs(delta) <= 1) {
|
||||||
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1));
|
nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1))
|
||||||
} else {
|
} else {
|
||||||
nextIndex = clampIndex(currentIndex + Math.round(delta));
|
nextIndex = clampIndex(currentIndex + Math.round(delta))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
nextIndex = clampIndex(Math.round(rawIndex));
|
nextIndex = clampIndex(Math.round(rawIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasChanged = nextIndex !== activeIndexRef.current;
|
const hasChanged = nextIndex !== activeIndexRef.current
|
||||||
|
|
||||||
if (hasChanged) {
|
if (hasChanged) {
|
||||||
activeIndexRef.current = nextIndex;
|
activeIndexRef.current = nextIndex
|
||||||
const callback = onActiveIndexChangeRef.current;
|
const callback = onActiveIndexChangeRef.current
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback(nextIndex);
|
callback(nextIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forceSnap || hasChanged) {
|
if (forceSnap || hasChanged) {
|
||||||
scrollToIndex(nextIndex, true, height);
|
scrollToIndex(nextIndex, true, height)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight]
|
[clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight]
|
||||||
);
|
)
|
||||||
|
|
||||||
const handleScrollEnd = useCallback(
|
const handleScrollEnd = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
clearPendingAlignment();
|
clearPendingAlignment()
|
||||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
|
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
|
||||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
|
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
|
||||||
alignToOffset(offsetY, layoutHeight, { forceSnap: true });
|
alignToOffset(offsetY, layoutHeight, { forceSnap: true })
|
||||||
},
|
},
|
||||||
[alignToOffset, clearPendingAlignment]
|
[alignToOffset, clearPendingAlignment]
|
||||||
);
|
)
|
||||||
|
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
if (!isWeb) {
|
if (!isWeb) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
|
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0
|
||||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
|
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height
|
||||||
clearPendingAlignment();
|
clearPendingAlignment()
|
||||||
alignTimeoutRef.current = globalThis.setTimeout(() => {
|
alignTimeoutRef.current = globalThis.setTimeout(() => {
|
||||||
alignToOffset(offsetY, layoutHeight, { forceSnap: false });
|
alignToOffset(offsetY, layoutHeight, { forceSnap: false })
|
||||||
alignTimeoutRef.current = null;
|
alignTimeoutRef.current = null
|
||||||
}, 80);
|
}, 80)
|
||||||
},
|
},
|
||||||
[alignToOffset, clearPendingAlignment, isWeb]
|
[alignToOffset, clearPendingAlignment, isWeb]
|
||||||
);
|
)
|
||||||
|
|
||||||
const animatedScrollHandler = useMemo(
|
const animatedScrollHandler = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -319,19 +301,19 @@ const FeatureCarousel = ({
|
|||||||
listener: isWeb ? handleScroll : undefined,
|
listener: isWeb ? handleScroll : undefined,
|
||||||
}),
|
}),
|
||||||
[handleScroll, isWeb, scrollY]
|
[handleScroll, isWeb, scrollY]
|
||||||
);
|
)
|
||||||
|
|
||||||
const handleLayout = useCallback(
|
const handleLayout = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0;
|
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0
|
||||||
if (layoutHeight > 0) {
|
if (layoutHeight > 0) {
|
||||||
updateSnapHeight(layoutHeight);
|
updateSnapHeight(layoutHeight)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateSnapHeight]
|
[updateSnapHeight]
|
||||||
);
|
)
|
||||||
|
|
||||||
const hasBackgroundImage = !!backgroundImage;
|
const hasBackgroundImage = !!backgroundImage
|
||||||
|
|
||||||
const renderContent = useMemo(
|
const renderContent = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -345,43 +327,35 @@ const FeatureCarousel = ({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.cardWrapper}>
|
<View style={styles.cardWrapper}>
|
||||||
<PersonaCard
|
<PersonaCard item={item} index={index} isLock={item.isLocked} height={itemHeight} />
|
||||||
item={item}
|
|
||||||
index={index}
|
|
||||||
isLock={item.isLocked}
|
|
||||||
height={itemHeight}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)),
|
)),
|
||||||
[carouselItems, hasBackgroundImage, itemHeight]
|
[carouselItems, hasBackgroundImage, itemHeight]
|
||||||
);
|
)
|
||||||
|
|
||||||
const snapOffsets = useMemo(() => {
|
const snapOffsets = useMemo(() => {
|
||||||
if (!itemHeight || !isWeb) {
|
if (!itemHeight || !isWeb) {
|
||||||
return undefined;
|
return undefined
|
||||||
}
|
}
|
||||||
return carouselItems.map((_, index) => index * itemHeight);
|
return carouselItems.map((_, index) => index * itemHeight)
|
||||||
}, [carouselItems, isWeb, itemHeight]);
|
}, [carouselItems, isWeb, itemHeight])
|
||||||
|
|
||||||
const blurIntensity = isWeb ? 80 : 30;
|
const blurIntensity = isWeb ? 80 : 30
|
||||||
const dotsWrapperStyle = useMemo(
|
const dotsWrapperStyle = useMemo(
|
||||||
() => [
|
() => [styles.dotsWrapperBase, isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative],
|
||||||
styles.dotsWrapperBase,
|
|
||||||
isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative,
|
|
||||||
],
|
|
||||||
[isWeb]
|
[isWeb]
|
||||||
);
|
)
|
||||||
|
|
||||||
const containerProps = hasBackgroundImage
|
const containerProps = hasBackgroundImage
|
||||||
? {
|
? {
|
||||||
source: backgroundImage,
|
source: backgroundImage,
|
||||||
resizeMode: "cover",
|
resizeMode: 'cover',
|
||||||
imageStyle: styles.backgroundImage,
|
imageStyle: styles.backgroundImage,
|
||||||
}
|
}
|
||||||
: {};
|
: {}
|
||||||
|
|
||||||
const ContainerComponent = hasBackgroundImage ? ImageBackground : View;
|
const ContainerComponent = hasBackgroundImage ? ImageBackground : View
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContainerComponent
|
<ContainerComponent
|
||||||
@@ -396,11 +370,7 @@ const FeatureCarousel = ({
|
|||||||
>
|
>
|
||||||
{hasBackgroundImage && (
|
{hasBackgroundImage && (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[
|
colors={['rgba(66, 91, 135, 0)', 'rgba(66, 91, 135, 0.4)', HOME_BACKGROUND_COLOR]}
|
||||||
"rgba(66, 91, 135, 0)",
|
|
||||||
"rgba(66, 91, 135, 0.4)",
|
|
||||||
HOME_BACKGROUND_COLOR,
|
|
||||||
]}
|
|
||||||
locations={[0, 0.75, 1]}
|
locations={[0, 0.75, 1]}
|
||||||
style={styles.backgroundGradient}
|
style={styles.backgroundGradient}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
@@ -416,7 +386,7 @@ const FeatureCarousel = ({
|
|||||||
snapToAlignment="start"
|
snapToAlignment="start"
|
||||||
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
|
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
|
||||||
snapToOffsets={snapOffsets}
|
snapToOffsets={snapOffsets}
|
||||||
decelerationRate={!isWeb ? "fast" : "normal"}
|
decelerationRate={!isWeb ? 'fast' : 'normal'}
|
||||||
style={styles.list}
|
style={styles.list}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
onScroll={animatedScrollHandler}
|
onScroll={animatedScrollHandler}
|
||||||
@@ -427,7 +397,7 @@ const FeatureCarousel = ({
|
|||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={blurIntensity}
|
intensity={blurIntensity}
|
||||||
tint={Platform.OS === "web" ? undefined : "dark"}
|
tint={Platform.OS === 'web' ? undefined : 'dark'}
|
||||||
style={dotsWrapperStyle}
|
style={dotsWrapperStyle}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
>
|
>
|
||||||
@@ -444,16 +414,16 @@ const FeatureCarousel = ({
|
|||||||
/>
|
/>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</ContainerComponent>
|
</ContainerComponent>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default FeatureCarousel;
|
export default FeatureCarousel
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: "100%",
|
width: '100%',
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
},
|
},
|
||||||
containerWeb: {
|
containerWeb: {
|
||||||
// paddingRight: 56,
|
// paddingRight: 56,
|
||||||
@@ -462,7 +432,7 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: HOME_BACKGROUND_COLOR,
|
backgroundColor: HOME_BACKGROUND_COLOR,
|
||||||
},
|
},
|
||||||
containerTransparent: {
|
containerTransparent: {
|
||||||
backgroundColor: "transparent",
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -477,45 +447,45 @@ const styles = StyleSheet.create({
|
|||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
},
|
},
|
||||||
slide: {
|
slide: {
|
||||||
width: "100%",
|
width: '100%',
|
||||||
justifyContent: "flex-start",
|
justifyContent: 'flex-start',
|
||||||
},
|
},
|
||||||
slideColored: {
|
slideColored: {
|
||||||
backgroundColor: HOME_BACKGROUND_COLOR,
|
backgroundColor: HOME_BACKGROUND_COLOR,
|
||||||
},
|
},
|
||||||
slideTransparent: {
|
slideTransparent: {
|
||||||
backgroundColor: "transparent",
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
cardWrapper: {
|
cardWrapper: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: "60%",
|
width: '60%',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
alignSelf: "center",
|
alignSelf: 'center',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
dotsWrapperBase: {
|
dotsWrapperBase: {
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
backgroundColor: "rgba(18, 18, 18, 0.2)",
|
backgroundColor: 'rgba(18, 18, 18, 0.2)',
|
||||||
},
|
},
|
||||||
dotsWrapperWeb: {
|
dotsWrapperWeb: {
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
right: 12,
|
right: 12,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
maxHeight: "75%",
|
maxHeight: '75%',
|
||||||
alignSelf: "center",
|
alignSelf: 'center',
|
||||||
},
|
},
|
||||||
dotsWrapperNative: {
|
dotsWrapperNative: {
|
||||||
marginLeft: 12,
|
marginLeft: 12,
|
||||||
alignSelf: "center",
|
alignSelf: 'center',
|
||||||
},
|
},
|
||||||
dotsContainer: {
|
dotsContainer: {
|
||||||
flexDirection: "column",
|
flexDirection: 'column',
|
||||||
},
|
},
|
||||||
dot: {
|
dot: {
|
||||||
width: 8,
|
width: 8,
|
||||||
@@ -523,6 +493,6 @@ const styles = StyleSheet.create({
|
|||||||
marginHorizontal: 0,
|
marginHorizontal: 0,
|
||||||
marginVertical: 6,
|
marginVertical: 6,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
backgroundColor: "rgba(255,255,255,0.4)",
|
backgroundColor: 'rgba(255,255,255,0.4)',
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useContext, useGlobal } from "reactn";
|
import { useContext, useGlobal } from 'reactn'
|
||||||
import { ScrollView, Text, View, Image, Pressable } from "react-native";
|
import { ScrollView, Text, View, Image, Pressable } from 'react-native'
|
||||||
|
|
||||||
import firebase, { arrayRemove } from "../config/firebase";
|
import firebase, { arrayRemove } from '../config/firebase'
|
||||||
|
|
||||||
import { getFileNameFromURL } from "../helpers";
|
import { getFileNameFromURL } from '../helpers'
|
||||||
import { Fonts, Palette, Style, gutters } from "../styles";
|
import { Fonts, Palette, Style, gutters } from '../styles'
|
||||||
import { icons } from "../assets";
|
import { icons } from '../assets'
|
||||||
|
|
||||||
import { WebViewContext } from "../providers/WebViewProvider";
|
import { WebViewContext } from '../providers/WebViewProvider'
|
||||||
import alert from "./Alert";
|
import alert from './Alert'
|
||||||
|
|
||||||
export default ({
|
export default ({
|
||||||
files = [],
|
files = [],
|
||||||
@@ -17,67 +17,61 @@ export default ({
|
|||||||
collectionRef = null,
|
collectionRef = null,
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
}) => {
|
}) => {
|
||||||
const [, setIsLoading] = useGlobal("_isLoading");
|
const [, setIsLoading] = useGlobal('_isLoading')
|
||||||
const [, setTooltip] = useGlobal("_tooltip");
|
const [, setTooltip] = useGlobal('_tooltip')
|
||||||
|
|
||||||
const { setWebViewUrl } = useContext(WebViewContext);
|
const { setWebViewUrl } = useContext(WebViewContext)
|
||||||
|
|
||||||
const onDeleteFile = async (url) => {
|
const onDeleteFile = async (url) => {
|
||||||
alert(
|
alert(
|
||||||
"Êtes-vous sûr ?",
|
'Êtes-vous sûr ?',
|
||||||
"Cette action est irréversible.",
|
'Cette action est irréversible.',
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: "Annuler",
|
text: 'Annuler',
|
||||||
style: "cancel",
|
style: 'cancel',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Confirmer",
|
text: 'Confirmer',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true)
|
||||||
|
|
||||||
setFiles(
|
setFiles(files.filter((file) => file !== url && file.uri !== url))
|
||||||
files.filter((file) => file !== url && file.uri !== url)
|
await firebase.storage().refFromURL(url).delete()
|
||||||
);
|
|
||||||
await firebase.storage().refFromURL(url).delete();
|
|
||||||
|
|
||||||
if (documentID) {
|
if (documentID) {
|
||||||
await collectionRef.doc(documentID).update({
|
await collectionRef.doc(documentID).update({
|
||||||
files: arrayRemove(url),
|
files: arrayRemove(url),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setTooltip({
|
setTooltip({
|
||||||
type: "success",
|
type: 'success',
|
||||||
text: "Fichier supprimé avec succès !",
|
text: 'Fichier supprimé avec succès !',
|
||||||
});
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: "confirm",
|
style: 'confirm',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ cancelable: false }
|
{ cancelable: false }
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<ScrollView
|
<ScrollView horizontal style={{ ...containerStyle }} showsHorizontalScrollIndicator={false}>
|
||||||
horizontal
|
|
||||||
style={{ ...containerStyle }}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{files.map((file, index) => {
|
{files.map((file, index) => {
|
||||||
const uri = file.uri || file;
|
const uri = file.uri || file
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -97,7 +91,7 @@ export default ({
|
|||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={Fonts({
|
style={Fonts({
|
||||||
type: "default",
|
type: 'default',
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
style: {
|
style: {
|
||||||
maxWidth: files?.length > 1 ? 100 : 200,
|
maxWidth: files?.length > 1 ? 100 : 200,
|
||||||
@@ -119,16 +113,12 @@ export default ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Pressable onPress={() => setWebViewUrl(uri)}>
|
<Pressable onPress={() => setWebViewUrl(uri)}>
|
||||||
<Image
|
<Image source={icons.eye} style={Style.iconDefault} resizeMode="contain" />
|
||||||
source={icons.eye}
|
|
||||||
style={Style.iconDefault}
|
|
||||||
resizeMode="contain"
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,102 +1,86 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from "react";
|
import React, { useCallback, useEffect, useRef } from 'react'
|
||||||
import { View, Pressable, Text } from "react-native";
|
import { View, Pressable, Text } from 'react-native'
|
||||||
import { VideoView, useVideoPlayer } from "expo-video";
|
import { VideoView, useVideoPlayer } from 'expo-video'
|
||||||
import { videos } from "../assets";
|
import { videos } from '../assets'
|
||||||
import { Portal } from "@gorhom/portal";
|
import { Portal } from '@gorhom/portal'
|
||||||
|
|
||||||
// Fullscreen vertical video overlay without controls
|
// Fullscreen vertical video overlay without controls
|
||||||
// Props:
|
// Props:
|
||||||
// - url?: string | number (require), source of the video. Defaults to videos.test
|
// - url?: string | number (require), source of the video. Defaults to videos.test
|
||||||
// - visible?: boolean, when false returns null
|
// - visible?: boolean, when false returns null
|
||||||
// - onClose: () => void, called when user skips or when video ends
|
// - onClose: () => void, called when user skips or when video ends
|
||||||
const CLOSE_THRESHOLD_SECONDS = 0.35;
|
const CLOSE_THRESHOLD_SECONDS = 0.35
|
||||||
|
|
||||||
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||||
const source = url
|
const source = url ? (typeof url === 'string' ? { uri: url } : url) : videos.test
|
||||||
? typeof url === "string"
|
|
||||||
? { uri: url }
|
|
||||||
: url
|
|
||||||
: videos.test;
|
|
||||||
|
|
||||||
const hasClosedRef = useRef(false);
|
const hasClosedRef = useRef(false)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (hasClosedRef.current) return;
|
if (hasClosedRef.current) return
|
||||||
hasClosedRef.current = true;
|
hasClosedRef.current = true
|
||||||
try {
|
try {
|
||||||
onClose?.();
|
onClose?.()
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}, [onClose]);
|
}, [onClose])
|
||||||
|
|
||||||
const player = useVideoPlayer(source, (p) => {
|
const player = useVideoPlayer(source, (p) => {
|
||||||
p.loop = false;
|
p.loop = false
|
||||||
p.timeUpdateEventInterval = 0.25;
|
p.timeUpdateEventInterval = 0.25
|
||||||
});
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
hasClosedRef.current = false;
|
hasClosedRef.current = false
|
||||||
} else {
|
} else {
|
||||||
hasClosedRef.current = true;
|
hasClosedRef.current = true
|
||||||
try {
|
try {
|
||||||
player?.pause?.();
|
player?.pause?.()
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
}, [player, visible]);
|
}, [player, visible])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
player?.play?.();
|
player?.play?.()
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}, [player, visible]);
|
}, [player, visible])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!player || !visible) return;
|
if (!player || !visible) return
|
||||||
const playToEndSub = player.addListener?.("playToEnd", handleClose);
|
const playToEndSub = player.addListener?.('playToEnd', handleClose)
|
||||||
const timeUpdateSub = player.addListener?.(
|
const timeUpdateSub = player.addListener?.('timeUpdate', ({ currentTime } = {}) => {
|
||||||
"timeUpdate",
|
|
||||||
({ currentTime } = {}) => {
|
|
||||||
if (!player?.duration || hasClosedRef.current) {
|
if (!player?.duration || hasClosedRef.current) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
const remaining = player.duration - currentTime;
|
const remaining = player.duration - currentTime
|
||||||
if (
|
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
|
||||||
Number.isFinite(remaining) &&
|
handleClose()
|
||||||
remaining <= CLOSE_THRESHOLD_SECONDS
|
|
||||||
) {
|
|
||||||
handleClose();
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
const playingChangeSub = player.addListener?.('playingChange', ({ isPlaying } = {}) => {
|
||||||
const playingChangeSub = player.addListener?.(
|
|
||||||
"playingChange",
|
|
||||||
({ isPlaying } = {}) => {
|
|
||||||
if (isPlaying || !player?.duration || hasClosedRef.current) {
|
if (isPlaying || !player?.duration || hasClosedRef.current) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
const remaining = player.duration - (player.currentTime ?? 0);
|
const remaining = player.duration - (player.currentTime ?? 0)
|
||||||
if (
|
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
|
||||||
Number.isFinite(remaining) &&
|
handleClose()
|
||||||
remaining <= CLOSE_THRESHOLD_SECONDS
|
|
||||||
) {
|
|
||||||
handleClose();
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
try {
|
||||||
playToEndSub?.remove?.();
|
playToEndSub?.remove?.()
|
||||||
timeUpdateSub?.remove?.();
|
timeUpdateSub?.remove?.()
|
||||||
playingChangeSub?.remove?.();
|
playingChangeSub?.remove?.()
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
}
|
||||||
}, [handleClose, player, visible]);
|
}, [handleClose, player, visible])
|
||||||
|
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,15 +88,15 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
|||||||
<View
|
<View
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: "black",
|
backgroundColor: 'black',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -133,22 +117,22 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleClose}
|
onPress={handleClose}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: 50,
|
top: 50,
|
||||||
right: 20,
|
right: 20,
|
||||||
backgroundColor: "#00000080",
|
backgroundColor: '#00000080',
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#FFFFFF55",
|
borderColor: '#FFFFFF55',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ color: "#FFF", fontSize: 14 }}>Passer la vidéo</Text>
|
<Text style={{ color: '#FFF', fontSize: 14 }}>Passer la vidéo</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</Portal>
|
</Portal>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default FullscreenIntroVideo;
|
export default FullscreenIntroVideo
|
||||||
|
|||||||
@@ -1,183 +1,183 @@
|
|||||||
import { Portal } from "@gorhom/portal";
|
import { Portal } from '@gorhom/portal'
|
||||||
import { Asset } from "expo-asset";
|
import { Asset } from 'expo-asset'
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from 'react-native'
|
||||||
|
|
||||||
const CLOSE_THRESHOLD_SECONDS = 0.35;
|
const CLOSE_THRESHOLD_SECONDS = 0.35
|
||||||
const CLOSE_POLL_INTERVAL_MS = 500;
|
const CLOSE_POLL_INTERVAL_MS = 500
|
||||||
|
|
||||||
const overlayStyle = {
|
const overlayStyle = {
|
||||||
position: "fixed",
|
position: 'fixed',
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
backgroundColor: "black",
|
backgroundColor: 'black',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
};
|
}
|
||||||
|
|
||||||
const videoStyle = {
|
const videoStyle = {
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: "100%",
|
width: '100%',
|
||||||
height: "100%",
|
height: '100%',
|
||||||
objectFit: "cover",
|
objectFit: 'cover',
|
||||||
};
|
}
|
||||||
|
|
||||||
const closeButtonStyle = {
|
const closeButtonStyle = {
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: 50,
|
top: 50,
|
||||||
right: 20,
|
right: 20,
|
||||||
backgroundColor: "#00000080",
|
backgroundColor: '#00000080',
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#FFFFFF55",
|
borderColor: '#FFFFFF55',
|
||||||
cursor: "pointer",
|
cursor: 'pointer',
|
||||||
};
|
}
|
||||||
|
|
||||||
const closeTextStyle = {
|
const closeTextStyle = {
|
||||||
color: "#FFF",
|
color: '#FFF',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
};
|
}
|
||||||
|
|
||||||
const resolveModuleUri = async (module) => {
|
const resolveModuleUri = async (module) => {
|
||||||
const asset = Asset.fromModule(module);
|
const asset = Asset.fromModule(module)
|
||||||
|
|
||||||
if (!asset.localUri && !asset.uri) {
|
if (!asset.localUri && !asset.uri) {
|
||||||
await asset.downloadAsync();
|
await asset.downloadAsync()
|
||||||
}
|
}
|
||||||
|
|
||||||
return asset.localUri ?? asset.uri ?? null;
|
return asset.localUri ?? asset.uri ?? null
|
||||||
};
|
}
|
||||||
|
|
||||||
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null)
|
||||||
const [uri, setUri] = useState(null);
|
const [uri, setUri] = useState(null)
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false)
|
||||||
const hasClosedRef = useRef(false);
|
const hasClosedRef = useRef(false)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (hasClosedRef.current) return;
|
if (hasClosedRef.current) return
|
||||||
hasClosedRef.current = true;
|
hasClosedRef.current = true
|
||||||
onClose?.();
|
onClose?.()
|
||||||
}, [onClose]);
|
}, [onClose])
|
||||||
|
|
||||||
const evaluateShouldClose = useCallback(() => {
|
const evaluateShouldClose = useCallback(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current
|
||||||
|
|
||||||
if (!video || hasClosedRef.current) {
|
if (!video || hasClosedRef.current) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (video.ended) {
|
if (video.ended) {
|
||||||
handleClose();
|
handleClose()
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const remaining = video.duration - video.currentTime;
|
const remaining = video.duration - video.currentTime
|
||||||
|
|
||||||
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
|
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
|
||||||
handleClose();
|
handleClose()
|
||||||
}
|
}
|
||||||
}, [handleClose]);
|
}, [handleClose])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true
|
||||||
|
|
||||||
const assignUri = (nextUri) => {
|
const assignUri = (nextUri) => {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setMuted(false);
|
setMuted(false)
|
||||||
setUri(nextUri);
|
setUri(nextUri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof url === "string") {
|
if (typeof url === 'string') {
|
||||||
assignUri(url);
|
assignUri(url)
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const nextUri = await resolveModuleUri(url);
|
const nextUri = await resolveModuleUri(url)
|
||||||
assignUri(nextUri);
|
assignUri(nextUri)
|
||||||
} catch {
|
} catch {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setUri(null);
|
setUri(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
load();
|
load()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false
|
||||||
};
|
}
|
||||||
}, [url]);
|
}, [url])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !uri) {
|
if (!visible || !uri) {
|
||||||
return undefined;
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
hasClosedRef.current = false;
|
hasClosedRef.current = false
|
||||||
let rafId;
|
let rafId
|
||||||
|
|
||||||
const attemptPlay = () => {
|
const attemptPlay = () => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current
|
||||||
|
|
||||||
if (!video) {
|
if (!video) {
|
||||||
rafId = requestAnimationFrame(attemptPlay);
|
rafId = requestAnimationFrame(attemptPlay)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
video.currentTime = 0;
|
video.currentTime = 0
|
||||||
const result = video.play();
|
const result = video.play()
|
||||||
|
|
||||||
if (result?.catch) {
|
if (result?.catch) {
|
||||||
result.catch((error) => {
|
result.catch((error) => {
|
||||||
if (error?.name === "NotAllowedError" && !muted) {
|
if (error?.name === 'NotAllowedError' && !muted) {
|
||||||
setMuted(true);
|
setMuted(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
attemptPlay();
|
attemptPlay()
|
||||||
const pollId = setInterval(evaluateShouldClose, CLOSE_POLL_INTERVAL_MS);
|
const pollId = setInterval(evaluateShouldClose, CLOSE_POLL_INTERVAL_MS)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (rafId) {
|
if (rafId) {
|
||||||
cancelAnimationFrame(rafId);
|
cancelAnimationFrame(rafId)
|
||||||
}
|
}
|
||||||
clearInterval(pollId);
|
clearInterval(pollId)
|
||||||
const video = videoRef.current;
|
const video = videoRef.current
|
||||||
video?.pause();
|
video?.pause()
|
||||||
};
|
}
|
||||||
}, [evaluateShouldClose, muted, uri, visible]);
|
}, [evaluateShouldClose, muted, uri, visible])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current
|
||||||
|
|
||||||
if (!video || !muted) {
|
if (!video || !muted) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = video.play();
|
const result = video.play()
|
||||||
|
|
||||||
if (result?.catch) {
|
if (result?.catch) {
|
||||||
result.catch(() => {});
|
result.catch(() => {})
|
||||||
}
|
}
|
||||||
}, [muted]);
|
}, [muted])
|
||||||
|
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -204,7 +204,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</Portal>
|
</Portal>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default FullscreenIntroVideo;
|
export default FullscreenIntroVideo
|
||||||
|
|||||||
@@ -1,40 +1,39 @@
|
|||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Image, Pressable, Text } from "react-native";
|
import { Image, Pressable, Text } from 'react-native'
|
||||||
import { Palette, Style } from "../styles";
|
import { Palette, Style } from '../styles'
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
import { FONT_FAMILY } from '../styles/Fonts'
|
||||||
import { size } from "../styles/Style";
|
import { size } from '../styles/Style'
|
||||||
import { LinearGradient } from "./LinearGradient/LinearGradient";
|
import { LinearGradient } from './LinearGradient/LinearGradient'
|
||||||
|
|
||||||
const HEIGHT_BY_SIZE = {
|
const HEIGHT_BY_SIZE = {
|
||||||
small: 44,
|
small: 44,
|
||||||
medium: 50,
|
medium: 50,
|
||||||
large: 58,
|
large: 58,
|
||||||
};
|
}
|
||||||
|
|
||||||
const FONT_SIZE_BY_SIZE = {
|
const FONT_SIZE_BY_SIZE = {
|
||||||
small: 14,
|
small: 14,
|
||||||
medium: 15,
|
medium: 15,
|
||||||
large: 17,
|
large: 17,
|
||||||
};
|
}
|
||||||
|
|
||||||
const GradientButton = ({
|
const GradientButton = ({
|
||||||
title = "",
|
title = '',
|
||||||
colors = ["#F94697", "#7023F7"],
|
colors = ['#F94697', '#7023F7'],
|
||||||
onPress,
|
onPress,
|
||||||
props,
|
props,
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
icon,
|
icon,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
maxWidth = null,
|
maxWidth = null,
|
||||||
size = "medium",
|
size = 'medium',
|
||||||
textStyle = {},
|
textStyle = {},
|
||||||
gradientStyle = {},
|
gradientStyle = {},
|
||||||
height = null,
|
height = null,
|
||||||
}) => {
|
}) => {
|
||||||
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
|
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : 'medium'
|
||||||
const buttonHeight =
|
const buttonHeight = typeof height === 'number' ? height : HEIGHT_BY_SIZE[resolvedSize]
|
||||||
typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize];
|
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]
|
||||||
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -79,7 +78,7 @@ const GradientButton = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default GradientButton;
|
export default GradientButton
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import Hyperlink from "react-native-hyperlink";
|
import Hyperlink from 'react-native-hyperlink'
|
||||||
|
|
||||||
import { useWebView } from "../providers/WebViewProvider";
|
import { useWebView } from '../providers/WebViewProvider'
|
||||||
import { Palette } from "../styles";
|
import { Palette } from '../styles'
|
||||||
|
|
||||||
const HyperlinkContainer = ({ children }) => {
|
const HyperlinkContainer = ({ children }) => {
|
||||||
const { setWebViewUrl } = useWebView();
|
const { setWebViewUrl } = useWebView()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
onPress={(url) => {
|
onPress={(url) => {
|
||||||
setWebViewUrl(url);
|
setWebViewUrl(url)
|
||||||
}}
|
}}
|
||||||
linkStyle={{
|
linkStyle={{
|
||||||
color: Palette.primary,
|
color: Palette.primary,
|
||||||
textDecorationLine: "underline",
|
textDecorationLine: 'underline',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default HyperlinkContainer;
|
export default HyperlinkContainer
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Image, View } from "react-native";
|
import { Image, View } from 'react-native'
|
||||||
|
|
||||||
import { Palette, Style } from "../styles";
|
import { Palette, Style } from '../styles'
|
||||||
import { gutters, mainBorderRadius } from "../styles/Style";
|
import { gutters, mainBorderRadius } from '../styles/Style'
|
||||||
|
|
||||||
const IconContainer = ({ icon }) => {
|
const IconContainer = ({ icon }) => {
|
||||||
return (
|
return (
|
||||||
@@ -19,13 +19,13 @@ const IconContainer = ({ icon }) => {
|
|||||||
source={icon}
|
source={icon}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
style={{
|
style={{
|
||||||
width: "50%",
|
width: '50%',
|
||||||
height: "50%",
|
height: '50%',
|
||||||
tintColor: Palette.primary,
|
tintColor: Palette.primary,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default IconContainer;
|
export default IconContainer
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
import React, { useState, useEffect } from "reactn";
|
import React, { useState, useEffect } from 'reactn'
|
||||||
import { Image, Pressable, Text, View } from "react-native";
|
import { Image, Pressable, Text, View } from 'react-native'
|
||||||
import { FlatGrid } from "react-native-super-grid";
|
import { FlatGrid } from 'react-native-super-grid'
|
||||||
|
|
||||||
import { responsiveWidth } from "../actions/responsiveSizes.js";
|
import { responsiveWidth } from '../actions/responsiveSizes.js'
|
||||||
|
|
||||||
import { Fonts, Palette, Style, gutters } from "../styles";
|
import { Fonts, Palette, Style, gutters } from '../styles'
|
||||||
import { mainBorderRadius } from "../styles/Style";
|
import { mainBorderRadius } from '../styles/Style'
|
||||||
|
|
||||||
import useLayoutType from "../hooks/useLayoutType.js";
|
import useLayoutType from '../hooks/useLayoutType.js'
|
||||||
|
|
||||||
const IconSelector = ({ onClose } = {}) => {
|
const IconSelector = ({ onClose } = {}) => {
|
||||||
const { isNative } = useLayoutType();
|
const { isNative } = useLayoutType()
|
||||||
|
|
||||||
const [currentIconIndex, setCurrentIconIndex] = useState(0);
|
const [currentIconIndex, setCurrentIconIndex] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
// import("expo-dynamic-app-icon").then((module) => {
|
// import("expo-dynamic-app-icon").then((module) => {
|
||||||
// getAppIcon = module.getAppIcon;
|
// getAppIcon = module.getAppIcon;
|
||||||
|
|
||||||
// const iconIndex = getAppIcon();
|
// const iconIndex = getAppIcon();
|
||||||
// setCurrentIconIndex(Number(iconIndex));
|
// setCurrentIconIndex(Number(iconIndex));
|
||||||
// });
|
// });
|
||||||
}
|
}
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatGrid
|
<FlatGrid
|
||||||
@@ -31,8 +30,8 @@ const IconSelector = ({ onClose } = {}) => {
|
|||||||
<View>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
...Fonts({ type: "title" }),
|
...Fonts({ type: 'title' }),
|
||||||
textAlign: "center",
|
textAlign: 'center',
|
||||||
marginBottom: gutters / 2,
|
marginBottom: gutters / 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -40,10 +39,10 @@ const IconSelector = ({ onClose } = {}) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
...Fonts({ type: "default" }),
|
...Fonts({ type: 'default' }),
|
||||||
textAlign: "center",
|
textAlign: 'center',
|
||||||
width: "60%",
|
width: '60%',
|
||||||
alignSelf: "center",
|
alignSelf: 'center',
|
||||||
marginBottom: gutters,
|
marginBottom: gutters,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -61,13 +60,13 @@ const IconSelector = ({ onClose } = {}) => {
|
|||||||
style={{ flex: 1, backgroundColor: Palette.darkPurple }}
|
style={{ flex: 1, backgroundColor: Palette.darkPurple }}
|
||||||
spacing={10}
|
spacing={10}
|
||||||
renderItem={({ item, index }) => {
|
renderItem={({ item, index }) => {
|
||||||
const isSelected = currentIconIndex === index + 1;
|
const isSelected = currentIconIndex === index + 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={index}
|
key={index}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: '100%',
|
||||||
height: responsiveWidth(45),
|
height: responsiveWidth(45),
|
||||||
...Style.containerCenter,
|
...Style.containerCenter,
|
||||||
...Style.defaultShadows,
|
...Style.defaultShadows,
|
||||||
@@ -75,7 +74,6 @@ const IconSelector = ({ onClose } = {}) => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
// import("expo-dynamic-app-icon").then((module) => {
|
// import("expo-dynamic-app-icon").then((module) => {
|
||||||
// setAppIcon = module.setAppIcon;
|
// setAppIcon = module.setAppIcon;
|
||||||
|
|
||||||
// setAppIcon((index + 1).toString());
|
// setAppIcon((index + 1).toString());
|
||||||
// setCurrentIconIndex(index + 1);
|
// setCurrentIconIndex(index + 1);
|
||||||
// onClose?.();
|
// onClose?.();
|
||||||
@@ -86,10 +84,10 @@ const IconSelector = ({ onClose } = {}) => {
|
|||||||
source={item}
|
source={item}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
style={{
|
style={{
|
||||||
width: "90%",
|
width: '90%',
|
||||||
height: "90%",
|
height: '90%',
|
||||||
borderRadius: mainBorderRadius * 2,
|
borderRadius: mainBorderRadius * 2,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
...(isSelected && {
|
...(isSelected && {
|
||||||
borderColor: Palette.primary,
|
borderColor: Palette.primary,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
@@ -97,10 +95,10 @@ const IconSelector = ({ onClose } = {}) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default IconSelector;
|
export default IconSelector
|
||||||
|
|||||||
+64
-76
@@ -1,30 +1,22 @@
|
|||||||
import { useState } from "react";
|
import { useState } from 'react'
|
||||||
import {
|
import { Image, InputAccessoryView, Keyboard, Pressable, Text, TextInput, View } from 'react-native'
|
||||||
Image,
|
|
||||||
InputAccessoryView,
|
|
||||||
Keyboard,
|
|
||||||
Pressable,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
// import CountryPicker, { DARK_THEME } from "react-native-country-picker-modal";
|
// import CountryPicker, { DARK_THEME } from "react-native-country-picker-modal";
|
||||||
import usePlaceApi from "react-native-minuit/src/hooks/usePlacesApi";
|
import usePlaceApi from 'react-native-minuit/src/hooks/usePlacesApi'
|
||||||
|
|
||||||
import { icons } from "../assets";
|
import { icons } from '../assets'
|
||||||
import { Fonts, Palette, Style, gutters } from "../styles";
|
import { Fonts, Palette, Style, gutters } from '../styles'
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
import { FONT_FAMILY } from '../styles/Fonts'
|
||||||
|
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
import EyeSlashSVG from "../assets/UI/EyeSlashSVG";
|
import EyeSlashSVG from '../assets/UI/EyeSlashSVG'
|
||||||
import EyeSVG from "../assets/UI/EyeSVG";
|
import EyeSVG from '../assets/UI/EyeSVG'
|
||||||
import { GOOGLE_API_KEY } from "../data/keys";
|
import { GOOGLE_API_KEY } from '../data/keys'
|
||||||
import { isWeb } from "../hooks/useLayoutType";
|
import { isWeb } from '../hooks/useLayoutType'
|
||||||
|
|
||||||
const Input = ({
|
const Input = ({
|
||||||
inputRef = null,
|
inputRef = null,
|
||||||
label = "",
|
label = '',
|
||||||
placeholder = "",
|
placeholder = '',
|
||||||
|
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
textInputStyle = {},
|
textInputStyle = {},
|
||||||
@@ -34,48 +26,46 @@ const Input = ({
|
|||||||
|
|
||||||
textInputProps = {},
|
textInputProps = {},
|
||||||
|
|
||||||
type = "default", // "default" | "password" | "textarea" | "coinAmount" | "countryPicker" | "autoCompleteAddress"
|
type = 'default', // "default" | "password" | "textarea" | "coinAmount" | "countryPicker" | "autoCompleteAddress"
|
||||||
theme = "default", // "default" | "radioactiv" | "dashed"
|
theme = 'default', // "default" | "radioactiv" | "dashed"
|
||||||
|
|
||||||
borderType = "none", // "solid" | "dashed" | "none"
|
borderType = 'none', // "solid" | "dashed" | "none"
|
||||||
|
|
||||||
layout = "default", // "default" | "line"
|
layout = 'default', // "default" | "line"
|
||||||
|
|
||||||
isNumeric = false,
|
isNumeric = false,
|
||||||
isBlur = false,
|
isBlur = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false)
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|
||||||
const isDefaultLayout = layout === "default";
|
const isDefaultLayout = layout === 'default'
|
||||||
|
|
||||||
const mainColor =
|
const mainColor = theme === 'radioactiv' ? Palette.radioactivGreen : Palette.primary
|
||||||
theme === "radioactiv" ? Palette.radioactivGreen : Palette.primary;
|
|
||||||
|
|
||||||
const isRoundedRectangle =
|
const isRoundedRectangle = ['textarea', 'coinAmount'].includes(type) || layout === 'default'
|
||||||
["textarea", "coinAmount"].includes(type) || layout === "default";
|
|
||||||
|
|
||||||
const inputAccessoryViewID = "uniqueID";
|
const inputAccessoryViewID = 'uniqueID'
|
||||||
|
|
||||||
const { places } = usePlaceApi({
|
const { places } = usePlaceApi({
|
||||||
query: type === "autoCompleteAddress" ? value : "",
|
query: type === 'autoCompleteAddress' ? value : '',
|
||||||
apiKey: GOOGLE_API_KEY, // Your Google API Key
|
apiKey: GOOGLE_API_KEY, // Your Google API Key
|
||||||
queryFields: "formatted_address,geometry,name,address_components",
|
queryFields: 'formatted_address,geometry,name,address_components',
|
||||||
queryCountries: ["fr"],
|
queryCountries: ['fr'],
|
||||||
language: "fr-FR",
|
language: 'fr-FR',
|
||||||
minChars: 2,
|
minChars: 2,
|
||||||
});
|
})
|
||||||
|
|
||||||
const ContainerView = isBlur ? BlurView : View;
|
const ContainerView = isBlur ? BlurView : View
|
||||||
const resolvedKeyboardType =
|
const resolvedKeyboardType =
|
||||||
textInputProps?.keyboardType ??
|
textInputProps?.keyboardType ??
|
||||||
(isNumeric ? "numeric" : type === "email" ? "email-address" : "default");
|
(isNumeric ? 'numeric' : type === 'email' ? 'email-address' : 'default')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: '100%',
|
||||||
...containerStyle,
|
...containerStyle,
|
||||||
gap: 4,
|
gap: 4,
|
||||||
}}
|
}}
|
||||||
@@ -106,11 +96,11 @@ const Input = ({
|
|||||||
? {
|
? {
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
type === "coinAmount"
|
type === 'coinAmount'
|
||||||
? Palette.transparentRadioactivGreen
|
? Palette.transparentRadioactivGreen
|
||||||
: Palette.glass,
|
: Palette.glass,
|
||||||
height: type === "textarea" ? 150 : 50,
|
height: type === 'textarea' ? 150 : 50,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -120,68 +110,66 @@ const Input = ({
|
|||||||
borderBottomColor: mainColor,
|
borderBottomColor: mainColor,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
}),
|
}),
|
||||||
...(borderType === "dashed"
|
...(borderType === 'dashed'
|
||||||
? {
|
? {
|
||||||
borderStyle: "dashed",
|
borderStyle: 'dashed',
|
||||||
borderColor: isFocused
|
borderColor: isFocused ? Palette.primary : Palette.transparentPrimary,
|
||||||
? Palette.primary
|
|
||||||
: Palette.transparentPrimary,
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
width: "100%",
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{type === "search" ? (
|
{type === 'search' ? (
|
||||||
<Image
|
<Image
|
||||||
source={icons.search}
|
source={icons.search}
|
||||||
style={[Style.iconDefault, { marginRight: 10 }]}
|
style={[Style.iconDefault, { marginRight: 10 }]}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{type !== "countryPicker" && (
|
{type !== 'countryPicker' && (
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor={Palette.gray}
|
placeholderTextColor={Palette.gray}
|
||||||
value={value}
|
value={value}
|
||||||
onChangeText={setValue}
|
onChangeText={setValue}
|
||||||
multiline={type === "textarea"}
|
multiline={type === 'textarea'}
|
||||||
editable={type !== "countryPicker"}
|
editable={type !== 'countryPicker'}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: '100%',
|
||||||
...(isRoundedRectangle
|
...(isRoundedRectangle
|
||||||
? {
|
? {
|
||||||
height: "100%",
|
height: '100%',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
textAlignVertical: type === "textarea" ? "top" : "center",
|
textAlignVertical: type === 'textarea' ? 'top' : 'center',
|
||||||
}
|
}
|
||||||
: { textAlign: "center" }),
|
: { textAlign: 'center' }),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
...(isWeb
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
lineHeight: "auto",
|
lineHeight: 'auto',
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...textInputStyle,
|
...textInputStyle,
|
||||||
}}
|
}}
|
||||||
{...(type === "password"
|
{...(type === 'password'
|
||||||
? {
|
? {
|
||||||
secureTextEntry: !showPassword,
|
secureTextEntry: !showPassword,
|
||||||
autoCapitalize: "none",
|
autoCapitalize: 'none',
|
||||||
autoCompleteType: "password",
|
autoCompleteType: 'password',
|
||||||
textContentType: "password",
|
textContentType: 'password',
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
{...(type === "email"
|
{...(type === 'email'
|
||||||
? {
|
? {
|
||||||
autoCapitalize: "none",
|
autoCapitalize: 'none',
|
||||||
autoCompleteType: "email",
|
autoCompleteType: 'email',
|
||||||
textContentType: "emailAddress",
|
textContentType: 'emailAddress',
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
keyboardType={resolvedKeyboardType}
|
keyboardType={resolvedKeyboardType}
|
||||||
@@ -190,21 +178,21 @@ const Input = ({
|
|||||||
{...textInputProps}
|
{...textInputProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{type === "password" ? (
|
{type === 'password' ? (
|
||||||
<Pressable onPress={() => setShowPassword(!showPassword)}>
|
<Pressable onPress={() => setShowPassword(!showPassword)}>
|
||||||
{showPassword ? <EyeSVG /> : <EyeSlashSVG />}
|
{showPassword ? <EyeSVG /> : <EyeSlashSVG />}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
</ContainerView>
|
</ContainerView>
|
||||||
|
|
||||||
{type === "autoCompleteAddress" &&
|
{type === 'autoCompleteAddress' &&
|
||||||
places?.[0]?.description &&
|
places?.[0]?.description &&
|
||||||
places?.[0]?.description !== value &&
|
places?.[0]?.description !== value &&
|
||||||
places.map((place, index) => (
|
places.map((place, index) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={index}
|
key={index}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setValue(place?.description);
|
setValue(place?.description)
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
...Style.containerSpaceBetween,
|
...Style.containerSpaceBetween,
|
||||||
@@ -216,19 +204,19 @@ const Input = ({
|
|||||||
...Fonts({}),
|
...Fonts({}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{place?.description || "-"}
|
{place?.description || '-'}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{(type === "textarea" || isNumeric) && !isWeb && (
|
{(type === 'textarea' || isNumeric) && !isWeb && (
|
||||||
<InputAccessoryView nativeID={inputAccessoryViewID}>
|
<InputAccessoryView nativeID={inputAccessoryViewID}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => Keyboard.dismiss()}
|
onPress={() => Keyboard.dismiss()}
|
||||||
style={{
|
style={{
|
||||||
...Style.containerRow,
|
...Style.containerRow,
|
||||||
justifyContent: "flex-end",
|
justifyContent: 'flex-end',
|
||||||
backgroundColor: Palette.transparentPrimary,
|
backgroundColor: Palette.transparentPrimary,
|
||||||
padding: gutters,
|
padding: gutters,
|
||||||
paddingVertical: gutters / 2,
|
paddingVertical: gutters / 2,
|
||||||
@@ -245,7 +233,7 @@ const Input = ({
|
|||||||
</InputAccessoryView>
|
</InputAccessoryView>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export { Input };
|
export { Input }
|
||||||
|
|||||||
+49
-68
@@ -1,78 +1,69 @@
|
|||||||
import { useState } from "react";
|
import { useState } from 'react'
|
||||||
import { View, Text, Image, Pressable, StyleSheet } from "react-native";
|
import { View, Text, Image, Pressable, StyleSheet } from 'react-native'
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
|
|
||||||
import Switch from "../components/Switch";
|
import Switch from '../components/Switch'
|
||||||
import OptionSelector from "../components/OptionSelector";
|
import OptionSelector from '../components/OptionSelector'
|
||||||
import { Container, Title, Input, Button } from "../components/Dialog";
|
import { Container, Title, Input, Button } from '../components/Dialog'
|
||||||
|
|
||||||
import { Fonts, Style, gutters } from "../styles";
|
import { Fonts, Style, gutters } from '../styles'
|
||||||
import { icons } from "../assets";
|
import { icons } from '../assets'
|
||||||
|
|
||||||
const labelOptions = {
|
const labelOptions = {
|
||||||
name: "Nouveau nom",
|
name: 'Nouveau nom',
|
||||||
email: "Nouvelle adresse email",
|
email: 'Nouvelle adresse email',
|
||||||
password: "Nouveau mot de passe",
|
password: 'Nouveau mot de passe',
|
||||||
language: "Nouvelle langue",
|
language: 'Nouvelle langue',
|
||||||
};
|
}
|
||||||
|
|
||||||
const languageOptions = {
|
const languageOptions = {
|
||||||
fr: "Français",
|
fr: 'Français',
|
||||||
en: "English",
|
en: 'English',
|
||||||
es: "Español",
|
es: 'Español',
|
||||||
de: "Deutsch",
|
de: 'Deutsch',
|
||||||
it: "Italiano",
|
it: 'Italiano',
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ({ itemKey, type, value, title, onUpdateValue }) => {
|
export default ({ itemKey, type, value, title, onUpdateValue }) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false)
|
||||||
const [inputData, setInputData] = useState(value);
|
const [inputData, setInputData] = useState(value)
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={{}}>
|
<View style={{}}>
|
||||||
<View style={Style.containerSpaceBetween}>
|
<View style={Style.containerSpaceBetween}>
|
||||||
<Text style={Fonts({ type: "section" })}>{title}</Text>
|
<Text style={Fonts({ type: 'section' })}>{title}</Text>
|
||||||
{type === "boolean" ? (
|
{type === 'boolean' ? (
|
||||||
<Switch
|
<Switch
|
||||||
value={value}
|
value={value}
|
||||||
setValue={(newValue) =>
|
setValue={(newValue) => onUpdateValue({ key: itemKey, value: newValue })}
|
||||||
onUpdateValue({ key: itemKey, value: newValue })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setShowDialog(true)}
|
onPress={() => setShowDialog(true)}
|
||||||
style={[
|
style={[Style.containerRow, { maxWidth: '50%', justifyContent: 'flex-end' }]}
|
||||||
Style.containerRow,
|
|
||||||
{ maxWidth: "50%", justifyContent: "flex-end" },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={Fonts({
|
style={Fonts({
|
||||||
type: "section",
|
type: 'section',
|
||||||
style: {
|
style: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
textAlign: "right",
|
textAlign: 'right',
|
||||||
width: "80%",
|
width: '80%',
|
||||||
marginRight: gutters,
|
marginRight: gutters,
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{itemKey === "password"
|
{itemKey === 'password'
|
||||||
? "********"
|
? '********'
|
||||||
: itemKey === "language"
|
: itemKey === 'language'
|
||||||
? languageOptions[value] || value
|
? languageOptions[value] || value
|
||||||
: value}
|
: value}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Image
|
<Image source={icons.edit} style={Style.iconDefault} resizeMode="contain" />
|
||||||
source={icons.edit}
|
|
||||||
style={Style.iconDefault}
|
|
||||||
resizeMode="contain"
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -83,37 +74,33 @@ export default ({ itemKey, type, value, title, onUpdateValue }) => {
|
|||||||
<Container
|
<Container
|
||||||
visible={showDialog}
|
visible={showDialog}
|
||||||
blurComponentIOS={
|
blurComponentIOS={
|
||||||
<BlurView
|
<BlurView style={StyleSheet.absoluteFill} blurType="xdark" blurAmount={50} />
|
||||||
style={StyleSheet.absoluteFill}
|
|
||||||
blurType="xdark"
|
|
||||||
blurAmount={50}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Title>{`Changement ${title?.toLowerCase()}`}</Title>
|
<Title>{`Changement ${title?.toLowerCase()}`}</Title>
|
||||||
|
|
||||||
{["password", "email"].includes(itemKey) && (
|
{['password', 'email'].includes(itemKey) && (
|
||||||
<Input
|
<Input
|
||||||
label="Mot de passe actuel"
|
label="Mot de passe actuel"
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChangeText={(text) => setCurrentPassword(text)}
|
onChangeText={(text) => setCurrentPassword(text)}
|
||||||
keyboardType="visible-password"
|
keyboardType="visible-password"
|
||||||
type={"password"}
|
type={'password'}
|
||||||
containerStyle={{ marginBottom: gutters / 2 }}
|
containerStyle={{ marginBottom: gutters / 2 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{itemKey === "language" ? (
|
{itemKey === 'language' ? (
|
||||||
<OptionSelector
|
<OptionSelector
|
||||||
optionTypeList={languageOptions}
|
optionTypeList={languageOptions}
|
||||||
selected={inputData || "fr"}
|
selected={inputData || 'fr'}
|
||||||
setSelected={setInputData}
|
setSelected={setInputData}
|
||||||
containerStyle={{ marginBottom: gutters / 2 }}
|
containerStyle={{ marginBottom: gutters / 2 }}
|
||||||
colorMap={{
|
colorMap={{
|
||||||
fr: { primary: "#F94697", secondary: "#F946971A" },
|
fr: { primary: '#F94697', secondary: '#F946971A' },
|
||||||
en: { primary: "#7023F7", secondary: "#7023F71A" },
|
en: { primary: '#7023F7', secondary: '#7023F71A' },
|
||||||
es: { primary: "#FDBA74", secondary: "#FDBA741A" },
|
es: { primary: '#FDBA74', secondary: '#FDBA741A' },
|
||||||
de: { primary: "#60A5FA", secondary: "#60A5FA1A" },
|
de: { primary: '#60A5FA', secondary: '#60A5FA1A' },
|
||||||
it: { primary: "#34D399", secondary: "#34D3991A" },
|
it: { primary: '#34D399', secondary: '#34D3991A' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -121,9 +108,7 @@ export default ({ itemKey, type, value, title, onUpdateValue }) => {
|
|||||||
label={labelOptions[itemKey]}
|
label={labelOptions[itemKey]}
|
||||||
value={inputData}
|
value={inputData}
|
||||||
onChangeText={(text) => setInputData(text)}
|
onChangeText={(text) => setInputData(text)}
|
||||||
autoCapitalize={
|
autoCapitalize={['password', 'email'].includes(itemKey) ? 'none' : 'words'}
|
||||||
["password", "email"].includes(itemKey) ? "none" : "words"
|
|
||||||
}
|
|
||||||
type={itemKey}
|
type={itemKey}
|
||||||
containerStyle={{ marginBottom: gutters / 2 }}
|
containerStyle={{ marginBottom: gutters / 2 }}
|
||||||
/>
|
/>
|
||||||
@@ -131,16 +116,12 @@ export default ({ itemKey, type, value, title, onUpdateValue }) => {
|
|||||||
<Button
|
<Button
|
||||||
label="Valider"
|
label="Valider"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onUpdateValue({ key: itemKey, value: inputData, currentPassword });
|
onUpdateValue({ key: itemKey, value: inputData, currentPassword })
|
||||||
setShowDialog(false);
|
setShowDialog(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button label="Annuler" onPress={() => setShowDialog(false)} type={'secondary'} />
|
||||||
label="Annuler"
|
|
||||||
onPress={() => setShowDialog(false)}
|
|
||||||
type={"secondary"}
|
|
||||||
/>
|
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useKeyboard } from "@react-native-community/hooks";
|
import { useKeyboard } from '@react-native-community/hooks'
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Platform, StyleSheet, View } from "react-native";
|
import { Platform, StyleSheet, View } from 'react-native'
|
||||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||||
import BorderGradient from "../BorderGradient/BorderGradient";
|
import BorderGradient from '../BorderGradient/BorderGradient'
|
||||||
|
|
||||||
const ItemContainer = ({
|
const ItemContainer = ({
|
||||||
height = responsiveHeight(40),
|
height = responsiveHeight(40),
|
||||||
@@ -12,23 +12,19 @@ const ItemContainer = ({
|
|||||||
style,
|
style,
|
||||||
disableKeyboardHeight = false,
|
disableKeyboardHeight = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { keyboardShown } = useKeyboard();
|
const { keyboardShown } = useKeyboard()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BorderGradient
|
<BorderGradient
|
||||||
gradientProps={{
|
gradientProps={{
|
||||||
colors: ["#FFFFFF00", "#FFFFFF"],
|
colors: ['#FFFFFF00', '#FFFFFF'],
|
||||||
start: { x: 0.3, y: 0 },
|
start: { x: 0.3, y: 0 },
|
||||||
end: { x: 1, y: 1 },
|
end: { x: 1, y: 1 },
|
||||||
...gradientProps,
|
...gradientProps,
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
...styles.borderGradientStyle,
|
...styles.borderGradientStyle,
|
||||||
height: !disableKeyboardHeight
|
height: !disableKeyboardHeight ? (keyboardShown ? responsiveHeight(30) : height) : height,
|
||||||
? keyboardShown
|
|
||||||
? responsiveHeight(30)
|
|
||||||
: height
|
|
||||||
: height,
|
|
||||||
|
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
@@ -40,23 +36,23 @@ const ItemContainer = ({
|
|||||||
android: 100,
|
android: 100,
|
||||||
web: 100,
|
web: 100,
|
||||||
})}
|
})}
|
||||||
tint={"dark"}
|
tint={'dark'}
|
||||||
style={{ flex: 1, padding: 6 }}
|
style={{ flex: 1, padding: 6 }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</View>
|
</View>
|
||||||
</BorderGradient>
|
</BorderGradient>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ItemContainer;
|
export default ItemContainer
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
borderGradientStyle: {
|
borderGradientStyle: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
shadowColor: "#000",
|
shadowColor: '#000',
|
||||||
shadowOffset: {
|
shadowOffset: {
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 2,
|
height: 2,
|
||||||
@@ -68,7 +64,7 @@ const styles = StyleSheet.create({
|
|||||||
blurContainer: {
|
blurContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import { View, StyleSheet } from "react-native";
|
import { View, StyleSheet } from 'react-native'
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from 'react'
|
||||||
import omit from "lodash/omit";
|
import omit from 'lodash/omit'
|
||||||
import BorderGradient from "../BorderGradient/BorderGradient";
|
import BorderGradient from '../BorderGradient/BorderGradient'
|
||||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
import { Style } from "../../styles";
|
import { Style } from '../../styles'
|
||||||
|
|
||||||
const ITEM_BORDER_RADIUS = 24;
|
const ITEM_BORDER_RADIUS = 24
|
||||||
const CONTAINER_STYLE_PROPS = [
|
const CONTAINER_STYLE_PROPS = [
|
||||||
"margin",
|
'margin',
|
||||||
"marginTop",
|
'marginTop',
|
||||||
"marginBottom",
|
'marginBottom',
|
||||||
"marginLeft",
|
'marginLeft',
|
||||||
"marginRight",
|
'marginRight',
|
||||||
"marginHorizontal",
|
'marginHorizontal',
|
||||||
"marginVertical",
|
'marginVertical',
|
||||||
"alignSelf",
|
'alignSelf',
|
||||||
"alignItems",
|
'alignItems',
|
||||||
"justifyContent",
|
'justifyContent',
|
||||||
"width",
|
'width',
|
||||||
"minWidth",
|
'minWidth',
|
||||||
"maxWidth",
|
'maxWidth',
|
||||||
];
|
]
|
||||||
|
|
||||||
const ItemContainer = ({
|
const ItemContainer = ({
|
||||||
height = responsiveHeight(40),
|
height = responsiveHeight(40),
|
||||||
@@ -32,33 +32,33 @@ const ItemContainer = ({
|
|||||||
style,
|
style,
|
||||||
disableKeyboardHeight = false, // parity with native signature
|
disableKeyboardHeight = false, // parity with native signature
|
||||||
}) => {
|
}) => {
|
||||||
const [containerLayout, setContainerLayout] = useState(null);
|
const [containerLayout, setContainerLayout] = useState(null)
|
||||||
const flattenedStyle = StyleSheet.flatten(style) || {};
|
const flattenedStyle = StyleSheet.flatten(style) || {}
|
||||||
const containerStyleOverrides = useMemo(() => {
|
const containerStyleOverrides = useMemo(() => {
|
||||||
return CONTAINER_STYLE_PROPS.reduce((acc, key) => {
|
return CONTAINER_STYLE_PROPS.reduce((acc, key) => {
|
||||||
if (typeof flattenedStyle[key] !== "undefined") {
|
if (typeof flattenedStyle[key] !== 'undefined') {
|
||||||
acc[key] = flattenedStyle[key];
|
acc[key] = flattenedStyle[key]
|
||||||
}
|
}
|
||||||
return acc;
|
return acc
|
||||||
}, {});
|
}, {})
|
||||||
}, [flattenedStyle]);
|
}, [flattenedStyle])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
width: styleWidth,
|
width: styleWidth,
|
||||||
maxWidth: styleMaxWidth,
|
maxWidth: styleMaxWidth,
|
||||||
minWidth: styleMinWidth,
|
minWidth: styleMinWidth,
|
||||||
...remainingContainerStyle
|
...remainingContainerStyle
|
||||||
} = containerStyleOverrides;
|
} = containerStyleOverrides
|
||||||
|
|
||||||
const gradientStyleOverrides = useMemo(
|
const gradientStyleOverrides = useMemo(
|
||||||
() => omit(flattenedStyle, CONTAINER_STYLE_PROPS),
|
() => omit(flattenedStyle, CONTAINER_STYLE_PROPS),
|
||||||
[flattenedStyle]
|
[flattenedStyle]
|
||||||
);
|
)
|
||||||
|
|
||||||
const baseHeight = typeof height === "number" ? height : undefined;
|
const baseHeight = typeof height === 'number' ? height : undefined
|
||||||
const measuredHeight = containerLayout?.height ?? baseHeight;
|
const measuredHeight = containerLayout?.height ?? baseHeight
|
||||||
const resolvedWidth = styleWidth ?? width ?? "100%";
|
const resolvedWidth = styleWidth ?? width ?? '100%'
|
||||||
const resolvedMaxWidth = styleMaxWidth ?? maxWidth;
|
const resolvedMaxWidth = styleMaxWidth ?? maxWidth
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -72,7 +72,7 @@ const ItemContainer = ({
|
|||||||
>
|
>
|
||||||
<BorderGradient
|
<BorderGradient
|
||||||
gradientProps={{
|
gradientProps={{
|
||||||
colors: ["rgba(72, 51, 51, 0)", "#FFFFFF"],
|
colors: ['rgba(72, 51, 51, 0)', '#FFFFFF'],
|
||||||
start: { x: 0.3, y: 0 },
|
start: { x: 0.3, y: 0 },
|
||||||
end: { x: 1, y: 1 },
|
end: { x: 1, y: 1 },
|
||||||
locations: [0, 1],
|
locations: [0, 1],
|
||||||
@@ -91,32 +91,28 @@ const ItemContainer = ({
|
|||||||
baseHeight ? { height: baseHeight } : null,
|
baseHeight ? { height: baseHeight } : null,
|
||||||
]}
|
]}
|
||||||
onLayout={(e) => {
|
onLayout={(e) => {
|
||||||
setContainerLayout(e.nativeEvent.layout);
|
setContainerLayout(e.nativeEvent.layout)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BlurView
|
<BlurView intensity={disableKeyboardHeight ? 35 : 45} tint="dark" style={styles.blurView}>
|
||||||
intensity={disableKeyboardHeight ? 35 : 45}
|
|
||||||
tint="dark"
|
|
||||||
style={styles.blurView}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ItemContainer;
|
export default ItemContainer
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
position: "relative",
|
position: 'relative',
|
||||||
alignSelf: "stretch",
|
alignSelf: 'stretch',
|
||||||
},
|
},
|
||||||
borderGradientStyle: {
|
borderGradientStyle: {
|
||||||
borderWidth: 1.2,
|
borderWidth: 1.2,
|
||||||
borderRadius: ITEM_BORDER_RADIUS,
|
borderRadius: ITEM_BORDER_RADIUS,
|
||||||
shadowColor: "rgba(3, 0, 18, 0.58)",
|
shadowColor: 'rgba(3, 0, 18, 0.58)',
|
||||||
shadowOffset: {
|
shadowOffset: {
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 2,
|
height: 2,
|
||||||
@@ -124,21 +120,21 @@ const styles = StyleSheet.create({
|
|||||||
shadowOpacity: 0.22,
|
shadowOpacity: 0.22,
|
||||||
shadowRadius: 18,
|
shadowRadius: 18,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
width: "100%",
|
width: '100%',
|
||||||
},
|
},
|
||||||
contentWrapper: {
|
contentWrapper: {
|
||||||
borderRadius: ITEM_BORDER_RADIUS,
|
borderRadius: ITEM_BORDER_RADIUS,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
blurView: {
|
blurView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: "100%",
|
width: '100%',
|
||||||
height: "100%",
|
height: '100%',
|
||||||
paddingHorizontal: 32,
|
paddingHorizontal: 32,
|
||||||
paddingVertical: 28,
|
paddingVertical: 28,
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
alignSelf: "stretch",
|
alignSelf: 'stretch',
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Image, Pressable, Text, View } from "react-native";
|
import { Image, Pressable, Text, View } from 'react-native'
|
||||||
|
|
||||||
import { responsiveHeight } from "../actions/responsiveSizes";
|
import { responsiveHeight } from '../actions/responsiveSizes'
|
||||||
|
|
||||||
import { Fonts, Palette, Style } from "../styles";
|
import { Fonts, Palette, Style } from '../styles'
|
||||||
import { icons } from "../assets";
|
import { icons } from '../assets'
|
||||||
import IconContainer from "./IconContainer";
|
import IconContainer from './IconContainer'
|
||||||
|
|
||||||
const ItemRowList = ({
|
const ItemRowList = ({
|
||||||
title,
|
title,
|
||||||
@@ -14,7 +14,7 @@ const ItemRowList = ({
|
|||||||
textStyle = {},
|
textStyle = {},
|
||||||
addMarginTopFromPrevious = false,
|
addMarginTopFromPrevious = false,
|
||||||
containerStyle = {},
|
containerStyle = {},
|
||||||
separatorPosition = "bottom",
|
separatorPosition = 'bottom',
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -23,27 +23,21 @@ const ItemRowList = ({
|
|||||||
...containerStyle,
|
...containerStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{separatorPosition === "top" && (
|
{separatorPosition === 'top' && <View style={Style.separatorHorizontal} />}
|
||||||
<View style={Style.separatorHorizontal} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Pressable onPress={action} style={Style.containerSpaceBetween}>
|
<Pressable onPress={action} style={Style.containerSpaceBetween}>
|
||||||
<View style={Style.containerRow}>
|
<View style={Style.containerRow}>
|
||||||
{icon && <IconContainer icon={icon} />}
|
{icon && <IconContainer icon={icon} />}
|
||||||
|
|
||||||
<Text style={Fonts({ type: "section", style: textStyle })}>
|
<Text style={Fonts({ type: 'section', style: textStyle })}>{title}</Text>
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Image source={icons.arrowRight} style={Style.iconSmall} />
|
<Image source={icons.arrowRight} style={Style.iconSmall} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{separatorPosition === "bottom" && (
|
{separatorPosition === 'bottom' && <View style={Style.separatorHorizontal} />}
|
||||||
<View style={Style.separatorHorizontal} />
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ItemRowList;
|
export default ItemRowList
|
||||||
|
|||||||
@@ -1,72 +1,68 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from 'react'
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from 'react-native'
|
||||||
import { Palette } from "../styles";
|
import { Palette } from '../styles'
|
||||||
import { FONT_FAMILY } from "../styles/Fonts";
|
import { FONT_FAMILY } from '../styles/Fonts'
|
||||||
|
|
||||||
export function groupAlignedWordsToLines(alignedWords = [], { removeTags = true } = {}) {
|
export function groupAlignedWordsToLines(alignedWords = [], { removeTags = true } = {}) {
|
||||||
const out = [];
|
const out = []
|
||||||
let buf = [];
|
let buf = []
|
||||||
let start = null;
|
let start = null
|
||||||
const clean = (txt) =>
|
const clean = (txt) =>
|
||||||
String(txt || "")
|
String(txt || '')
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim()
|
||||||
const isSentenceEnd = (txt) => /[.!?]$/.test((txt || "").trim());
|
const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim())
|
||||||
const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || "").trim());
|
const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || '').trim())
|
||||||
|
|
||||||
for (let i = 0; i < alignedWords.length; i++) {
|
for (let i = 0; i < alignedWords.length; i++) {
|
||||||
const w = alignedWords[i] || {};
|
const w = alignedWords[i] || {}
|
||||||
const original = String(w.word || "");
|
const original = String(w.word || '')
|
||||||
const textNoNewline = original.replace(/\n/g, " ");
|
const textNoNewline = original.replace(/\n/g, ' ')
|
||||||
const textNoTag = removeTags
|
const textNoTag = removeTags ? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, '') : textNoNewline
|
||||||
? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, "")
|
const next = alignedWords[i + 1] || null
|
||||||
: textNoNewline;
|
const gapToNext = next ? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0)) : 0
|
||||||
const next = alignedWords[i + 1] || null;
|
|
||||||
const gapToNext = next
|
|
||||||
? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (buf.length === 0) start = Number(w.startS || 0);
|
if (buf.length === 0) start = Number(w.startS || 0)
|
||||||
|
|
||||||
if (isSectionTag(textNoNewline)) {
|
if (isSectionTag(textNoNewline)) {
|
||||||
if (!removeTags) {
|
if (!removeTags) {
|
||||||
const joined = clean(textNoNewline);
|
const joined = clean(textNoNewline)
|
||||||
if (joined)
|
if (joined)
|
||||||
out.push({
|
out.push({
|
||||||
text: joined,
|
text: joined,
|
||||||
startS: start ?? Number(w.startS || 0),
|
startS: start ?? Number(w.startS || 0),
|
||||||
endS: Number(w.endS || 0),
|
endS: Number(w.endS || 0),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
buf = [];
|
buf = []
|
||||||
start = null;
|
start = null
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clean(textNoTag)) {
|
if (!clean(textNoTag)) {
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.push(textNoTag);
|
buf.push(textNoTag)
|
||||||
|
|
||||||
const eolByNewline = /\n/.test(original);
|
const eolByNewline = /\n/.test(original)
|
||||||
const eolByPause = gapToNext >= 0.6; // threshold for a logical break
|
const eolByPause = gapToNext >= 0.6 // threshold for a logical break
|
||||||
const eolByPunct = isSentenceEnd(textNoTag);
|
const eolByPunct = isSentenceEnd(textNoTag)
|
||||||
const isLast = i === alignedWords.length - 1;
|
const isLast = i === alignedWords.length - 1
|
||||||
|
|
||||||
if (eolByNewline || eolByPause || eolByPunct || isLast) {
|
if (eolByNewline || eolByPause || eolByPunct || isLast) {
|
||||||
const joined = clean(buf.join(" "));
|
const joined = clean(buf.join(' '))
|
||||||
if (joined)
|
if (joined)
|
||||||
out.push({
|
out.push({
|
||||||
text: joined,
|
text: joined,
|
||||||
startS: start ?? Number(w.startS || 0),
|
startS: start ?? Number(w.startS || 0),
|
||||||
endS: Number(w.endS || 0),
|
endS: Number(w.endS || 0),
|
||||||
});
|
})
|
||||||
buf = [];
|
buf = []
|
||||||
start = null;
|
start = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KaraokeLyrics({
|
export default function KaraokeLyrics({
|
||||||
@@ -78,30 +74,24 @@ export default function KaraokeLyrics({
|
|||||||
const lines = useMemo(
|
const lines = useMemo(
|
||||||
() => groupAlignedWordsToLines(alignedWords, { removeTags }),
|
() => groupAlignedWordsToLines(alignedWords, { removeTags }),
|
||||||
[alignedWords, removeTags]
|
[alignedWords, removeTags]
|
||||||
);
|
)
|
||||||
|
|
||||||
const currentLineIdx = useMemo(() => {
|
const currentLineIdx = useMemo(() => {
|
||||||
if (!lines || lines.length === 0) return -1;
|
if (!lines || lines.length === 0) return -1
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const L = lines[i];
|
const L = lines[i]
|
||||||
if (currentTimeS >= (L.startS || 0) && currentTimeS <= (L.endS || 0))
|
if (currentTimeS >= (L.startS || 0) && currentTimeS <= (L.endS || 0)) return i
|
||||||
return i;
|
|
||||||
}
|
}
|
||||||
if (currentTimeS > (lines[lines.length - 1]?.endS || 0))
|
if (currentTimeS > (lines[lines.length - 1]?.endS || 0)) return lines.length - 1
|
||||||
return lines.length - 1;
|
return -1
|
||||||
return -1;
|
}, [lines, currentTimeS])
|
||||||
}, [lines, currentTimeS]);
|
|
||||||
|
|
||||||
if (!lines.length) return null;
|
if (!lines.length) return null
|
||||||
|
|
||||||
const prev =
|
const prev = showContext && currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : ''
|
||||||
showContext && currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : "";
|
const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : lines[0]?.text
|
||||||
const curr =
|
|
||||||
currentLineIdx >= 0 ? lines[currentLineIdx]?.text : lines[0]?.text;
|
|
||||||
const next =
|
const next =
|
||||||
showContext && currentLineIdx + 1 < lines.length
|
showContext && currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : ''
|
||||||
? lines[currentLineIdx + 1]?.text
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ gap: 4 }}>
|
<View style={{ gap: 4 }}>
|
||||||
@@ -121,7 +111,7 @@ export default function KaraokeLyrics({
|
|||||||
style={{
|
style={{
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
textAlign: "center",
|
textAlign: 'center',
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -130,9 +120,9 @@ export default function KaraokeLyrics({
|
|||||||
{next ? (
|
{next ? (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#FFFFFF99",
|
color: '#FFFFFF99',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
textAlign: "center",
|
textAlign: 'center',
|
||||||
fontFamily: FONT_FAMILY.InterMedium,
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -140,5 +130,5 @@ export default function KaraokeLyrics({
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from 'expo-blur'
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { FlatList, Platform, SectionList, Text, View } from "react-native";
|
import { FlatList, Platform, SectionList, Text, View } from 'react-native'
|
||||||
import useLayoutType from "../../hooks/useLayoutType";
|
import useLayoutType from '../../hooks/useLayoutType'
|
||||||
import CreateLyricsHeader from "../../screens/Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from '../../screens/Writing/components/CreateLyricsHeader'
|
||||||
import { Palette } from "../../styles";
|
import { Palette } from '../../styles'
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||||
import { LinearGradient } from "../LinearGradient/LinearGradient";
|
import { LinearGradient } from '../LinearGradient/LinearGradient'
|
||||||
|
|
||||||
const BLUE_SELECTION_GRADIENT = ["#4673F9", "#7023F7"];
|
const BLUE_SELECTION_GRADIENT = ['#4673F9', '#7023F7']
|
||||||
|
|
||||||
const buildItemKey = (value, category) =>
|
const buildItemKey = (value, category) => (category ? `${category}::${value}` : `${value}`)
|
||||||
category ? `${category}::${value}` : `${value}`;
|
|
||||||
|
|
||||||
const ListSelection = ({
|
const ListSelection = ({
|
||||||
options = [],
|
options = [],
|
||||||
variant = "simple",
|
variant = 'simple',
|
||||||
selected: selectedProp,
|
selected: selectedProp,
|
||||||
setSelected: setSelectedProp,
|
setSelected: setSelectedProp,
|
||||||
multiple = false,
|
multiple = false,
|
||||||
@@ -27,92 +26,88 @@ const ListSelection = ({
|
|||||||
itemContainerStyle,
|
itemContainerStyle,
|
||||||
itemOuterStyle,
|
itemOuterStyle,
|
||||||
itemTextStyle,
|
itemTextStyle,
|
||||||
highlightColor = "#F94697",
|
highlightColor = '#F94697',
|
||||||
disableHover = false,
|
disableHover = false,
|
||||||
}) => {
|
}) => {
|
||||||
// état interne si non contrôlé
|
// état interne si non contrôlé
|
||||||
const [internalSelected, setInternalSelected] = useState(
|
const [internalSelected, setInternalSelected] = useState(
|
||||||
variant === "sectioned" ? {} : multiple ? [] : null
|
variant === 'sectioned' ? {} : multiple ? [] : null
|
||||||
);
|
)
|
||||||
const selected = selectedProp !== undefined ? selectedProp : internalSelected;
|
const selected = selectedProp !== undefined ? selectedProp : internalSelected
|
||||||
const setSelected =
|
const setSelected = setSelectedProp !== undefined ? setSelectedProp : setInternalSelected
|
||||||
setSelectedProp !== undefined ? setSelectedProp : setInternalSelected;
|
|
||||||
|
|
||||||
const { isWeb } = useLayoutType();
|
const { isWeb } = useLayoutType()
|
||||||
const [hoveredKey, setHoveredKey] = useState(null);
|
const [hoveredKey, setHoveredKey] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disableHover && hoveredKey !== null) setHoveredKey(null);
|
if (disableHover && hoveredKey !== null) setHoveredKey(null)
|
||||||
}, [disableHover, hoveredKey]);
|
}, [disableHover, hoveredKey])
|
||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
const valueOf = useMemo(() => {
|
const valueOf = useMemo(() => {
|
||||||
if (typeof getItemValue === "function") return getItemValue;
|
if (typeof getItemValue === 'function') return getItemValue
|
||||||
if (variant === "simple") return (item) => item;
|
if (variant === 'simple') return (item) => item
|
||||||
return (item) => item?.title ?? item;
|
return (item) => item?.title ?? item
|
||||||
}, [getItemValue, variant]);
|
}, [getItemValue, variant])
|
||||||
|
|
||||||
const formatValue = (item) =>
|
const formatValue = (item) =>
|
||||||
typeof formatSelectedValue === "function"
|
typeof formatSelectedValue === 'function' ? formatSelectedValue(item) : valueOf(item)
|
||||||
? formatSelectedValue(item)
|
|
||||||
: valueOf(item);
|
|
||||||
|
|
||||||
const extractSelectedComparable = (s) => {
|
const extractSelectedComparable = (s) => {
|
||||||
if (typeof selectedValueExtractor === "function")
|
if (typeof selectedValueExtractor === 'function') return selectedValueExtractor(s)
|
||||||
return selectedValueExtractor(s);
|
if (s && typeof s === 'object' && 'title' in s) return s.title
|
||||||
if (s && typeof s === "object" && "title" in s) return s.title;
|
return s
|
||||||
return s;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const isSelected = (val, category) => {
|
const isSelected = (val, category) => {
|
||||||
if (variant === "sectioned") return selected?.[category] === val;
|
if (variant === 'sectioned') return selected?.[category] === val
|
||||||
if (multiple) {
|
if (multiple) {
|
||||||
const list = Array.isArray(selected) ? selected : [];
|
const list = Array.isArray(selected) ? selected : []
|
||||||
return list.some((v) => v === val);
|
return list.some((v) => v === val)
|
||||||
|
}
|
||||||
|
return extractSelectedComparable(selected) === val
|
||||||
}
|
}
|
||||||
return extractSelectedComparable(selected) === val;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSelect = (item, category) => {
|
const toggleSelect = (item, category) => {
|
||||||
const val = valueOf(item);
|
const val = valueOf(item)
|
||||||
|
|
||||||
if (variant === "sectioned") {
|
if (variant === 'sectioned') {
|
||||||
const current = selected && typeof selected === "object" ? selected : {};
|
const current = selected && typeof selected === 'object' ? selected : {}
|
||||||
const next = { ...current };
|
const next = { ...current }
|
||||||
if (current?.[category] === val) next[category] = null;
|
if (current?.[category] === val) next[category] = null
|
||||||
else next[category] = formatValue(item);
|
else next[category] = formatValue(item)
|
||||||
setSelected(next);
|
setSelected(next)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (multiple) {
|
if (multiple) {
|
||||||
const list = Array.isArray(selected) ? selected : [];
|
const list = Array.isArray(selected) ? selected : []
|
||||||
const exists = list.some((v) => v === val);
|
const exists = list.some((v) => v === val)
|
||||||
if (exists) setSelected(list.filter((v) => v !== val));
|
if (exists) setSelected(list.filter((v) => v !== val))
|
||||||
else if (!maxSelection || list.length < maxSelection)
|
else if (!maxSelection || list.length < maxSelection)
|
||||||
setSelected([...list, formatValue(item)]);
|
setSelected([...list, formatValue(item)])
|
||||||
} else {
|
} else {
|
||||||
if (extractSelectedComparable(selected) === val) setSelected(null);
|
if (extractSelectedComparable(selected) === val) setSelected(null)
|
||||||
else setSelected(formatValue(item));
|
else setSelected(formatValue(item))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// contenus élémentaires : toujours encapsuler le texte dans <Text>
|
// contenus élémentaires : toujours encapsuler le texte dans <Text>
|
||||||
const renderSimpleContent = (label) => (
|
const renderSimpleContent = (label) => (
|
||||||
<View style={{ minHeight: 40, justifyContent: "center" }}>
|
<View style={{ minHeight: 40, justifyContent: 'center' }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
textAlign: "left",
|
textAlign: 'left',
|
||||||
...itemTextStyle,
|
...itemTextStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{typeof label === "string" ? label : String(label)}
|
{typeof label === 'string' ? label : String(label)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
|
|
||||||
const renderTitleDescriptionContent = (item) => (
|
const renderTitleDescriptionContent = (item) => (
|
||||||
<View style={{ paddingVertical: 6 }}>
|
<View style={{ paddingVertical: 6 }}>
|
||||||
@@ -125,14 +120,14 @@ const ListSelection = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontFamily: FONT_FAMILY.InterBold }}>{item?.title}</Text>
|
<Text style={{ fontFamily: FONT_FAMILY.InterBold }}>{item?.title}</Text>
|
||||||
<Text>{` : ${item?.description ?? ""}`}</Text>
|
<Text>{` : ${item?.description ?? ''}`}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
|
|
||||||
// applique le gradient bleu UNIQUEMENT sur web et quand sélectionné
|
// applique le gradient bleu UNIQUEMENT sur web et quand sélectionné
|
||||||
const withWebBlueGradientIfSelected = (content, sel) => {
|
const withWebBlueGradientIfSelected = (content, sel) => {
|
||||||
if (!isWeb || !sel) return content;
|
if (!isWeb || !sel) return content
|
||||||
// wrap dans un View pour éviter texte brut direct sous le gradient (compat RN Web)
|
// wrap dans un View pour éviter texte brut direct sous le gradient (compat RN Web)
|
||||||
return (
|
return (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
@@ -144,62 +139,53 @@ const ListSelection = ({
|
|||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View>{content}</View>
|
<View>{content}</View>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
// ============ SECTIONED ============
|
// ============ SECTIONED ============
|
||||||
if (variant === "sectioned") {
|
if (variant === 'sectioned') {
|
||||||
return (
|
return (
|
||||||
<SectionList
|
<SectionList
|
||||||
sections={options}
|
sections={options}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={contentContainerStyle}
|
contentContainerStyle={contentContainerStyle}
|
||||||
renderItem={({ item, section }) => {
|
renderItem={({ item, section }) => {
|
||||||
const cat = section?.title;
|
const cat = section?.title
|
||||||
const val = valueOf(item);
|
const val = valueOf(item)
|
||||||
const sel = isSelected(val, cat);
|
const sel = isSelected(val, cat)
|
||||||
const itemKey = buildItemKey(String(val ?? ""), cat);
|
const itemKey = buildItemKey(String(val ?? ''), cat)
|
||||||
const isHovered = isWeb && !disableHover && hoveredKey === itemKey;
|
const isHovered = isWeb && !disableHover && hoveredKey === itemKey
|
||||||
const showHoverOutline = isHovered && !sel;
|
const showHoverOutline = isHovered && !sel
|
||||||
const showSelectionOutline = !isWeb && sel;
|
const showSelectionOutline = !isWeb && sel
|
||||||
|
|
||||||
const baseContent = renderSimpleContent(item);
|
const baseContent = renderSimpleContent(item)
|
||||||
const headerContainerStyle = {
|
const headerContainerStyle = {
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
borderColor: "transparent",
|
borderColor: 'transparent',
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
...itemContainerStyle,
|
...itemContainerStyle,
|
||||||
};
|
}
|
||||||
if (sel && isWeb)
|
if (sel && isWeb) headerContainerStyle.backgroundColor = 'transparent'
|
||||||
headerContainerStyle.backgroundColor = "transparent";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[{ position: "relative" }, itemOuterStyle]}
|
style={[{ position: 'relative' }, itemOuterStyle]}
|
||||||
onMouseEnter={
|
onMouseEnter={isWeb && !disableHover ? () => setHoveredKey(itemKey) : undefined}
|
||||||
isWeb && !disableHover
|
onMouseLeave={isWeb && !disableHover ? () => setHoveredKey(null) : undefined}
|
||||||
? () => setHoveredKey(itemKey)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onMouseLeave={
|
|
||||||
isWeb && !disableHover ? () => setHoveredKey(null) : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<CreateLyricsHeader
|
<CreateLyricsHeader
|
||||||
onPress={() => toggleSelect(item, cat)}
|
onPress={() => toggleSelect(item, cat)}
|
||||||
tint={sel ? "default" : "dark"}
|
tint={sel ? 'default' : 'dark'}
|
||||||
colors={[Palette.tran, Palette.tran]}
|
colors={[Palette.tran, Palette.tran]}
|
||||||
showBorder={!sel}
|
showBorder={!sel}
|
||||||
disableBlur={sel && isWeb}
|
disableBlur={sel && isWeb}
|
||||||
blurViewStyle={
|
blurViewStyle={
|
||||||
sel && isWeb
|
sel && isWeb ? { paddingHorizontal: 0, paddingVertical: 0 } : undefined
|
||||||
? { paddingHorizontal: 0, paddingVertical: 0 }
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
containerStyle={headerContainerStyle}
|
containerStyle={headerContainerStyle}
|
||||||
>
|
>
|
||||||
@@ -210,7 +196,7 @@ const ListSelection = ({
|
|||||||
<View
|
<View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -223,21 +209,17 @@ const ListSelection = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
}}
|
}}
|
||||||
renderSectionHeader={({ section: { title } }) => (
|
renderSectionHeader={({ section: { title } }) => (
|
||||||
<View
|
<View style={{ alignSelf: 'flex-start', marginLeft: 10, marginBottom: 6 }}>
|
||||||
style={{ alignSelf: "flex-start", marginLeft: 10, marginBottom: 6 }}
|
|
||||||
>
|
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={40}
|
intensity={40}
|
||||||
tint="dark"
|
tint="dark"
|
||||||
experimentalBlurMethod={
|
experimentalBlurMethod={Platform.OS !== 'ios' ? 'dimezisBlurView' : 'none'}
|
||||||
Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
|
||||||
}
|
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
}}
|
}}
|
||||||
@@ -256,7 +238,7 @@ const ListSelection = ({
|
|||||||
)}
|
)}
|
||||||
keyExtractor={(item, idx) => `${valueOf(item)}-${idx}`}
|
keyExtractor={(item, idx) => `${valueOf(item)}-${idx}`}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ FLAT (simple/titleDescription/emotion) ============
|
// ============ FLAT (simple/titleDescription/emotion) ============
|
||||||
@@ -266,66 +248,54 @@ const ListSelection = ({
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={contentContainerStyle}
|
contentContainerStyle={contentContainerStyle}
|
||||||
renderItem={({ item }) => {
|
renderItem={({ item }) => {
|
||||||
const val = valueOf(item);
|
const val = valueOf(item)
|
||||||
const sel = isSelected(val);
|
const sel = isSelected(val)
|
||||||
const useEmotionColors = variant === "emotion";
|
const useEmotionColors = variant === 'emotion'
|
||||||
|
|
||||||
// pour "emotion", on masque le bord gauche si sélectionné
|
// pour "emotion", on masque le bord gauche si sélectionné
|
||||||
const borderColors = useEmotionColors
|
const borderColors = useEmotionColors
|
||||||
? sel
|
? sel
|
||||||
? [Palette.tran, Palette.tran]
|
? [Palette.tran, Palette.tran]
|
||||||
: item?.color
|
: item?.color
|
||||||
: [Palette.tran, Palette.tran];
|
: [Palette.tran, Palette.tran]
|
||||||
const tint = useEmotionColors ? "dark" : sel ? "default" : "dark";
|
const tint = useEmotionColors ? 'dark' : sel ? 'default' : 'dark'
|
||||||
|
|
||||||
const itemKey = buildItemKey(String(val ?? ""));
|
const itemKey = buildItemKey(String(val ?? ''))
|
||||||
const isHovered = isWeb && !disableHover && hoveredKey === itemKey;
|
const isHovered = isWeb && !disableHover && hoveredKey === itemKey
|
||||||
const showHoverOutline = isHovered && !sel;
|
const showHoverOutline = isHovered && !sel
|
||||||
const showSelectionOutline =
|
const showSelectionOutline =
|
||||||
!isWeb &&
|
!isWeb &&
|
||||||
(variant === "simple" ||
|
(variant === 'simple' || variant === 'emotion' || variant === 'titleDescription') &&
|
||||||
variant === "emotion" ||
|
sel
|
||||||
variant === "titleDescription") &&
|
|
||||||
sel;
|
|
||||||
|
|
||||||
const baseContent =
|
const baseContent =
|
||||||
variant === "simple"
|
variant === 'simple' ? renderSimpleContent(item) : renderTitleDescriptionContent(item)
|
||||||
? renderSimpleContent(item)
|
|
||||||
: renderTitleDescriptionContent(item);
|
|
||||||
|
|
||||||
const headerContainerStyle = {
|
const headerContainerStyle = {
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
borderColor: "transparent",
|
borderColor: 'transparent',
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
...itemContainerStyle,
|
...itemContainerStyle,
|
||||||
};
|
}
|
||||||
if (sel && isWeb) headerContainerStyle.backgroundColor = "transparent";
|
if (sel && isWeb) headerContainerStyle.backgroundColor = 'transparent'
|
||||||
|
|
||||||
const shouldShowBorder = !sel;
|
const shouldShowBorder = !sel
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[{ position: "relative" }, itemOuterStyle]}
|
style={[{ position: 'relative' }, itemOuterStyle]}
|
||||||
onMouseEnter={
|
onMouseEnter={isWeb && !disableHover ? () => setHoveredKey(itemKey) : undefined}
|
||||||
isWeb && !disableHover ? () => setHoveredKey(itemKey) : undefined
|
onMouseLeave={isWeb && !disableHover ? () => setHoveredKey(null) : undefined}
|
||||||
}
|
|
||||||
onMouseLeave={
|
|
||||||
isWeb && !disableHover ? () => setHoveredKey(null) : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<CreateLyricsHeader
|
<CreateLyricsHeader
|
||||||
colors={borderColors}
|
colors={borderColors}
|
||||||
tint={tint}
|
tint={tint}
|
||||||
onPress={() => toggleSelect(item)}
|
onPress={() => toggleSelect(item)}
|
||||||
gradientProps={
|
gradientProps={useEmotionColors && !sel ? { locations: [0.24, 1] } : undefined}
|
||||||
useEmotionColors && !sel ? { locations: [0.24, 1] } : undefined
|
|
||||||
}
|
|
||||||
showBorder={shouldShowBorder}
|
showBorder={shouldShowBorder}
|
||||||
disableBlur={sel && isWeb} // web: pas de blur si gradient
|
disableBlur={sel && isWeb} // web: pas de blur si gradient
|
||||||
blurViewStyle={
|
blurViewStyle={
|
||||||
sel && isWeb
|
sel && isWeb ? { paddingHorizontal: 0, paddingVertical: 0 } : undefined
|
||||||
? { paddingHorizontal: 0, paddingVertical: 0 }
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
containerStyle={headerContainerStyle}
|
containerStyle={headerContainerStyle}
|
||||||
>
|
>
|
||||||
@@ -336,7 +306,7 @@ const ListSelection = ({
|
|||||||
<View
|
<View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -349,11 +319,11 @@ const ListSelection = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
}}
|
}}
|
||||||
keyExtractor={(item, idx) => `${valueOf(item)}-${idx}`}
|
keyExtractor={(item, idx) => `${valueOf(item)}-${idx}`}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ListSelection;
|
export default ListSelection
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import NativeMaskedView from "@react-native-masked-view/masked-view";
|
import NativeMaskedView from '@react-native-masked-view/masked-view'
|
||||||
|
|
||||||
const MaskedView = NativeMaskedView;
|
const MaskedView = NativeMaskedView
|
||||||
export default MaskedView;
|
export default MaskedView
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from "react";
|
import React from 'react'
|
||||||
import { View } from "react-native";
|
import { View } from 'react-native'
|
||||||
|
|
||||||
function MaskedView({ maskElement, ...props }) {
|
function MaskedView({ maskElement, ...props }) {
|
||||||
return React.createElement(View, props, maskElement);
|
return React.createElement(View, props, maskElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MaskedView;
|
export default MaskedView
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user