Fixes and tickets
@@ -37,6 +37,14 @@
|
||||
{
|
||||
"client_id": "305598753437-dtjknrpfrjt3bdd7838nga41m5i4emva.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.omedis.musicland.ai",
|
||||
"app_store_id": "6754162822"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 20 KiB |
@@ -15,9 +15,6 @@
|
||||
"updates": {
|
||||
"fallbackToCacheTimeout": 0
|
||||
},
|
||||
"packagerOpts": {
|
||||
"sourceExts": ["js", "json", "ts", "tsx", "jsx", "vue"]
|
||||
},
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "cover",
|
||||
@@ -26,10 +23,11 @@
|
||||
"ios": {
|
||||
"googleServicesFile": "./config/GoogleService-Info.plist",
|
||||
"supportsTablet": true,
|
||||
"usesAppleSignIn": true,
|
||||
"requireFullScreen": true,
|
||||
"userInterfaceStyle": "dark",
|
||||
"associatedDomains": ["applinks:minuit.starter"],
|
||||
"bundleIdentifier": "com.omedis.musicland",
|
||||
"bundleIdentifier": "com.omedis.musicland.ai",
|
||||
"infoPlist": {
|
||||
"UISupportedInterfaceOrientations": [
|
||||
"UIInterfaceOrientationPortrait",
|
||||
|
||||
@@ -3,21 +3,23 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CLIENT_ID</key>
|
||||
<string>943006074419-h43c7p48nhn5f4td4oimv4dq7b1p3s29.apps.googleusercontent.com</string>
|
||||
<string>305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk.apps.googleusercontent.com</string>
|
||||
<key>REVERSED_CLIENT_ID</key>
|
||||
<string>com.googleusercontent.apps.943006074419-h43c7p48nhn5f4td4oimv4dq7b1p3s29</string>
|
||||
<string>com.googleusercontent.apps.305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk</string>
|
||||
<key>ANDROID_CLIENT_ID</key>
|
||||
<string>305598753437-2oe78um7k9fagc9cjrb9vurldn5a0a4c.apps.googleusercontent.com</string>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyAkl2RAzUCYTQCcYhSeij6-tXNk6z7121Y</string>
|
||||
<string>AIzaSyCHExqSfSbgk-hFmmh0ylMAvqzp7JqES4c</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>943006074419</string>
|
||||
<string>305598753437</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.minuit.starter</string>
|
||||
<string>com.omedis.musicland.ai</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>minuitcloud</string>
|
||||
<string>musicland-d33f9</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>minuitcloud.appspot.com</string>
|
||||
<string>musicland-d33f9.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
@@ -29,6 +31,6 @@
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:943006074419:ios:5deb4cce66c04c09658546</string>
|
||||
<string>1:305598753437:ios:7942793c4093dedff28274</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -37,6 +37,14 @@
|
||||
{
|
||||
"client_id": "305598753437-dtjknrpfrjt3bdd7838nga41m5i4emva.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.omedis.musicland.ai",
|
||||
"app_store_id": "6754162822"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,14 +25,5 @@
|
||||
"autoIncrement": "buildNumber"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleId": "letawny@gmail.com",
|
||||
"ascAppId": "1661696886",
|
||||
"appleTeamId": "MW8MX2T3ZL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y";
|
||||
// exports.SUNO_API_KEY = "543b5650b30d93e98d8046a830244c80"; client api key
|
||||
exports.SUNO_API_KEY = "138bace8405b869f917156440acb6fed"; // perso api key
|
||||
exports.SUNO_API_KEY = "5a689606d3c59f58268b4963d5107361"; // api key
|
||||
|
||||
exports.RESEND_API_KEY = "re_";
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script interactif pour supprimer toutes les Cloud Functions d'un projet Firebase
|
||||
# Utilise les flèches pour naviguer et Entrée pour sélectionner
|
||||
|
||||
# Couleurs pour l'affichage
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
BOLD='\033[1m'
|
||||
|
||||
# Fonction pour afficher le menu avec sélection
|
||||
show_menu() {
|
||||
local options=("$@")
|
||||
local selected=0
|
||||
local num_options=${#options[@]}
|
||||
|
||||
while true; do
|
||||
clear
|
||||
echo -e "${BLUE}${BOLD}=== Gestionnaire Cloud Functions Firebase ===${NC}\n"
|
||||
|
||||
if [ ! -z "$CURRENT_PROJECT" ]; then
|
||||
echo -e "${GREEN}Projet actuel: ${BOLD}$CURRENT_PROJECT${NC}\n"
|
||||
fi
|
||||
|
||||
echo "Utilisez les flèches ↑↓ pour naviguer et Entrée pour sélectionner:\n"
|
||||
|
||||
for i in "${!options[@]}"; do
|
||||
if [ $i -eq $selected ]; then
|
||||
echo -e "${GREEN}► ${options[$i]}${NC}"
|
||||
else
|
||||
echo -e " ${options[$i]}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Lire une seule touche
|
||||
read -rsn1 key
|
||||
|
||||
case $key in
|
||||
$'\033') # Séquence d'échappement pour les flèches
|
||||
read -rsn2 key
|
||||
case $key in
|
||||
'[A') # Flèche haut
|
||||
((selected--))
|
||||
if [ $selected -lt 0 ]; then
|
||||
selected=$((num_options-1))
|
||||
fi
|
||||
;;
|
||||
'[B') # Flèche bas
|
||||
((selected++))
|
||||
if [ $selected -ge $num_options ]; then
|
||||
selected=0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
'') # Entrée
|
||||
return $selected
|
||||
;;
|
||||
'q'|'Q') # Quitter
|
||||
echo -e "\n${YELLOW}Au revoir!${NC}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Fonction pour vérifier si gcloud est installé
|
||||
check_gcloud() {
|
||||
if ! command -v gcloud &> /dev/null; then
|
||||
echo -e "${RED}Erreur: gcloud CLI n'est pas installé.${NC}"
|
||||
echo "Veuillez installer Google Cloud SDK: https://cloud.google.com/sdk/docs/install"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Fonction pour obtenir le projet actuel
|
||||
get_current_project() {
|
||||
CURRENT_PROJECT=$(gcloud config get-value project 2>/dev/null)
|
||||
if [ -z "$CURRENT_PROJECT" ]; then
|
||||
echo -e "${YELLOW}Aucun projet configuré.${NC}"
|
||||
CURRENT_PROJECT="Aucun"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fonction pour afficher les informations de débogage
|
||||
show_debug_info() {
|
||||
clear
|
||||
echo -e "${BLUE}${BOLD}=== Informations de débogage ===${NC}\n"
|
||||
|
||||
echo -e "${YELLOW}1. Version de gcloud:${NC}"
|
||||
gcloud version 2>/dev/null || echo "Erreur lors de la récupération de la version"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}2. Configuration actuelle:${NC}"
|
||||
gcloud config list 2>/dev/null || echo "Erreur lors de la récupération de la configuration"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}3. Comptes authentifiés:${NC}"
|
||||
gcloud auth list 2>/dev/null || echo "Erreur lors de la récupération des comptes"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}4. Test de la commande projects list (brute):${NC}"
|
||||
echo "Commande: gcloud projects list"
|
||||
gcloud projects list 2>&1 || echo "Erreur lors de l'exécution"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}5. Test avec format JSON:${NC}"
|
||||
echo "Commande: gcloud projects list --format=json"
|
||||
gcloud projects list --format="json" 2>&1 | head -20 || echo "Erreur lors de l'exécution"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}6. Permissions et quotas:${NC}"
|
||||
echo "Commande: gcloud projects list --limit=1"
|
||||
gcloud projects list --limit=1 2>&1 || echo "Erreur lors de l'exécution"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Fonction pour gérer l'authentification
|
||||
handle_authentication() {
|
||||
clear
|
||||
echo -e "${RED}${BOLD}=== Erreur d'authentification ===${NC}\n"
|
||||
echo -e "${YELLOW}Aucun projet trouvé ou erreur d'authentification.${NC}\n"
|
||||
|
||||
local auth_options=("Se connecter maintenant (gcloud auth login)" "Réessayer" "← Retour au menu principal")
|
||||
|
||||
echo "Que souhaitez-vous faire ?\n"
|
||||
show_menu "${auth_options[@]}"
|
||||
local choice=$?
|
||||
|
||||
case $choice in
|
||||
0) # Se connecter
|
||||
echo -e "\n${BLUE}Lancement de l'authentification...${NC}"
|
||||
echo -e "${YELLOW}Une page web va s'ouvrir pour vous connecter.${NC}\n"
|
||||
|
||||
if gcloud auth login; then
|
||||
echo -e "\n${GREEN}Authentification réussie!${NC}"
|
||||
sleep 2
|
||||
return 0
|
||||
else
|
||||
echo -e "\n${RED}Échec de l'authentification.${NC}"
|
||||
read -p "Appuyez sur Entrée pour continuer..."
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
1) # Réessayer
|
||||
return 0
|
||||
;;
|
||||
2) # Retour
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Fonction pour récupérer la liste des projets
|
||||
get_projects_list() {
|
||||
local projects=()
|
||||
|
||||
# Vérifier si l'utilisateur est connecté
|
||||
local auth_status=$(gcloud auth list --format="value(account)" 2>/dev/null | head -1)
|
||||
if [ -z "$auth_status" ]; then
|
||||
echo -e "${RED}Vous n'êtes pas connecté à GCP.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Récupération des projets...${NC}"
|
||||
|
||||
# Méthode 1: Utiliser --format=json pour éviter les problèmes de parsing
|
||||
local temp_file=$(mktemp)
|
||||
gcloud projects list --format="json" > "$temp_file" 2>/dev/null
|
||||
|
||||
if [ -s "$temp_file" ]; then
|
||||
# Extraire les IDs de projet du JSON
|
||||
while read -r project_id; do
|
||||
if [ ! -z "$project_id" ]; then
|
||||
projects+=("$project_id")
|
||||
fi
|
||||
done < <(cat "$temp_file" | grep -o '"projectId": "[^"]*"' | cut -d '"' -f 4)
|
||||
else
|
||||
# Méthode 2: Essayer avec la méthode originale
|
||||
gcloud projects list --format="table(projectId)" --sort-by=projectId > "$temp_file" 2>&1
|
||||
|
||||
while IFS= read -r line; do
|
||||
if [[ $line =~ ^[a-zA-Z0-9-]+[[:space:]] ]]; then
|
||||
project_id=$(echo "$line" | awk '{print $1}')
|
||||
if [ ! -z "$project_id" ]; then
|
||||
projects+=("$project_id")
|
||||
fi
|
||||
fi
|
||||
done < <(cat "$temp_file" | tail -n +2)
|
||||
fi
|
||||
|
||||
# Nettoyer le fichier temporaire
|
||||
rm -f "$temp_file"
|
||||
|
||||
# Retourner les projets via une variable globale
|
||||
PROJECTS_LIST=("${projects[@]}")
|
||||
return 0
|
||||
}
|
||||
|
||||
# Fonction pour sélectionner un projet de manière interactive avec filtrage
|
||||
select_project_interactive() {
|
||||
echo -e "\n${BLUE}Récupération de la liste des projets...${NC}"
|
||||
|
||||
# Récupérer tous les projets disponibles
|
||||
local all_projects=()
|
||||
if ! get_projects_list; then
|
||||
if ! handle_authentication; then
|
||||
return 1
|
||||
fi
|
||||
if ! get_projects_list; then
|
||||
echo -e "${RED}Impossible de récupérer la liste des projets.${NC}"
|
||||
read -p "Appuyez sur Entrée pour continuer..."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
all_projects=("${PROJECTS_LIST[@]}")
|
||||
|
||||
if [ ${#all_projects[@]} -eq 0 ]; then
|
||||
echo -e "${RED}Aucun projet accessible trouvé.${NC}"
|
||||
echo -e "${YELLOW}Cela peut être dû à:${NC}"
|
||||
echo -e " • Aucun projet associé à votre compte"
|
||||
echo -e " • Permissions insuffisantes"
|
||||
echo -e " • Problème de configuration gcloud"
|
||||
echo ""
|
||||
|
||||
local retry_options=("Réessayer" "Se reconnecter" "Spécifier manuellement l'ID du projet" "Afficher les détails de débogage" "← Retour au menu principal")
|
||||
echo "Que souhaitez-vous faire ?\n"
|
||||
show_menu "${retry_options[@]}"
|
||||
local retry_choice=$?
|
||||
|
||||
case $retry_choice in
|
||||
0) # Réessayer
|
||||
return select_project_interactive
|
||||
;;
|
||||
1) # Se reconnecter
|
||||
if handle_authentication; then
|
||||
return select_project_interactive
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
2) # Spécifier manuellement l'ID du projet
|
||||
clear
|
||||
echo -e "${BLUE}${BOLD}=== Spécifier l'ID du projet ===${NC}\n"
|
||||
echo -e "${YELLOW}Entrez l'ID du projet Firebase/GCP:${NC}"
|
||||
echo -e "(exemple: my-project-12345)\n"
|
||||
read -p "> " manual_project_id
|
||||
|
||||
if [ -z "$manual_project_id" ]; then
|
||||
echo -e "\n${RED}Aucun ID de projet spécifié.${NC}"
|
||||
read -p "Appuyez sur Entrée pour continuer..."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "\n${BLUE}Vérification du projet: $manual_project_id${NC}"
|
||||
if gcloud projects describe "$manual_project_id" &>/dev/null; then
|
||||
echo -e "${GREEN}Projet trouvé!${NC}"
|
||||
all_projects=("$manual_project_id")
|
||||
else
|
||||
echo -e "${RED}Projet non trouvé ou vous n'avez pas les permissions nécessaires.${NC}"
|
||||
read -p "Appuyez sur Entrée pour continuer..."
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
3) # Afficher les détails de débogage
|
||||
show_debug_info
|
||||
read -p "Appuyez sur Entrée pour continuer..."
|
||||
return 1
|
||||
;;
|
||||
4) # Retour
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Ajouter option de retour
|
||||
all_projects+=("← Retour au menu principal")
|
||||
|
||||
local selected=0
|
||||
local filter=""
|
||||
local filtered_projects=()
|
||||
|
||||
# Fonction pour filtrer les projets
|
||||
filter_projects() {
|
||||
filtered_projects=()
|
||||
for project in "${all_projects[@]}"; do
|
||||
if [[ "$project" == *"$filter"* ]]; then
|
||||
filtered_projects+=("$project")
|
||||
fi
|
||||
done
|
||||
|
||||
# Réajuster la sélection si nécessaire
|
||||
if [ $selected -ge ${#filtered_projects[@]} ]; then
|
||||
selected=0
|
||||
fi
|
||||
}
|
||||
|
||||
# Initialiser le filtre
|
||||
filter_projects
|
||||
|
||||
while true; do
|
||||
clear
|
||||
echo -e "${BLUE}${BOLD}=== Sélection du projet ===${NC}\n"
|
||||
|
||||
echo -e "${YELLOW}Projet actuel: ${BOLD}${CURRENT_PROJECT:-"Aucun"}${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}Tapez pour filtrer, utilisez ↑↓ pour naviguer, Entrée pour sélectionner:${NC}"
|
||||
echo -e "Filtre: ${BOLD}$filter${NC}"
|
||||
echo ""
|
||||
|
||||
# Afficher les projets filtrés
|
||||
if [ ${#filtered_projects[@]} -eq 0 ]; then
|
||||
echo -e "${RED}Aucun projet trouvé pour le filtre '$filter'${NC}"
|
||||
else
|
||||
for i in "${!filtered_projects[@]}"; do
|
||||
if [ $i -eq $selected ]; then
|
||||
echo -e "${GREEN}► ${filtered_projects[$i]}${NC}"
|
||||
else
|
||||
echo -e " ${filtered_projects[$i]}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo -e "\n${BLUE}Commandes: [Backspace] Effacer filtre | [Esc] Annuler${NC}"
|
||||
|
||||
# Lire une seule touche
|
||||
read -rsn1 key
|
||||
|
||||
case $key in
|
||||
$'\033') # Séquence d'échappement
|
||||
read -rsn2 key
|
||||
case $key in
|
||||
'[A') # Flèche haut
|
||||
if [ ${#filtered_projects[@]} -gt 0 ]; then
|
||||
((selected--))
|
||||
if [ $selected -lt 0 ]; then
|
||||
selected=$((${#filtered_projects[@]}-1))
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
'[B') # Flèche bas
|
||||
if [ ${#filtered_projects[@]} -gt 0 ]; then
|
||||
((selected++))
|
||||
if [ $selected -ge ${#filtered_projects[@]} ]; then
|
||||
selected=0
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
'') # Entrée
|
||||
if [ ${#filtered_projects[@]} -gt 0 ]; then
|
||||
local chosen_project="${filtered_projects[$selected]}"
|
||||
if [[ "$chosen_project" == "← Retour au menu principal" ]]; then
|
||||
return 1
|
||||
else
|
||||
echo -e "\n${BLUE}Configuration du projet: $chosen_project${NC}"
|
||||
if gcloud config set project "$chosen_project" &>/dev/null; then
|
||||
CURRENT_PROJECT="$chosen_project"
|
||||
echo -e "${GREEN}Projet configuré avec succès!${NC}"
|
||||
sleep 2
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}Erreur lors de la configuration du projet.${NC}"
|
||||
read -p "Appuyez sur Entrée pour continuer..."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
$'\177'|$'\b') # Backspace
|
||||
if [ ${#filter} -gt 0 ]; then
|
||||
filter="${filter%?}"
|
||||
filter_projects
|
||||
fi
|
||||
;;
|
||||
$'\e') # Escape
|
||||
return 1
|
||||
;;
|
||||
*) # Caractère normal
|
||||
if [[ "$key" =~ [a-zA-Z0-9-] ]]; then
|
||||
filter="$filter$key"
|
||||
filter_projects
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Fonction pour lister et sélectionner un projet (version simplifiée)
|
||||
select_project() {
|
||||
select_project_interactive
|
||||
}
|
||||
|
||||
# Fonction pour afficher les informations de débogage des Cloud Functions
|
||||
show_functions_debug_info() {
|
||||
clear
|
||||
echo -e "${BLUE}${BOLD}=== Débogage Cloud Functions ===${NC}\n"
|
||||
|
||||
echo -e "${YELLOW}1. Test Cloud Functions Gen1 (toutes régions):${NC}"
|
||||
gcloud functions list 2>&1 || echo "Erreur lors de la récupération des fonctions Gen1"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}2. Test Cloud Functions Gen2 (Cloud Run):${NC}"
|
||||
gcloud run services list 2>&1 || echo "Erreur lors de la récupération des services Cloud Run"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}3. Régions disponibles pour Cloud Functions:${NC}"
|
||||
gcloud functions regions list 2>&1 || echo "Erreur lors de la récupération des régions"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}4. Configuration actuelle:${NC}"
|
||||
echo "Projet: $(gcloud config get-value project 2>/dev/null)"
|
||||
echo "Région par défaut: $(gcloud config get-value functions/region 2>/dev/null || echo 'Non définie')"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}5. APIs activées:${NC}"
|
||||
gcloud services list --enabled --filter="name:cloudfunctions OR name:run OR name:cloudbuild" 2>&1 || echo "Erreur lors de la vérification des APIs"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Fonction pour sélectionner une région de manière interactive avec filtrage
|
||||
select_region_interactive() {
|
||||
echo -e "\n${BLUE}Récupération des régions disponibles...${NC}"
|
||||
|
||||
# Récupérer toutes les régions disponibles
|
||||
local all_regions=()
|
||||
while IFS= read -r region; do
|
||||
if [[ -n "$region" ]]; then
|
||||
all_regions+=("$region")
|
||||
fi
|
||||
done < <(gcloud functions regions list --format="value(name)" 2>/dev/null | sort)
|
||||
|
||||
if [ ${#all_regions[@]} -eq 0 ]; then
|
||||
echo -e "${RED}Impossible de récupérer la liste des régions.${NC}"
|
||||
read -p "Appuyez sur Entrée pour continuer..."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Ajouter option de retour
|
||||
all_regions+=("← Retour au menu précédent")
|
||||
|
||||
local selected=0
|
||||
local filter=""
|
||||
local filtered_regions=()
|
||||
|
||||
# Fonction pour filtrer les régions
|
||||
filter_regions() {
|
||||
filtered_regions=()
|
||||
for region in "${all_regions[@]}"; do
|
||||
if [[ "$region" == *"$filter"* ]]; then
|
||||
filtered_regions+=("$region")
|
||||
fi
|
||||
done
|
||||
|
||||
# Réajuster la sélection si nécessaire
|
||||
if [ $selected -ge ${#filtered_regions[@]} ]; then
|
||||
selected=0
|
||||
fi
|
||||
}
|
||||
|
||||
# Initialiser le filtre
|
||||
filter_regions
|
||||
|
||||
while true; do
|
||||
clear
|
||||
echo -e "${BLUE}${BOLD}=== Sélection de région ===${NC}\n"
|
||||
|
||||
echo -e "${YELLOW}Configuration actuelle:${NC}"
|
||||
echo "Région par défaut: $(gcloud config get-value functions/region 2>/dev/null || echo 'Non définie')"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}Tapez pour filtrer, utilisez ↑↓ pour naviguer, Entrée pour sélectionner:${NC}"
|
||||
echo -e "Filtre: ${BOLD}$filter${NC}"
|
||||
echo ""
|
||||
|
||||
# Afficher les régions filtrées
|
||||
if [ ${#filtered_regions[@]} -eq 0 ]; then
|
||||
echo -e "${RED}Aucune région trouvée pour le filtre '$filter'${NC}"
|
||||
else
|
||||
for i in "${!filtered_regions[@]}"; do
|
||||
if [ $i -eq $selected ]; then
|
||||
echo -e "${GREEN}► ${filtered_regions[$i]}${NC}"
|
||||
else
|
||||
echo -e " ${filtered_regions[$i]}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo -e "\n${BLUE}Commandes: [Backspace] Effacer filtre | [Esc] Annuler${NC}"
|
||||
|
||||
# Lire une seule touche
|
||||
read -rsn1 key
|
||||
|
||||
case $key in
|
||||
$'\033') # Séquence d'échappement
|
||||
read -rsn2 key
|
||||
case $key in
|
||||
'[A') # Flèche haut
|
||||
if [ ${#filtered_regions[@]} -gt 0 ]; then
|
||||
((selected--))
|
||||
if [ $selected -lt 0 ]; then
|
||||
selected=$((${#filtered_regions[@]}-1))
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
'[B') # Flèche bas
|
||||
if [ ${#filtered_regions[@]} -gt 0 ]; then
|
||||
((selected++))
|
||||
if [ $selected -ge ${#filtered_regions[@]} ]; then
|
||||
selected=0
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
'') # Entrée
|
||||
if [ ${#filtered_regions[@]} -gt 0 ]; then
|
||||
local chosen_region="${filtered_regions[$selected]}"
|
||||
if [[ "$chosen_region" == "← Retour au menu précédent" ]]; then
|
||||
return 1
|
||||
else
|
||||
echo -e "\n${BLUE}Configuration de la région $chosen_region...${NC}"
|
||||
if gcloud config set functions/region "$chosen_region" 2>/dev/null; then
|
||||
echo -e "${GREEN}✓ Région configurée: $chosen_region${NC}"
|
||||
echo -e "\n${BLUE}Nouvelle tentative de récupération des fonctions...${NC}"
|
||||
sleep 2
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗ Erreur lors de la configuration de la région${NC}"
|
||||
read -p "Appuyez sur Entrée pour continuer..."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
$'\177'|$'\b') # Backspace
|
||||
if [ ${#filter} -gt 0 ]; then
|
||||
filter="${filter%?}"
|
||||
filter_regions
|
||||
fi
|
||||
;;
|
||||
$'\e') # Escape
|
||||
return 1
|
||||
;;
|
||||
*) # Caractère normal
|
||||
if [[ "$key" =~ [a-zA-Z0-9-] ]]; then
|
||||
filter="$filter$key"
|
||||
filter_regions
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Fonction pour spécifier une région et réessayer (version simplifiée)
|
||||
specify_region_and_retry() {
|
||||
if select_region_interactive; then
|
||||
delete_all_functions
|
||||
fi
|
||||
}
|
||||
|
||||
# Fonction pour supprimer toutes les Cloud Functions
|
||||
delete_all_functions() {
|
||||
# Ne pas clear ici pour garder les informations du menu principal visible
|
||||
echo -e "${BLUE}Récupération des Cloud Functions du projet $CURRENT_PROJECT...${NC}"
|
||||
|
||||
# Obtenir toutes les fonctions (sans distinction Gen1/Gen2)
|
||||
local all_functions=()
|
||||
local function_names=()
|
||||
local function_regions=()
|
||||
|
||||
echo -e "\n${YELLOW}Recherche de toutes les Cloud Functions...${NC}"
|
||||
|
||||
# Récupérer tous les noms de fonctions avec leurs régions
|
||||
while read -r line; do
|
||||
if [[ -n "$line" && "$line" != *"NAME"* ]]; then
|
||||
# Extraire le nom et la région de la fonction
|
||||
name=$(echo "$line" | awk '{print $1}')
|
||||
region=$(echo "$line" | awk '{print $4}')
|
||||
|
||||
if [[ -n "$name" && "$name" != "NAME" && -n "$region" ]]; then
|
||||
function_names+=("$name")
|
||||
function_regions+=("$region")
|
||||
all_functions+=("$name ($region)")
|
||||
fi
|
||||
fi
|
||||
done < <(gcloud functions list --format="table(name,state,trigger,region)" 2>/dev/null | tail -n +2)
|
||||
|
||||
echo -e "${GREEN}Total des fonctions trouvées: ${#function_names[@]}${NC}\n"
|
||||
|
||||
if [ ${#function_names[@]} -eq 0 ]; then
|
||||
echo -e "${YELLOW}Aucune Cloud Function trouvée dans le projet $CURRENT_PROJECT.${NC}"
|
||||
echo -e "${YELLOW}Cela peut être dû à:${NC}"
|
||||
echo -e " • Aucune fonction déployée"
|
||||
echo -e " • Fonctions dans une région non configurée"
|
||||
echo -e " • APIs Cloud Functions/Cloud Run non activées"
|
||||
echo -e " • Permissions insuffisantes"
|
||||
echo ""
|
||||
|
||||
local debug_options=("Afficher les informations de débogage" "Spécifier une région" "← Retour au menu principal")
|
||||
echo "Que souhaitez-vous faire ?\n"
|
||||
show_menu "${debug_options[@]}"
|
||||
local debug_choice=$?
|
||||
|
||||
case $debug_choice in
|
||||
0) # Afficher debug
|
||||
show_functions_debug_info
|
||||
read -p "Appuyez sur Entrée pour continuer..."
|
||||
;;
|
||||
1) # Spécifier région
|
||||
specify_region_and_retry
|
||||
;;
|
||||
2) # Retour
|
||||
return
|
||||
;;
|
||||
esac
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "\n${YELLOW}${BOLD}Fonctions trouvées (${#function_names[@]}):${NC}"
|
||||
for func in "${function_names[@]}"; do
|
||||
echo -e " • $func"
|
||||
done
|
||||
|
||||
echo -e "\n${RED}${BOLD}⚠️ ATTENTION: Cette action supprimera TOUTES les Cloud Functions du projet!${NC}"
|
||||
echo -e "${RED}Cette action est IRRÉVERSIBLE!${NC}\n"
|
||||
|
||||
local confirm_options=("Oui, supprimer toutes les fonctions" "Non, annuler")
|
||||
show_menu "${confirm_options[@]}"
|
||||
local confirm=$?
|
||||
|
||||
if [ $confirm -eq 0 ]; then
|
||||
echo -e "\n${RED}Suppression en cours...${NC}\n"
|
||||
|
||||
local success_count=0
|
||||
local error_count=0
|
||||
|
||||
# Supprimer toutes les fonctions avec leur région spécifique
|
||||
for i in "${!function_names[@]}"; do
|
||||
local func="${function_names[$i]}"
|
||||
local region="${function_regions[$i]}"
|
||||
|
||||
echo -e "Suppression de: ${BOLD}$func${NC} (région: $region)"
|
||||
|
||||
# Capturer l'erreur détaillée avec la région spécifique
|
||||
local error_output
|
||||
error_output=$(gcloud functions delete "$func" --region="$region" --quiet 2>&1)
|
||||
local exit_code=$?
|
||||
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ $func supprimée${NC}"
|
||||
((success_count++))
|
||||
else
|
||||
echo -e "${RED}✗ Erreur lors de la suppression de $func${NC}"
|
||||
echo -e "${RED} Détail: $error_output${NC}"
|
||||
((error_count++))
|
||||
fi
|
||||
|
||||
# Petite pause pour éviter les erreurs de rate limiting
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo -e "\n${BOLD}Résumé:${NC}"
|
||||
echo -e "${GREEN}• Fonctions supprimées: $success_count${NC}"
|
||||
if [ $error_count -gt 0 ]; then
|
||||
echo -e "${RED}• Erreurs: $error_count${NC}"
|
||||
echo -e "${YELLOW}Il y a eu des erreurs lors de la suppression.${NC}"
|
||||
echo -e "${YELLOW}Vérifiez les permissions et l'état des fonctions.${NC}"
|
||||
echo -e "${YELLOW}Les erreurs détaillées sont affichées ci-dessus.${NC}"
|
||||
else
|
||||
echo -e "${GREEN}Toutes les fonctions ont été supprimées avec succès!${NC}"
|
||||
fi
|
||||
|
||||
echo -e "\n${BLUE}Appuyez sur Entrée pour continuer...${NC}"
|
||||
read -r
|
||||
else
|
||||
echo -e "\n${YELLOW}Suppression annulée.${NC}"
|
||||
echo -e "\n${BLUE}Appuyez sur Entrée pour continuer...${NC}"
|
||||
read -r
|
||||
fi
|
||||
|
||||
read -p "Appuyez sur Entrée pour continuer..."
|
||||
}
|
||||
|
||||
# Fonction principale
|
||||
main() {
|
||||
clear
|
||||
check_gcloud
|
||||
|
||||
while true; do
|
||||
get_current_project
|
||||
|
||||
local main_options=()
|
||||
if [ "$CURRENT_PROJECT" != "Aucun" ]; then
|
||||
main_options=("Supprimer toutes les Cloud Functions" "Changer de projet" "Se connecter à GCP" "Quitter")
|
||||
else
|
||||
main_options=("Changer de projet" "Se connecter à GCP" "Quitter")
|
||||
fi
|
||||
|
||||
show_menu "${main_options[@]}"
|
||||
local choice=$?
|
||||
|
||||
if [ "$CURRENT_PROJECT" != "Aucun" ]; then
|
||||
case $choice in
|
||||
0) # Supprimer toutes les fonctions
|
||||
delete_all_functions
|
||||
;;
|
||||
1) # Changer de projet
|
||||
select_project
|
||||
;;
|
||||
2) # Se connecter à GCP
|
||||
handle_authentication
|
||||
;;
|
||||
3) # Quitter
|
||||
echo -e "\n${YELLOW}Au revoir!${NC}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
else
|
||||
case $choice in
|
||||
0) # Changer de projet
|
||||
select_project
|
||||
;;
|
||||
1) # Se connecter à GCP
|
||||
handle_authentication
|
||||
;;
|
||||
2) # Quitter
|
||||
echo -e "\n${YELLOW}Au revoir!${NC}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Gestion des signaux pour un arrêt propre
|
||||
trap 'echo -e "\n${YELLOW}Script interrompu.${NC}"; exit 0' INT TERM
|
||||
|
||||
# Lancement du script
|
||||
main
|
||||
@@ -0,0 +1,44 @@
|
||||
function basicTemplate({ title = "", content = "", button = null }) {
|
||||
const btn = button?.url
|
||||
? `<p><a href="${button.url}" style="display:inline-block;padding:10px 16px;background:#6C5CE7;color:#fff;border-radius:8px;text-decoration:none">${
|
||||
button?.label || "Ouvrir"
|
||||
}</a></p>`
|
||||
: "";
|
||||
return `<!doctype html><html><body style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;color:#111">
|
||||
<div style="max-width:640px;margin:40px auto;padding:24px;border:1px solid #eee;border-radius:12px">
|
||||
<h2 style="margin:0 0 16px">${title}</h2>
|
||||
<div>${content}</div>
|
||||
${btn}
|
||||
<hr style="margin:24px 0;border:none;border-top:1px solid #eee" />
|
||||
<p style="color:#666;font-size:12px">minuit.app</p>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function welcomeTemplate({ firstName = "", lastName = "" }) {
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
|
||||
const greeting = fullName ? `Salut ${fullName},` : "Salut,";
|
||||
return `<!doctype html><html><body style="margin:0;padding:0;background:#0b0b10;color:#fff;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
||||
<div style="max-width:640px;margin:0 auto;padding:40px 24px">
|
||||
<div style="background:#151520;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)">
|
||||
<div style="padding:32px 28px">
|
||||
<p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px">${greeting}</p>
|
||||
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.2">Bienvenue sur MusicLand</h1>
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:rgba(255,255,255,0.85)">
|
||||
Nous sommes ravis de t'accueillir dans la communauté des artistes et passionnés de MusicLand.
|
||||
</p>
|
||||
<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:rgba(255,255,255,0.75)">
|
||||
Explore les playlists collaboratives, dépose tes projets et découvre ce que prépare la scène émergente.
|
||||
</p>
|
||||
<p style="margin:0;font-size:15px;line-height:1.6;color:rgba(255,255,255,0.75)">
|
||||
L'équipe MusicLand
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin:32px 0 0;text-align:center;font-size:12px;color:rgba(255,255,255,0.45)">minuit.app · Paris, France</p>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
exports.basicTemplate = basicTemplate;
|
||||
exports.welcomeTemplate = welcomeTemplate;
|
||||
@@ -24,6 +24,7 @@
|
||||
"firebase-functions": "^6.4.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"genkit": "^1.18.0",
|
||||
"resend": "^6.3.0",
|
||||
"sharp": "^0.33.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -5,11 +5,18 @@ const {
|
||||
const admin = require("firebase-admin");
|
||||
const { refList, ALERT_TYPE } = require("../index");
|
||||
const { Expo } = require("expo-server-sdk");
|
||||
const { Resend } = require("resend");
|
||||
const { basicTemplate } = require("../helpers/email");
|
||||
const { RESEND_API_KEY } = require("../config/keys");
|
||||
|
||||
const resendInstance = new Resend(RESEND_API_KEY);
|
||||
|
||||
// Initialisation de Expo SDK
|
||||
let expo = new Expo();
|
||||
const db = admin.firestore();
|
||||
|
||||
const EMAIL_FROM = "MusicLand <musicland@minuit.app>";
|
||||
|
||||
exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
||||
{ region: "europe-west1", document: "notifications/{notificationId}" },
|
||||
async (event) => {
|
||||
@@ -26,14 +33,18 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
||||
throw new Error("Receiver and message are required");
|
||||
}
|
||||
|
||||
const userSnap = await db.collection(receiverCollection).doc(receiver).get();
|
||||
const { pushToken = null, pushTokens = [] } = userSnap?.data() || {};
|
||||
const {
|
||||
pushToken = null,
|
||||
pushTokens = [],
|
||||
email = "",
|
||||
emailNotifications = false,
|
||||
} = (await db.collection(receiverCollection).doc(receiver).get()).data();
|
||||
|
||||
const tokensSet = new Set(
|
||||
[]
|
||||
.concat(Array.isArray(pushTokens) ? pushTokens : [])
|
||||
.concat(pushToken ? [pushToken] : [])
|
||||
.filter(Boolean)
|
||||
.filter(Boolean),
|
||||
);
|
||||
const tokens = Array.from(tokensSet);
|
||||
|
||||
@@ -50,11 +61,31 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
||||
message: message,
|
||||
data: notifData || {},
|
||||
});
|
||||
if (emailNotifications && !!email) {
|
||||
try {
|
||||
if (!email) {
|
||||
throw new Error("userMail is required");
|
||||
}
|
||||
if (!email?.subject || !email?.html) {
|
||||
throw new Error("Email subject and html are required");
|
||||
}
|
||||
await resendInstance.emails.send({
|
||||
from: EMAIL_FROM,
|
||||
to: [email],
|
||||
subject: title,
|
||||
html: basicTemplate({ title, content: message }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.log("Error sending email:", e);
|
||||
}
|
||||
} else {
|
||||
console.log(`Email disabled for user ${receiver} and type ${type}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Fonction pour envoyer la notification via Expo SDK
|
||||
@@ -144,7 +175,11 @@ async function sendExpoNotification({
|
||||
}
|
||||
}
|
||||
|
||||
async function removeInvalidTokens({ tokens = [], receiverId, receiverCollection }) {
|
||||
async function removeInvalidTokens({
|
||||
tokens = [],
|
||||
receiverId,
|
||||
receiverCollection,
|
||||
}) {
|
||||
try {
|
||||
if (!receiverId || !tokens.length) {
|
||||
return;
|
||||
@@ -170,7 +205,7 @@ async function removeInvalidTokens({ tokens = [], receiverId, receiverCollection
|
||||
await docRef.set(updates, { merge: true });
|
||||
console.log(
|
||||
"Pruned invalid push tokens",
|
||||
JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2)
|
||||
JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2),
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("Failed to prune invalid push tokens:", error);
|
||||
@@ -204,7 +239,7 @@ const sendNotification = async ({
|
||||
const { id } = await refList.notifications.add(payload);
|
||||
console.log(
|
||||
"[sendNotification] Notification created",
|
||||
JSON.stringify({ id, receiver, receiverCollection }, null, 2)
|
||||
JSON.stringify({ id, receiver, receiverCollection }, null, 2),
|
||||
);
|
||||
|
||||
return id;
|
||||
@@ -224,7 +259,7 @@ exports.createProjectCommentNotification = onDocumentCreated(
|
||||
try {
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Trigger received",
|
||||
JSON.stringify(event.params || {}, null, 2)
|
||||
JSON.stringify(event.params || {}, null, 2),
|
||||
);
|
||||
const { data: snap } = event;
|
||||
const { projectId, commentId } = event.params || {};
|
||||
@@ -233,21 +268,21 @@ exports.createProjectCommentNotification = onDocumentCreated(
|
||||
if (!projectId || !comment) {
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Missing project/comment data",
|
||||
{ hasProjectId: !!projectId, hasComment: !!comment }
|
||||
{ hasProjectId: !!projectId, hasComment: !!comment },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Comment payload",
|
||||
JSON.stringify(comment, null, 2)
|
||||
JSON.stringify(comment, null, 2),
|
||||
);
|
||||
|
||||
const projectSnap = await refList.projects.doc(projectId).get();
|
||||
if (!projectSnap.exists) {
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Project not found",
|
||||
projectId
|
||||
projectId,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -258,7 +293,7 @@ exports.createProjectCommentNotification = onDocumentCreated(
|
||||
if (!receiver || receiver === comment.userId) {
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Invalid receiver",
|
||||
JSON.stringify({ receiver, commentUserId: comment.userId })
|
||||
JSON.stringify({ receiver, commentUserId: comment.userId }),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -283,8 +318,8 @@ exports.createProjectCommentNotification = onDocumentCreated(
|
||||
projectId,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await sendNotification({
|
||||
@@ -308,7 +343,7 @@ exports.createProjectCommentNotification = onDocumentCreated(
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[createProjectCommentNotification] Notification creation complete"
|
||||
"[createProjectCommentNotification] Notification creation complete",
|
||||
);
|
||||
|
||||
return null;
|
||||
@@ -316,7 +351,7 @@ exports.createProjectCommentNotification = onDocumentCreated(
|
||||
console.log("createProjectCommentNotification error:", error);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
exports.createProjectLikeNotification = onDocumentWritten(
|
||||
@@ -356,10 +391,10 @@ exports.createProjectLikeNotification = onDocumentWritten(
|
||||
const beforePlaybackSet = new Set(beforePlaybackLikes);
|
||||
|
||||
const newSongLikers = afterSongLikes.filter(
|
||||
(uid) => uid && !beforeSongSet.has(uid)
|
||||
(uid) => uid && !beforeSongSet.has(uid),
|
||||
);
|
||||
const newPlaybackLikers = afterPlaybackLikes.filter(
|
||||
(uid) => uid && !beforePlaybackSet.has(uid)
|
||||
(uid) => uid && !beforePlaybackSet.has(uid),
|
||||
);
|
||||
|
||||
const newLikers = [];
|
||||
@@ -413,7 +448,7 @@ exports.createProjectLikeNotification = onDocumentWritten(
|
||||
});
|
||||
|
||||
return null;
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return null;
|
||||
@@ -421,7 +456,7 @@ exports.createProjectLikeNotification = onDocumentWritten(
|
||||
console.log("[createProjectLikeNotification] error:", error);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
exports.createNewFollowerNotification = onDocumentWritten(
|
||||
@@ -451,7 +486,9 @@ exports.createNewFollowerNotification = onDocumentWritten(
|
||||
}
|
||||
|
||||
const previousSet = new Set(beforeFollowers);
|
||||
const newFollowers = afterFollowers.filter((uid) => !previousSet.has(uid));
|
||||
const newFollowers = afterFollowers.filter(
|
||||
(uid) => !previousSet.has(uid),
|
||||
);
|
||||
|
||||
if (!newFollowers.length) {
|
||||
return null;
|
||||
@@ -487,7 +524,7 @@ exports.createNewFollowerNotification = onDocumentWritten(
|
||||
});
|
||||
|
||||
return null;
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return null;
|
||||
@@ -495,5 +532,5 @@ exports.createNewFollowerNotification = onDocumentWritten(
|
||||
console.log("[createNewFollowerNotification] error:", error);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,7 +1,44 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { onDocumentDeleted } = require("firebase-functions/firestore");
|
||||
const {
|
||||
onDocumentDeleted,
|
||||
onDocumentCreated,
|
||||
} = require("firebase-functions/firestore");
|
||||
const { refList } = require("../index");
|
||||
const { deleteFolder } = require("../helpers/firebase");
|
||||
const { Resend } = require("resend");
|
||||
const { welcomeTemplate } = require("../helpers/email");
|
||||
const { RESEND_API_KEY } = require("../config/keys");
|
||||
|
||||
const resendClient = new Resend(RESEND_API_KEY);
|
||||
const WELCOME_EMAIL_FROM =
|
||||
process.env.RESEND_FROM_EMAIL || "MusicLand <musicland@minuit.app>";
|
||||
const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
|
||||
|
||||
exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
|
||||
try {
|
||||
const { email = "", firstName = "", lastName = "" } = event?.data?.data();
|
||||
if (email) {
|
||||
if (!resendClient) {
|
||||
console.warn("Resend API key not configured; skipping welcome email.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await resendClient.emails.send({
|
||||
from: WELCOME_EMAIL_FROM,
|
||||
to: [email],
|
||||
subject: WELCOME_EMAIL_SUBJECT,
|
||||
html: welcomeTemplate({ firstName, lastName }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Failed to send welcome email:", error);
|
||||
}
|
||||
} else {
|
||||
throw new Error("User created with empty email");
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
|
||||
exports.onUserDelete = onDocumentDeleted("users/{userID}", async (event) => {
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"scripts": {
|
||||
"start": "expo start --dev-client",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"ios": "expo run:ios -d",
|
||||
"web": "expo start --web",
|
||||
"xcode": "open ios/musicland.xcworkspace",
|
||||
"clean": "npx expo prebuild --clean",
|
||||
@@ -35,6 +35,7 @@
|
||||
"@stripe/stripe-js": "~3.0.0",
|
||||
"deprecated-react-native-prop-types": "~3.0.1",
|
||||
"expo": "~52.0.47",
|
||||
"expo-apple-authentication": "~7.1.3",
|
||||
"expo-audio": "~0.3.5",
|
||||
"expo-auth-session": "~6.0.3",
|
||||
"expo-blur": "~14.0.3",
|
||||
|
||||
@@ -8,7 +8,7 @@ import React, {
|
||||
} from "react";
|
||||
import {
|
||||
Animated,
|
||||
Image,
|
||||
ImageBackground,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
View,
|
||||
@@ -43,8 +43,6 @@ const STAGE_CARD_CONTENT = [
|
||||
|
||||
const WEB_SCROLL_INACTIVE_DELTA = 0.05;
|
||||
const HOME_BACKGROUND_COLOR = "#425B87"; // Derived from the bottom color of the home hero image
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
||||
|
||||
const FeatureCarousel = ({
|
||||
style,
|
||||
selectedProject,
|
||||
@@ -117,16 +115,6 @@ const FeatureCarousel = ({
|
||||
);
|
||||
const onActiveIndexChangeRef = useRef(onActiveIndexChange);
|
||||
const scrollY = useRef(new Animated.Value(0)).current;
|
||||
const parallaxTranslateY = useMemo(() => {
|
||||
if (!itemHeight) {
|
||||
return null;
|
||||
}
|
||||
return scrollY.interpolate({
|
||||
inputRange: [0, itemHeight],
|
||||
outputRange: [0, -itemHeight * 0.25],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
}, [itemHeight, scrollY]);
|
||||
|
||||
useEffect(() => {
|
||||
onActiveIndexChangeRef.current = onActiveIndexChange;
|
||||
@@ -319,10 +307,19 @@ const FeatureCarousel = ({
|
||||
[updateSnapHeight]
|
||||
);
|
||||
|
||||
const hasBackgroundImage = !!backgroundImage;
|
||||
|
||||
const renderContent = useMemo(
|
||||
() =>
|
||||
carouselItems.map((item, index) => {
|
||||
const card = (
|
||||
carouselItems.map((item, index) => (
|
||||
<View
|
||||
key={item.key}
|
||||
style={[
|
||||
styles.slide,
|
||||
hasBackgroundImage ? styles.slideTransparent : styles.slideColored,
|
||||
itemHeight ? { height: itemHeight } : { minHeight: 1 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.cardWrapper}>
|
||||
<PersonaCard
|
||||
item={item}
|
||||
@@ -331,55 +328,9 @@ const FeatureCarousel = ({
|
||||
height={itemHeight}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const containerStyle = [styles.backgroundItem, { height: itemHeight }];
|
||||
const isFirstItem = index === 0 && backgroundImage;
|
||||
|
||||
if (isFirstItem) {
|
||||
const parallaxStyles = [
|
||||
styles.parallaxImage,
|
||||
itemHeight ? { height: itemHeight * 1.2 } : null,
|
||||
itemHeight ? { top: -itemHeight * 0.1 } : null,
|
||||
{ width: "100%" },
|
||||
].filter(Boolean);
|
||||
if (parallaxTranslateY) {
|
||||
parallaxStyles.push({
|
||||
transform: [{ translateY: parallaxTranslateY }],
|
||||
});
|
||||
}
|
||||
return (
|
||||
<View
|
||||
key={item.key}
|
||||
style={[...containerStyle, styles.parallaxContainer]}
|
||||
>
|
||||
<AnimatedImage
|
||||
source={backgroundImage}
|
||||
style={parallaxStyles}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
"rgba(66, 91, 135, 0)",
|
||||
"rgba(66, 91, 135, 0.4)",
|
||||
HOME_BACKGROUND_COLOR,
|
||||
]}
|
||||
locations={[0, 0.75, 1]}
|
||||
style={styles.backgroundGradient}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
{card}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={item.key} style={[containerStyle, styles.plainBackground]}>
|
||||
{card}
|
||||
</View>
|
||||
);
|
||||
}),
|
||||
[backgroundImage, carouselItems, itemHeight, parallaxTranslateY]
|
||||
)),
|
||||
[carouselItems, hasBackgroundImage, itemHeight]
|
||||
);
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
@@ -398,11 +349,39 @@ const FeatureCarousel = ({
|
||||
[isWeb]
|
||||
);
|
||||
|
||||
const containerProps = hasBackgroundImage
|
||||
? {
|
||||
source: backgroundImage,
|
||||
resizeMode: "cover",
|
||||
imageStyle: styles.backgroundImage,
|
||||
}
|
||||
: {};
|
||||
|
||||
const ContainerComponent = hasBackgroundImage ? ImageBackground : View;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.container, isWeb && styles.containerWeb, style]}
|
||||
<ContainerComponent
|
||||
{...containerProps}
|
||||
style={[
|
||||
styles.container,
|
||||
isWeb && styles.containerWeb,
|
||||
hasBackgroundImage ? styles.containerTransparent : styles.containerColored,
|
||||
style,
|
||||
]}
|
||||
onLayout={handleLayout}
|
||||
>
|
||||
{hasBackgroundImage && (
|
||||
<LinearGradient
|
||||
colors={[
|
||||
"rgba(66, 91, 135, 0)",
|
||||
"rgba(66, 91, 135, 0.4)",
|
||||
HOME_BACKGROUND_COLOR,
|
||||
]}
|
||||
locations={[0, 0.75, 1]}
|
||||
style={styles.backgroundGradient}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
<Animated.ScrollView
|
||||
ref={scrollViewRef}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -440,7 +419,7 @@ const FeatureCarousel = ({
|
||||
baseDotSize={8}
|
||||
/>
|
||||
</BlurView>
|
||||
</View>
|
||||
</ContainerComponent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -451,37 +430,38 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
backgroundColor: HOME_BACKGROUND_COLOR,
|
||||
},
|
||||
containerWeb: {
|
||||
// paddingRight: 56,
|
||||
},
|
||||
containerColored: {
|
||||
backgroundColor: HOME_BACKGROUND_COLOR,
|
||||
},
|
||||
containerTransparent: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
backgroundItem: {
|
||||
width: "100%",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
parallaxContainer: {
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
backgroundColor: HOME_BACKGROUND_COLOR,
|
||||
},
|
||||
parallaxImage: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundImage: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
backgroundGradient: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
plainBackground: {
|
||||
slide: {
|
||||
width: "100%",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
slideColored: {
|
||||
backgroundColor: HOME_BACKGROUND_COLOR,
|
||||
},
|
||||
slideTransparent: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
cardWrapper: {
|
||||
flex: 1,
|
||||
width: "60%",
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import * as AppleAuthentication from "expo-apple-authentication";
|
||||
import * as AuthSession from "expo-auth-session";
|
||||
import * as GoogleAuth from "expo-auth-session/providers/google";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import Constants from "expo-constants";
|
||||
import { useGlobal } from "reactn";
|
||||
|
||||
import { handleFirebaseError } from "../actions/signupActions";
|
||||
import firebase, { usersRef } from "../config/firebase";
|
||||
import {
|
||||
GOOGLE_ANDROID_CLIENT_ID,
|
||||
GOOGLE_IOS_CLIENT_ID,
|
||||
GOOGLE_WEB_CLIENT_ID,
|
||||
} from "../data/keys";
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
const isMeaningfulValue = (value) => {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if (normalized === "..." || normalized === "todo") return false;
|
||||
if (normalized.includes("placeholder")) return false;
|
||||
if (normalized.includes("example")) return false;
|
||||
if (normalized.startsWith("your-")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const splitDisplayName = (displayName) => {
|
||||
if (!displayName || typeof displayName !== "string") {
|
||||
return { firstName: null, lastName: null };
|
||||
}
|
||||
const parts = displayName.trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) {
|
||||
return { firstName: null, lastName: null };
|
||||
}
|
||||
const [firstName, ...rest] = parts;
|
||||
return {
|
||||
firstName: firstName || null,
|
||||
lastName: rest.length ? rest.join(" ") : null,
|
||||
};
|
||||
};
|
||||
|
||||
const getNativeGoogleRedirectUri = () => {
|
||||
const clientId = Platform.select({
|
||||
ios: GOOGLE_IOS_CLIENT_ID,
|
||||
android: GOOGLE_ANDROID_CLIENT_ID,
|
||||
default: null,
|
||||
});
|
||||
|
||||
if (!clientId || !clientId.includes(".apps.googleusercontent.com")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const clientPrefix = clientId.replace(".apps.googleusercontent.com", "");
|
||||
return `com.googleusercontent.apps.${clientPrefix}:/oauth2redirect`;
|
||||
};
|
||||
|
||||
const GOOGLE_KEY_LABELS = {
|
||||
androidClientId: "GOOGLE_ANDROID_CLIENT_ID",
|
||||
iosClientId: "GOOGLE_IOS_CLIENT_ID",
|
||||
webClientId: "GOOGLE_WEB_CLIENT_ID",
|
||||
expoClientId: "GOOGLE_WEB_CLIENT_ID",
|
||||
};
|
||||
|
||||
const generateNonce = (length = 32) => {
|
||||
const charset =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._";
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const randomIndex = Math.floor(Math.random() * charset.length);
|
||||
result += charset[randomIndex];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const ensureUserDocument = async (user, overrides = {}) => {
|
||||
if (!user?.uid) return;
|
||||
|
||||
try {
|
||||
const docRef = usersRef.doc(user.uid);
|
||||
const snapshot = await docRef.get();
|
||||
const payload = {
|
||||
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
||||
};
|
||||
|
||||
const existingData = snapshot.data() || {};
|
||||
|
||||
if (!snapshot.exists) {
|
||||
payload.createdAt = firebase.firestore.FieldValue.serverTimestamp();
|
||||
if (user.email) payload.email = user.email;
|
||||
}
|
||||
|
||||
const overrideFirstName =
|
||||
typeof overrides?.firstName === "string"
|
||||
? overrides.firstName.trim()
|
||||
: null;
|
||||
const overrideLastName =
|
||||
typeof overrides?.lastName === "string"
|
||||
? overrides.lastName.trim()
|
||||
: null;
|
||||
const overrideDisplayName =
|
||||
typeof overrides?.displayName === "string"
|
||||
? overrides.displayName.trim()
|
||||
: null;
|
||||
|
||||
const { firstName: nameFromDisplay, lastName: lastFromDisplay } =
|
||||
splitDisplayName(user.displayName);
|
||||
|
||||
if (overrideDisplayName) {
|
||||
payload.displayName = overrideDisplayName;
|
||||
} else if (!existingData?.displayName && user.displayName) {
|
||||
payload.displayName = user.displayName;
|
||||
}
|
||||
|
||||
if (overrideFirstName) {
|
||||
payload.firstName = overrideFirstName;
|
||||
} else if (!existingData?.firstName && nameFromDisplay) {
|
||||
payload.firstName = nameFromDisplay;
|
||||
}
|
||||
|
||||
if (overrideLastName) {
|
||||
payload.lastName = overrideLastName;
|
||||
} else if (!existingData?.lastName && lastFromDisplay) {
|
||||
payload.lastName = lastFromDisplay;
|
||||
}
|
||||
|
||||
await docRef.set(payload, { merge: true });
|
||||
} catch (error) {
|
||||
console.log("ensureUserDocument error", error?.message);
|
||||
}
|
||||
};
|
||||
|
||||
const useSocialAuth = ({ onSuccess } = {}) => {
|
||||
const [, setTooltip] = useGlobal("_tooltip");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isAppleSignInAvailable, setIsAppleSignInAvailable] = useState(false);
|
||||
const googleFlowActiveRef = useRef(false);
|
||||
|
||||
const googleClientIds = useMemo(
|
||||
() => ({
|
||||
androidClientId: GOOGLE_ANDROID_CLIENT_ID,
|
||||
iosClientId: GOOGLE_IOS_CLIENT_ID,
|
||||
webClientId: GOOGLE_WEB_CLIENT_ID,
|
||||
expoClientId: GOOGLE_WEB_CLIENT_ID,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const isWeb = Platform.OS === "web";
|
||||
const appOwnership = Constants?.appOwnership || null;
|
||||
const isExpoGo = appOwnership === "expo";
|
||||
|
||||
const redirectUri = useMemo(() => {
|
||||
const expoRedirect = AuthSession.makeRedirectUri({ useProxy: true });
|
||||
if (isWeb || isExpoGo) return expoRedirect;
|
||||
return getNativeGoogleRedirectUri() || expoRedirect;
|
||||
}, [isExpoGo, isWeb]);
|
||||
|
||||
const [request, response, promptAsync] = GoogleAuth.useAuthRequest({
|
||||
...googleClientIds,
|
||||
redirectUri,
|
||||
responseType: AuthSession.ResponseType.Code,
|
||||
usePKCE: true,
|
||||
scopes: ["openid", "profile", "email"],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
AppleAuthentication.isAvailableAsync()
|
||||
.then((available) => {
|
||||
if (mounted) {
|
||||
setIsAppleSignInAvailable(available);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setIsAppleSignInAvailable(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleGoogleResponse = useCallback(
|
||||
async (googleResponse) => {
|
||||
if (!googleResponse || googleResponse.type !== "success") {
|
||||
setIsLoading(false);
|
||||
googleFlowActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let idToken =
|
||||
googleResponse?.authentication?.idToken ||
|
||||
googleResponse?.params?.id_token;
|
||||
|
||||
if (!idToken) {
|
||||
const code =
|
||||
googleResponse?.params?.code ||
|
||||
googleResponse?.authentication?.code;
|
||||
if (!code) {
|
||||
throw new Error("Code d'autorisation Google manquant.");
|
||||
}
|
||||
|
||||
const discovery = {
|
||||
authorizationEndpoint:
|
||||
"https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
||||
revocationEndpoint: "https://oauth2.googleapis.com/revoke",
|
||||
};
|
||||
|
||||
const tokenResponse = await AuthSession.exchangeCodeAsync(
|
||||
{
|
||||
clientId: request?.clientId,
|
||||
code,
|
||||
redirectUri: request?.redirectUri ?? redirectUri,
|
||||
extraParams: { code_verifier: request?.codeVerifier },
|
||||
},
|
||||
discovery
|
||||
);
|
||||
|
||||
idToken = tokenResponse?.id_token;
|
||||
}
|
||||
|
||||
if (!idToken) {
|
||||
throw new Error("Jeton Google introuvable.");
|
||||
}
|
||||
|
||||
const credential =
|
||||
firebase.auth.GoogleAuthProvider.credential(idToken);
|
||||
|
||||
await firebase.auth().signInWithCredential(credential);
|
||||
await ensureUserDocument(firebase.auth().currentUser);
|
||||
if (typeof onSuccess === "function") {
|
||||
await onSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Google sign-in error", error?.message);
|
||||
const message =
|
||||
error?.code && error.code.startsWith("auth/")
|
||||
? handleFirebaseError(error.code)
|
||||
: error?.message ||
|
||||
"Connexion Google impossible. Merci de réessayer.";
|
||||
setTooltip({ text: message, type: "error" });
|
||||
} finally {
|
||||
googleFlowActiveRef.current = false;
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[onSuccess, redirectUri, request, setTooltip]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb || !googleFlowActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
handleGoogleResponse(response);
|
||||
}, [handleGoogleResponse, isWeb, response]);
|
||||
|
||||
const formatMissingKeysMessage = useCallback((missingKeys) => {
|
||||
if (!missingKeys.length) return null;
|
||||
const labels = missingKeys.map((key) => GOOGLE_KEY_LABELS[key] || key);
|
||||
return `Configuration Google incomplète. Renseigne ${
|
||||
labels.length > 1 ? "les clés" : "la clé"
|
||||
} ${labels.join(", ")} dans src/data/keys.js.`;
|
||||
}, []);
|
||||
|
||||
const findMissingGoogleKeys = useCallback(() => {
|
||||
const missing = [];
|
||||
if (!isMeaningfulValue(googleClientIds.webClientId)) {
|
||||
missing.push("webClientId");
|
||||
}
|
||||
if (!isMeaningfulValue(googleClientIds.expoClientId) && !isWeb) {
|
||||
missing.push("expoClientId");
|
||||
}
|
||||
if (!isWeb && !isExpoGo) {
|
||||
if (
|
||||
Platform.OS === "android" &&
|
||||
!isMeaningfulValue(googleClientIds.androidClientId)
|
||||
) {
|
||||
missing.push("androidClientId");
|
||||
}
|
||||
if (
|
||||
Platform.OS === "ios" &&
|
||||
!isMeaningfulValue(googleClientIds.iosClientId)
|
||||
) {
|
||||
missing.push("iosClientId");
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}, [googleClientIds, isExpoGo, isWeb]);
|
||||
|
||||
const signInWithGoogle = useCallback(async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
const missing = findMissingGoogleKeys();
|
||||
if (missing.length) {
|
||||
const message = formatMissingKeysMessage(missing);
|
||||
setTooltip({
|
||||
text: message || "Configuration Google incomplète.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
if (isWeb) {
|
||||
try {
|
||||
const provider = new firebase.auth.GoogleAuthProvider();
|
||||
provider.addScope("profile");
|
||||
provider.addScope("email");
|
||||
await firebase.auth().signInWithPopup(provider);
|
||||
await ensureUserDocument(firebase.auth().currentUser);
|
||||
if (typeof onSuccess === "function") {
|
||||
await onSuccess();
|
||||
}
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.log("Google web sign-in error", error?.message);
|
||||
const message =
|
||||
error?.code && error.code.startsWith("auth/")
|
||||
? handleFirebaseError(error.code)
|
||||
: error?.message ||
|
||||
"Connexion Google impossible. Merci de réessayer.";
|
||||
setTooltip({ text: message, type: "error" });
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!promptAsync) {
|
||||
setTooltip({
|
||||
text:
|
||||
"Configuration Google incomplète. Impossible de lancer la connexion.",
|
||||
type: "error",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
googleFlowActiveRef.current = true;
|
||||
const result = await promptAsync({ useProxy: isExpoGo });
|
||||
if (!result || result.type !== "success") {
|
||||
googleFlowActiveRef.current = false;
|
||||
setIsLoading(false);
|
||||
if (result?.type && result.type !== "dismiss") {
|
||||
setTooltip({
|
||||
text: "Connexion Google annulée.",
|
||||
type: "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
googleFlowActiveRef.current = false;
|
||||
setIsLoading(false);
|
||||
const message =
|
||||
error?.message ||
|
||||
"Connexion Google impossible. Merci de réessayer.";
|
||||
setTooltip({ text: message, type: "error" });
|
||||
}
|
||||
}, [
|
||||
findMissingGoogleKeys,
|
||||
formatMissingKeysMessage,
|
||||
isExpoGo,
|
||||
isLoading,
|
||||
isWeb,
|
||||
onSuccess,
|
||||
promptAsync,
|
||||
setTooltip,
|
||||
]);
|
||||
|
||||
const signInWithApple = useCallback(async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
if (!isAppleSignInAvailable) {
|
||||
setTooltip({
|
||||
text: "Connexion Apple indisponible sur cet appareil.",
|
||||
type: "warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const rawNonce = generateNonce();
|
||||
const hashedNonce = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
rawNonce
|
||||
);
|
||||
|
||||
const appleCredential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
],
|
||||
nonce: hashedNonce,
|
||||
});
|
||||
|
||||
if (!appleCredential?.identityToken) {
|
||||
throw new Error("Impossible de récupérer le jeton Apple.");
|
||||
}
|
||||
|
||||
const provider = new firebase.auth.OAuthProvider("apple.com");
|
||||
const credential = provider.credential({
|
||||
idToken: appleCredential.identityToken,
|
||||
rawNonce,
|
||||
});
|
||||
|
||||
const userCredential = await firebase
|
||||
.auth()
|
||||
.signInWithCredential(credential);
|
||||
|
||||
const fullName = appleCredential.fullName || {};
|
||||
const firstName = fullName.givenName?.trim() || null;
|
||||
const lastName = fullName.familyName?.trim() || null;
|
||||
|
||||
await ensureUserDocument(userCredential?.user, {
|
||||
firstName,
|
||||
lastName,
|
||||
displayName:
|
||||
userCredential?.user?.displayName ||
|
||||
[firstName, lastName].filter(Boolean).join(" ") ||
|
||||
null,
|
||||
});
|
||||
|
||||
if (typeof onSuccess === "function") {
|
||||
await onSuccess();
|
||||
}
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
if (error?.code === "ERR_CANCELED") {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
console.log("Apple sign-in error", error?.message);
|
||||
const message =
|
||||
error?.code && error.code.startsWith("auth/")
|
||||
? handleFirebaseError(error.code)
|
||||
: error?.message ||
|
||||
"Connexion Apple impossible. Merci de réessayer.";
|
||||
setTooltip({ text: message, type: "error" });
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isAppleSignInAvailable, isLoading, onSuccess, setTooltip]);
|
||||
|
||||
return {
|
||||
signInWithGoogle,
|
||||
signInWithApple,
|
||||
isAppleSignInAvailable,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSocialAuth;
|
||||
@@ -2,20 +2,29 @@ import { Motion } from "@legendapp/motion";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, { useState } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ItemContainer from "../components/ItemContainer/ItemContainer";
|
||||
import useLayoutType from "../hooks/useLayoutType";
|
||||
import { responsiveHeight } from "../actions/responsiveSizes";
|
||||
import { gutters, Palette, Style } from "../styles";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
|
||||
const TabBar = ({ state = {}, descriptors = {}, navigation = {} }) => {
|
||||
const { isWeb } = useLayoutType();
|
||||
const { isWeb, isIOS } = useLayoutType();
|
||||
const [pressedIndex, setPressedIndex] = useState(null);
|
||||
const { bottom: bottomInset = 0 } = useSafeAreaInsets();
|
||||
const fallbackBottomOffset = responsiveHeight(2, true);
|
||||
const bottomOffset = isWeb
|
||||
? gutters
|
||||
: isIOS
|
||||
? Math.max(bottomInset, fallbackBottomOffset)
|
||||
: bottomInset + fallbackBottomOffset;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: isWeb ? gutters : 5,
|
||||
bottom: bottomOffset,
|
||||
width: "90%",
|
||||
maxWidth: 500,
|
||||
alignSelf: "center",
|
||||
|
||||
@@ -46,11 +46,17 @@ export default ({ navigation }) => {
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
title: "Notifications",
|
||||
title: "Notifications push",
|
||||
key: "notifications",
|
||||
value: !!currentUserData?.notifications,
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
title: "Notifications par email",
|
||||
key: "emailNotifications",
|
||||
value: !!currentUserData?.emailNotifications,
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
title: "Langue",
|
||||
key: "language",
|
||||
|
||||
@@ -371,13 +371,19 @@ const Home = ({ navigation, route }) => {
|
||||
simpleRef: true,
|
||||
});
|
||||
const videoUrl = isWeb ? video?.landingWeb : video?.landing;
|
||||
const homeBackgroundImage = isWeb
|
||||
? background.homeBGWeb
|
||||
: background.homeBG;
|
||||
const landingBackgroundImage = homeBackgroundImage;
|
||||
|
||||
return adventureStarted ? (
|
||||
<Page
|
||||
shareBtn
|
||||
headerType="NONE"
|
||||
containerStyle={{ width: "100%", maxWidth: "100%", paddingHorizontal: 0 }}
|
||||
backgroundImg={homeBackgroundImage}
|
||||
>
|
||||
<View style={styles.root}>
|
||||
<View style={[styles.root, isWeb ? styles.rootWeb : styles.rootNative]}>
|
||||
<View
|
||||
style={{
|
||||
zIndex: 10,
|
||||
@@ -418,7 +424,7 @@ const Home = ({ navigation, route }) => {
|
||||
stageStates={stageStates}
|
||||
activeIndex={activeStageIndex}
|
||||
onActiveIndexChange={setActiveStageIndex}
|
||||
backgroundImage={background.homeBGWeb}
|
||||
backgroundImage={homeBackgroundImage}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
@@ -464,7 +470,11 @@ const Home = ({ navigation, route }) => {
|
||||
</Page>
|
||||
) : (
|
||||
<>
|
||||
<Page shareBtn title="Landing Page" backgroundImg={background.homeBGWeb}>
|
||||
<Page
|
||||
shareBtn
|
||||
title="Landing Page"
|
||||
backgroundImg={landingBackgroundImage}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -493,7 +503,12 @@ export default Home;
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "#425B87",
|
||||
},
|
||||
rootWeb: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
rootNative: {
|
||||
backgroundColor: "rgba(66, 91, 135, 0.3)",
|
||||
},
|
||||
heroImage: {
|
||||
position: "absolute",
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { background } from "../assets";
|
||||
import FullscreenIntroVideo from "../components/FullscreenIntroVideo";
|
||||
import GradientButton from "../components/GradientButton";
|
||||
import { usersRef, videosRef } from "../config/firebase";
|
||||
import { videosRef } from "../config/firebase";
|
||||
import useDataFromRef from "../hooks/useDataFromRef";
|
||||
import { isWeb } from "../hooks/useLayoutType";
|
||||
import Page from "../layouts/Page";
|
||||
import { Routes } from "../navigation/Routes";
|
||||
import { useUser } from "../providers/UserDataProvider";
|
||||
@@ -19,13 +24,9 @@ const LANGUAGE_STORAGE_KEY = "preferredLanguage";
|
||||
export default function LandingPage() {
|
||||
const navigation = useNavigation();
|
||||
const { currentUID } = useUser();
|
||||
const [isVideoVisible, setIsVideoVisible] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState(null);
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(null);
|
||||
|
||||
const handleStartVisit = useCallback(() => {
|
||||
setIsVideoVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleSelectLanguage = useCallback((language) => {
|
||||
setSelectedLanguage(language);
|
||||
AsyncStorage.setItem(LANGUAGE_STORAGE_KEY, language).catch((error) => {
|
||||
@@ -33,33 +34,10 @@ export default function LandingPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSashaHome = useCallback(() => {
|
||||
// Placeholder handler until the Sasha experience is available.
|
||||
}, []);
|
||||
|
||||
const handleLaunchAdventure = useCallback(() => {
|
||||
navigation.navigate(Routes.Register);
|
||||
}, [navigation]);
|
||||
|
||||
const persistAdventureStarted = useCallback(async () => {
|
||||
if (!currentUID) {
|
||||
return;
|
||||
}
|
||||
await usersRef
|
||||
.doc(currentUID)
|
||||
.set({ adventureStarted: true }, { merge: true });
|
||||
}, [currentUID]);
|
||||
|
||||
const handleVideoClose = useCallback(() => {
|
||||
setIsVideoVisible(false);
|
||||
persistAdventureStarted()
|
||||
.catch((error) => {
|
||||
console.warn("LandingPage: adventureStarted update failed", error);
|
||||
})
|
||||
.finally(() => {
|
||||
navigation.navigate(Routes.Login);
|
||||
});
|
||||
}, [navigation, persistAdventureStarted]);
|
||||
const { data: video } = useDataFromRef({
|
||||
ref: videosRef.doc("fr"),
|
||||
simpleRef: true,
|
||||
@@ -118,12 +96,18 @@ export default function LandingPage() {
|
||||
<View style={styles.actions}>
|
||||
<GradientButton
|
||||
title="Découvrir Musicland"
|
||||
onPress={handleStartVisit}
|
||||
onPress={() => setVideoUrl(video?.introWeb)}
|
||||
containerStyle={styles.actionButton}
|
||||
/>
|
||||
<GradientButton
|
||||
title="Accueil avec Sasha"
|
||||
onPress={handleSashaHome}
|
||||
title="Présentation de Musicland"
|
||||
onPress={() =>
|
||||
setVideoUrl(
|
||||
Platform.OS === "web"
|
||||
? video?.landingWeb
|
||||
: video?.landing,
|
||||
)
|
||||
}
|
||||
containerStyle={styles.actionButton}
|
||||
/>
|
||||
<GradientButton
|
||||
@@ -137,9 +121,9 @@ export default function LandingPage() {
|
||||
</View>
|
||||
</Page>
|
||||
<FullscreenIntroVideo
|
||||
url={isWeb ? video?.landingWeb : video?.landing}
|
||||
visible={isVideoVisible}
|
||||
onClose={handleVideoClose}
|
||||
url={videoUrl}
|
||||
visible={!!videoUrl}
|
||||
onClose={() => setVideoUrl(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { GoogleSigninButton } from "@react-native-google-signin/google-signin";
|
||||
import * as AuthSession from "expo-auth-session";
|
||||
import * as GoogleAuth from "expo-auth-session/providers/google";
|
||||
import Constants from "expo-constants";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import * as AppleAuthentication from "expo-apple-authentication";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import { useGlobal } from "reactn";
|
||||
import { handleFirebaseError } from "../actions/signupActions.js";
|
||||
@@ -21,11 +10,7 @@ import GradientButton from "../components/GradientButton.js";
|
||||
import { Input } from "../components/Input.js";
|
||||
import ItemContainer from "../components/ItemContainer/ItemContainer";
|
||||
import firebase from "../config/firebase";
|
||||
import {
|
||||
GOOGLE_ANDROID_CLIENT_ID,
|
||||
GOOGLE_IOS_CLIENT_ID,
|
||||
GOOGLE_WEB_CLIENT_ID,
|
||||
} from "../data/keys";
|
||||
import useSocialAuth from "../hooks/useSocialAuth";
|
||||
import { isWeb } from "../hooks/useLayoutType";
|
||||
import Page from "../layouts/Page.js";
|
||||
import { Routes } from "../navigation";
|
||||
@@ -34,37 +19,26 @@ import { FONT_FAMILY } from "../styles/Fonts.js";
|
||||
import Palette from "../styles/Palette.js";
|
||||
|
||||
export default ({ navigation }) => {
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
const [, setTooltip] = useGlobal("_tooltip");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Let the provider compute a compliant redirect URI for native (com.googleusercontent.apps.<client-id>:/oauth2redirect)
|
||||
// Avoid forcing a custom scheme like musicland:// which Google can reject for native apps.
|
||||
|
||||
const isExpoGo = Constants?.appOwnership === "expo";
|
||||
const expoRedirectUri = AuthSession.makeRedirectUri({ useProxy: true });
|
||||
const nativeRedirectUri = getNativeGoogleRedirectUri();
|
||||
const redirectUri =
|
||||
isWeb || isExpoGo ? expoRedirectUri : nativeRedirectUri || expoRedirectUri;
|
||||
|
||||
const [request, response, promptAsync] = GoogleAuth.useAuthRequest({
|
||||
iosClientId: GOOGLE_IOS_CLIENT_ID,
|
||||
androidClientId: GOOGLE_ANDROID_CLIENT_ID,
|
||||
webClientId: GOOGLE_WEB_CLIENT_ID,
|
||||
expoClientId: GOOGLE_WEB_CLIENT_ID,
|
||||
redirectUri,
|
||||
// Use Authorization Code + PKCE to comply with Google OAuth for native apps
|
||||
responseType: AuthSession.ResponseType.Code,
|
||||
usePKCE: true,
|
||||
scopes: ["openid", "profile", "email"],
|
||||
});
|
||||
|
||||
const afterLoginNavigate = async () => {
|
||||
const afterLoginNavigate = useCallback(async () => {
|
||||
const uid = firebase.auth().currentUser?.uid;
|
||||
if (!uid) throw new Error("Aucun utilisateur après connexion");
|
||||
navigation.reset({ index: 0, routes: [{ name: Routes.BottomTab }] });
|
||||
};
|
||||
}, [navigation]);
|
||||
|
||||
const {
|
||||
signInWithGoogle,
|
||||
signInWithApple,
|
||||
isAppleSignInAvailable,
|
||||
isLoading: socialLoading,
|
||||
} = useSocialAuth({
|
||||
onSuccess: afterLoginNavigate,
|
||||
});
|
||||
|
||||
const isBusy = loading || socialLoading;
|
||||
|
||||
const onLogin = async () => {
|
||||
try {
|
||||
@@ -83,110 +57,6 @@ export default ({ navigation }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onLoginWithGoogle = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (isWeb) {
|
||||
const provider = new firebase.auth.GoogleAuthProvider();
|
||||
provider.addScope("profile");
|
||||
provider.addScope("email");
|
||||
await firebase.auth().signInWithPopup(provider);
|
||||
await afterLoginNavigate();
|
||||
setLoading(false);
|
||||
} else {
|
||||
// Use defaults from the request; don't override with proxy here
|
||||
const result = await promptAsync({ useProxy: isExpoGo });
|
||||
if (result?.type !== "success") {
|
||||
// Cancelled or errored during the browser flow
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Google Login error", e?.message);
|
||||
const message =
|
||||
e?.code && e.code.startsWith("auth/")
|
||||
? handleFirebaseError(e.code)
|
||||
: e?.message;
|
||||
setTooltip({
|
||||
text: message || "Connexion Google impossible. Merci de réessayer.",
|
||||
type: "error",
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle native Google OAuth response
|
||||
useEffect(() => {
|
||||
const handleNativeGoogleResponse = async () => {
|
||||
try {
|
||||
if (response?.type === "success") {
|
||||
// If using Expo proxy, tokens can be present already.
|
||||
let idToken =
|
||||
response?.authentication?.idToken || response?.params?.id_token;
|
||||
|
||||
if (!idToken) {
|
||||
// Otherwise, exchange the authorization code for tokens using PKCE.
|
||||
const code =
|
||||
response?.params?.code || response?.authentication?.code;
|
||||
if (!code) throw new Error("Code d'autorisation Google manquant");
|
||||
|
||||
// Use the exact clientId used during the authorize request.
|
||||
const clientId = request?.clientId;
|
||||
const discovery = {
|
||||
authorizationEndpoint:
|
||||
"https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
||||
revocationEndpoint: "https://oauth2.googleapis.com/revoke",
|
||||
};
|
||||
|
||||
// Light debug: helps identify redirect/client mismatches in dev.
|
||||
console.log("Google token exchange", {
|
||||
clientId,
|
||||
redirectUri: request?.redirectUri,
|
||||
hasCodeVerifier: !!request?.codeVerifier,
|
||||
});
|
||||
|
||||
const tokenResponse = await AuthSession.exchangeCodeAsync(
|
||||
{
|
||||
clientId,
|
||||
code,
|
||||
redirectUri: request?.redirectUri,
|
||||
extraParams: { code_verifier: request?.codeVerifier },
|
||||
},
|
||||
discovery
|
||||
);
|
||||
idToken = tokenResponse?.id_token;
|
||||
}
|
||||
|
||||
if (!idToken)
|
||||
throw new Error("Jeton Google manquant (échange/retour)");
|
||||
|
||||
const credential =
|
||||
firebase.auth.GoogleAuthProvider.credential(idToken);
|
||||
await firebase.auth().signInWithCredential(credential);
|
||||
await afterLoginNavigate();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Google native sign-in error", e?.message);
|
||||
const message =
|
||||
e?.code && e.code.startsWith("auth/")
|
||||
? handleFirebaseError(e.code)
|
||||
: e?.message;
|
||||
setTooltip({
|
||||
text: message || "Connexion Google impossible",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isWeb && response) {
|
||||
handleNativeGoogleResponse();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [response]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
width={isWeb ? 600 : null}
|
||||
@@ -241,97 +111,95 @@ export default ({ navigation }) => {
|
||||
<GradientButton
|
||||
title="Se connecter"
|
||||
containerStyle={{
|
||||
width: "80%",
|
||||
width: "90%",
|
||||
alignSelf: "center",
|
||||
}}
|
||||
onPress={onLogin}
|
||||
disabled={loading || !email || !password}
|
||||
disabled={isBusy || !email || !password}
|
||||
/>
|
||||
<View style={styles.socialWrapper}>
|
||||
{isAppleSignInAvailable ? (
|
||||
<AppleAuthButton
|
||||
onPress={signInWithApple}
|
||||
disabled={isBusy}
|
||||
loading={socialLoading}
|
||||
/>
|
||||
) : null}
|
||||
<GoogleAuthButton
|
||||
onPress={onLoginWithGoogle}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
onPress={signInWithGoogle}
|
||||
disabled={isBusy}
|
||||
loading={socialLoading}
|
||||
/>
|
||||
|
||||
<View style={styles.signupWrapper}>
|
||||
<Text style={styles.signupPrompt}>Pas encore de compte ?</Text>
|
||||
<Pressable
|
||||
onPress={() => navigate(Routes.Register)}
|
||||
style={({ pressed }) => [
|
||||
styles.signupPressable,
|
||||
pressed && styles.linkPressed,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.signupLink}>Créer un compte</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
</ItemContainer>
|
||||
<View style={styles.bottomFooter}>
|
||||
<Text style={styles.bottomFooterText}>
|
||||
Tu n'as pas de compte ?{" "}
|
||||
<Text
|
||||
style={styles.bottomFooterLink}
|
||||
onPress={() => navigate(Routes.Register)}
|
||||
>
|
||||
Créer un compte
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const GoogleAuthButton = ({ onPress, disabled, loading }) => {
|
||||
const AppleAuthButton = ({ onPress, disabled, loading }) => {
|
||||
if (isWeb) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.webButton,
|
||||
pressed && !disabled ? styles.webButtonPressed : null,
|
||||
disabled ? styles.webButtonDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.webContent}>
|
||||
<View style={styles.webIconContainer}>
|
||||
<GoogleLogo />
|
||||
</View>
|
||||
<Text style={styles.webText}>Continuer avec Google</Text>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#4285F4" />
|
||||
) : (
|
||||
<View style={styles.webRightSpacer} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.nativeButtonWrapper}>
|
||||
<GoogleSigninButton
|
||||
<AppleAuthentication.AppleAuthenticationButton
|
||||
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
|
||||
buttonStyle={
|
||||
AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
||||
}
|
||||
style={styles.nativeButton}
|
||||
size={GoogleSigninButton.Size.Wide}
|
||||
color={GoogleSigninButton.Color.Dark}
|
||||
cornerRadius={12}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{loading ? (
|
||||
<View style={styles.nativeLoadingOverlay}>
|
||||
<ActivityIndicator size="small" color={Palette.white} />
|
||||
<ActivityIndicator size="small" color={Palette.black} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const getNativeGoogleRedirectUri = () => {
|
||||
const clientId = Platform.select({
|
||||
ios: GOOGLE_IOS_CLIENT_ID,
|
||||
android: GOOGLE_ANDROID_CLIENT_ID,
|
||||
default: null,
|
||||
});
|
||||
|
||||
if (!clientId || !clientId.includes(".apps.googleusercontent.com")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const clientPrefix = clientId.replace(".apps.googleusercontent.com", "");
|
||||
return `com.googleusercontent.apps.${clientPrefix}:/oauth2redirect`;
|
||||
};
|
||||
const GoogleAuthButton = ({ onPress, disabled, loading }) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.socialButton,
|
||||
pressed && !disabled ? styles.socialButtonPressed : null,
|
||||
disabled ? styles.socialButtonDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.socialButtonContent}>
|
||||
<View style={styles.socialIconContainer}>
|
||||
<GoogleLogo />
|
||||
</View>
|
||||
<Text style={styles.socialButtonText}>Continuer avec Google</Text>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#4285F4" />
|
||||
) : (
|
||||
<View style={styles.socialRightSpacer} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
@@ -347,6 +215,7 @@ const styles = StyleSheet.create({
|
||||
cardContent: {
|
||||
gap: 28,
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 5,
|
||||
width: "100%",
|
||||
},
|
||||
greetingSection: {
|
||||
@@ -384,31 +253,29 @@ const styles = StyleSheet.create({
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
textAlign: "right",
|
||||
},
|
||||
signupWrapper: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
bottomFooter: {
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
signupPrompt: {
|
||||
bottomFooterText: {
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
|
||||
textAlign: "center",
|
||||
},
|
||||
signupPressable: {
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
signupLink: {
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
bottomFooterLink: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
color: Palette.white,
|
||||
},
|
||||
webButton: {
|
||||
socialWrapper: {
|
||||
width: "100%",
|
||||
gap: 16,
|
||||
alignItems: "center",
|
||||
},
|
||||
socialButton: {
|
||||
alignSelf: "center",
|
||||
borderRadius: 12,
|
||||
width: "80%",
|
||||
|
||||
width: "90%",
|
||||
backgroundColor: Palette.white,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E3E7EF",
|
||||
@@ -420,38 +287,38 @@ const styles = StyleSheet.create({
|
||||
shadowRadius: 16,
|
||||
elevation: 2,
|
||||
},
|
||||
webButtonPressed: {
|
||||
socialButtonPressed: {
|
||||
backgroundColor: "#F4F7FF",
|
||||
},
|
||||
webButtonDisabled: {
|
||||
socialButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
webContent: {
|
||||
socialButtonContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
},
|
||||
webIconContainer: {
|
||||
socialIconContainer: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
},
|
||||
webText: {
|
||||
socialButtonText: {
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
fontSize: 15,
|
||||
color: "#202124",
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
},
|
||||
webRightSpacer: {
|
||||
socialRightSpacer: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
nativeButtonWrapper: {
|
||||
alignSelf: "center",
|
||||
width: "80%",
|
||||
width: "90%",
|
||||
height: 50,
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
||||
@@ -17,21 +17,28 @@ export default (props) => {
|
||||
const [currentUserData] = useGlobal("currentUserData");
|
||||
const [, setTooltip] = useGlobal("_tooltip");
|
||||
|
||||
const [isSwitch, setIsSwitch] = useState(false);
|
||||
const [isPushSwitch, setIsPushSwitch] = useState(false);
|
||||
const [isEmailSwitch, setIsEmailSwitch] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSwitch(!!currentUserData?.notifications);
|
||||
setIsPushSwitch(!!currentUserData?.notifications);
|
||||
}, [currentUserData?.notifications]);
|
||||
|
||||
const onToggle = async (next) => {
|
||||
useEffect(() => {
|
||||
setIsEmailSwitch(!!currentUserData?.emailNotifications);
|
||||
}, [currentUserData?.emailNotifications]);
|
||||
|
||||
const updateNotificationPreference = async (next, field, setState) => {
|
||||
try {
|
||||
setIsSwitch(next);
|
||||
setState(next);
|
||||
const uid = currentUID || firebase.auth().currentUser?.uid;
|
||||
if (!uid) return;
|
||||
await usersRef.doc(uid).set({ notifications: next }, { merge: true });
|
||||
if (!uid) {
|
||||
throw new Error("Utilisateur non connecté");
|
||||
}
|
||||
await usersRef.doc(uid).set({ [field]: next }, { merge: true });
|
||||
setTooltip({ type: "success", text: "Préférence enregistrée" });
|
||||
} catch (e) {
|
||||
setIsSwitch(!next);
|
||||
setState(!next);
|
||||
setTooltip({
|
||||
type: "error",
|
||||
text: e?.message || "Enregistrement impossible",
|
||||
@@ -39,6 +46,12 @@ export default (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onTogglePush = (next) =>
|
||||
updateNotificationPreference(next, "notifications", setIsPushSwitch);
|
||||
|
||||
const onToggleEmail = (next) =>
|
||||
updateNotificationPreference(next, "emailNotifications", setIsEmailSwitch);
|
||||
|
||||
return (
|
||||
<Page
|
||||
headerType="NAVIGATE"
|
||||
@@ -74,9 +87,9 @@ export default (props) => {
|
||||
fontFamily: FONT_FAMILY.HelveticaNeueMedium,
|
||||
}}
|
||||
>
|
||||
Notifications
|
||||
Notifications push
|
||||
</Text>
|
||||
<Switch value={isSwitch} setValue={onToggle} />
|
||||
<Switch value={isPushSwitch} setValue={onTogglePush} />
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
@@ -88,6 +101,28 @@ export default (props) => {
|
||||
Nous t’encourageons à activer les notifications pour découvrir les
|
||||
derniers classements, les nouveaux sons et les meilleurs playbacks.
|
||||
</Text>
|
||||
<View style={Style.containerSpaceBetween}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.HelveticaNeueMedium,
|
||||
}}
|
||||
>
|
||||
Notifications par email
|
||||
</Text>
|
||||
<Switch value={isEmailSwitch} setValue={onToggleEmail} />
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
}}
|
||||
>
|
||||
Reste informé en recevant nos nouveautés et rappels directement dans
|
||||
ta boîte mail.
|
||||
</Text>
|
||||
</BlurView>
|
||||
</View>
|
||||
</Page>
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import * as AppleAuthentication from "expo-apple-authentication";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
|
||||
import { BlurView } from "expo-blur";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import { background } from "../assets";
|
||||
import BorderGradientButton from "../components/BorderGradientButton";
|
||||
import { Input } from "../components/Input";
|
||||
import ItemContainer from "../components/ItemContainer/ItemContainer";
|
||||
import COUNTRIES from "../constants/countries";
|
||||
import firebase from "../config/firebase";
|
||||
import { isWeb } from "../hooks/useLayoutType";
|
||||
import useSocialAuth from "../hooks/useSocialAuth";
|
||||
import Page from "../layouts/Page";
|
||||
import { Routes } from "../navigation";
|
||||
import { navigate } from "../navigation/NavigationService";
|
||||
import { navigate, reset } from "../navigation/NavigationService";
|
||||
import { Palette } from "../styles";
|
||||
import { FONT_FAMILY } from "../styles/Fonts";
|
||||
|
||||
@@ -33,6 +42,23 @@ const Register = () => {
|
||||
const [isCountryModalVisible, setIsCountryModalVisible] = useState(false);
|
||||
const [preferredLanguage, setPreferredLanguage] = useState(null);
|
||||
|
||||
const afterSocialAuth = useCallback(async () => {
|
||||
const uid = firebase.auth().currentUser?.uid;
|
||||
if (!uid) throw new Error("Aucun utilisateur après connexion");
|
||||
reset({ index: 0, routes: [{ name: Routes.BottomTab }] });
|
||||
}, []);
|
||||
|
||||
const {
|
||||
signInWithGoogle,
|
||||
signInWithApple,
|
||||
isAppleSignInAvailable,
|
||||
isLoading: socialLoading,
|
||||
} = useSocialAuth({
|
||||
onSuccess: afterSocialAuth,
|
||||
});
|
||||
|
||||
const isBusy = socialLoading;
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem(LANGUAGE_STORAGE_KEY)
|
||||
.then((storedLanguage) => {
|
||||
@@ -51,7 +77,7 @@ const Register = () => {
|
||||
return COUNTRIES;
|
||||
}
|
||||
return COUNTRIES.filter((country) =>
|
||||
country.name.toLowerCase().includes(query)
|
||||
country.name.toLowerCase().includes(query),
|
||||
);
|
||||
}, [countrySearch]);
|
||||
|
||||
@@ -73,25 +99,17 @@ const Register = () => {
|
||||
setCountrySearch("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Page
|
||||
width={isWeb ? 600 : null}
|
||||
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
|
||||
headerType="NAVIGATION"
|
||||
title="Inscription"
|
||||
>
|
||||
<View style={styles.pageContent}>
|
||||
<ItemContainer height={680} disableKeyboardHeight>
|
||||
const renderFormContent = () => (
|
||||
<View style={styles.formContent}>
|
||||
<Text style={styles.title}>Renseigne tes informations</Text>
|
||||
{preferredLanguage && (
|
||||
{preferredLanguage && !isWeb ? (
|
||||
<Text style={styles.languageInfo}>
|
||||
Langue choisie :{" "}
|
||||
<Text style={styles.languageInfoValue}>
|
||||
{preferredLanguage === "fr" ? "Français" : "English"}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
<View style={{ gap: 16 }}>
|
||||
<View style={{ gap: 12 }}>
|
||||
<Input
|
||||
@@ -110,11 +128,18 @@ const Register = () => {
|
||||
/>
|
||||
<View style={styles.countryField}>
|
||||
<Text style={styles.inputLabel}>Pays</Text>
|
||||
<Pressable
|
||||
<BlurView
|
||||
tint="dark"
|
||||
style={[
|
||||
styles.countryButton,
|
||||
styles.countryBlurWrapper,
|
||||
selectedCountry ? styles.countryButtonFilled : null,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.countryButton,
|
||||
pressed ? styles.countryButtonPressed : null,
|
||||
]}
|
||||
onPress={() => setIsCountryModalVisible(true)}
|
||||
>
|
||||
<Text
|
||||
@@ -123,11 +148,10 @@ const Register = () => {
|
||||
!selectedCountry && styles.countryButtonPlaceholder,
|
||||
]}
|
||||
>
|
||||
{selectedCountry
|
||||
? selectedCountry.name
|
||||
: "Choisis ton pays"}
|
||||
{selectedCountry ? selectedCountry.name : "Choisis ton pays"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</BlurView>
|
||||
</View>
|
||||
<Input
|
||||
placeholder="Ville"
|
||||
@@ -149,7 +173,7 @@ const Register = () => {
|
||||
<BorderGradientButton
|
||||
title="Créer mon compte"
|
||||
containerStyle={{
|
||||
width: "80%",
|
||||
width: "90%",
|
||||
alignSelf: "center",
|
||||
}}
|
||||
onPress={() =>
|
||||
@@ -162,22 +186,72 @@ const Register = () => {
|
||||
preferredLanguage,
|
||||
})
|
||||
}
|
||||
disabled={!isFormValid}
|
||||
disabled={!isFormValid || isBusy}
|
||||
/>
|
||||
<View style={styles.socialWrapper}>
|
||||
{isAppleSignInAvailable ? (
|
||||
<AppleAuthButton
|
||||
onPress={signInWithApple}
|
||||
disabled={isBusy}
|
||||
loading={socialLoading}
|
||||
/>
|
||||
) : null}
|
||||
<GoogleAuthButton
|
||||
onPress={signInWithGoogle}
|
||||
disabled={isBusy}
|
||||
loading={socialLoading}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.terms}>
|
||||
En t’inscrivant, tu acceptes nos conditions générales{"\n"}
|
||||
d’utilisation et notre politique de confidentialité.
|
||||
</Text>
|
||||
<Pressable onPress={() => navigate(Routes.Login)}>
|
||||
<Text style={styles.loginText}>
|
||||
Tu as déjà un compte?{" "}
|
||||
<Text style={styles.loginLink}>Se connecter</Text>
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Page
|
||||
width={isWeb ? 600 : null}
|
||||
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
|
||||
headerType="NAVIGATION"
|
||||
title="Inscription"
|
||||
>
|
||||
<View style={styles.pageContent}>
|
||||
<ItemContainer
|
||||
height={isWeb ? 720 : undefined}
|
||||
disableKeyboardHeight
|
||||
style={!isWeb ? styles.mobileItemContainer : styles.webItemContainer}
|
||||
>
|
||||
{isWeb ? (
|
||||
renderFormContent()
|
||||
) : (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={styles.mobileAvoider}
|
||||
>
|
||||
<KeyboardAwareScrollView
|
||||
contentContainerStyle={styles.mobileScrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{renderFormContent()}
|
||||
</KeyboardAwareScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
)}
|
||||
</ItemContainer>
|
||||
<View style={styles.bottomLoginPrompt}>
|
||||
<Text style={styles.bottomFooterText}>
|
||||
Tu as déjà un compte ?{" "}
|
||||
<Text
|
||||
style={styles.bottomFooterLink}
|
||||
onPress={() => navigate(Routes.Login)}
|
||||
>
|
||||
Se connecter
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Modal
|
||||
transparent
|
||||
@@ -224,6 +298,76 @@ const Register = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const AppleAuthButton = ({ onPress, disabled, loading }) => {
|
||||
if (isWeb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.nativeButtonWrapper}>
|
||||
<AppleAuthentication.AppleAuthenticationButton
|
||||
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_UP}
|
||||
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.WHITE}
|
||||
style={styles.nativeButton}
|
||||
cornerRadius={12}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{loading ? (
|
||||
<View style={styles.nativeLoadingOverlay}>
|
||||
<ActivityIndicator size="small" color={Palette.black} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const GoogleAuthButton = ({ onPress, disabled, loading }) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.socialButton,
|
||||
pressed && !disabled ? styles.socialButtonPressed : null,
|
||||
disabled ? styles.socialButtonDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.socialButtonContent}>
|
||||
<View style={styles.socialIconContainer}>
|
||||
<GoogleLogo />
|
||||
</View>
|
||||
<Text style={styles.socialButtonText}>Continuer avec Google</Text>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#4285F4" />
|
||||
) : (
|
||||
<View style={styles.socialRightSpacer} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const GoogleLogo = () => (
|
||||
<Svg width={24} height={24} viewBox="0 0 18 18">
|
||||
<Path
|
||||
fill="#4285F4"
|
||||
d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.71v2.26h2.9c1.68-1.55 2.7-3.83 2.7-6.61Z"
|
||||
/>
|
||||
<Path
|
||||
fill="#34A853"
|
||||
d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.9-2.26c-.8.54-1.83.86-3.06.86a5.31 5.31 0 0 1-5.02-3.69H.96v2.32A9 9 0 0 0 9 18Z"
|
||||
/>
|
||||
<Path
|
||||
fill="#FBBC05"
|
||||
d="M3.98 10.73c-.18-.54-.28-1.12-.28-1.73s.1-1.19.28-1.73V4.95H.96A9 9 0 0 0 0 9c0 1.45.35 2.82.96 4.05l3.02-2.32Z"
|
||||
/>
|
||||
<Path
|
||||
fill="#EA4335"
|
||||
d="M9 3.54c1.32 0 2.5.45 3.43 1.33l2.57-2.57C13.47.9 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.02 2.32A5.31 5.31 0 0 1 9 3.54Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export default Register;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -232,6 +376,22 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
webItemContainer: {
|
||||
width: "100%",
|
||||
maxWidth: 680,
|
||||
},
|
||||
mobileItemContainer: {
|
||||
width: "100%",
|
||||
minHeight: 720,
|
||||
},
|
||||
mobileAvoider: {
|
||||
flex: 1,
|
||||
},
|
||||
mobileScrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
paddingVertical: 24,
|
||||
},
|
||||
formContent: {
|
||||
gap: 32,
|
||||
paddingTop: 5,
|
||||
@@ -251,17 +411,19 @@ const styles = StyleSheet.create({
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
},
|
||||
countryButton: {
|
||||
countryBlurWrapper: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.ultraLightWhite,
|
||||
backgroundColor: Palette.glass,
|
||||
overflow: "hidden",
|
||||
},
|
||||
countryButton: {
|
||||
minHeight: 50,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 16,
|
||||
width: "100%",
|
||||
},
|
||||
countryButtonFilled: {
|
||||
borderColor: Palette.primary,
|
||||
countryButtonPressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
countryButtonText: {
|
||||
fontSize: 14,
|
||||
@@ -297,6 +459,85 @@ const styles = StyleSheet.create({
|
||||
loginLink: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
},
|
||||
bottomLoginPrompt: {
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
bottomFooterText: {
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
|
||||
textAlign: "center",
|
||||
},
|
||||
bottomFooterLink: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
color: Palette.white,
|
||||
},
|
||||
socialWrapper: {
|
||||
width: "100%",
|
||||
gap: 16,
|
||||
alignItems: "center",
|
||||
},
|
||||
socialButton: {
|
||||
alignSelf: "center",
|
||||
borderRadius: 12,
|
||||
width: "90%",
|
||||
|
||||
backgroundColor: Palette.white,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E3E7EF",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
shadowColor: "rgba(12, 6, 34, 0.08)",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
elevation: 2,
|
||||
},
|
||||
socialButtonPressed: {
|
||||
backgroundColor: "#F4F7FF",
|
||||
},
|
||||
socialButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
socialButtonContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
},
|
||||
socialIconContainer: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
},
|
||||
socialButtonText: {
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
fontSize: 15,
|
||||
color: "#202124",
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
},
|
||||
socialRightSpacer: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
nativeButtonWrapper: {
|
||||
alignSelf: "center",
|
||||
width: "90%",
|
||||
height: 50,
|
||||
justifyContent: "center",
|
||||
},
|
||||
nativeButton: {
|
||||
width: "100%",
|
||||
height: 50,
|
||||
},
|
||||
nativeLoadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: Palette.transparentBlack,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import AppleSignInButton from '../../components/buttons/AppleSignInButton';
|
||||
import Button from '../../components/buttons/Button';
|
||||
import GoogleSignInButton from '../../components/buttons/GoogleSignInButton';
|
||||
import AppInput from '../../components/inputs/Input';
|
||||
import PublicScreenLayout from '../../components/layout/PublicScreenLayout';
|
||||
import useRegisterForm from '../../hooks/useRegisterForm';
|
||||
import Fonts from '../../styles/Fonts';
|
||||
import Palette from '../../styles/Palette';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
confirmPassword,
|
||||
setConfirmPassword,
|
||||
handleSubmit,
|
||||
signInWithGoogle,
|
||||
signInWithApple,
|
||||
isAppleSignInAvailable,
|
||||
isLoading,
|
||||
} = useRegisterForm();
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (router.canGoBack?.()) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
router.replace('/(public)/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicScreenLayout title="Inscription" bodyStyle={styles.body}>
|
||||
<Text style={styles.heading}>Rejoignez Minuit</Text>
|
||||
<Text style={styles.subheading}>
|
||||
Créez votre compte en quelques secondes et profitez de l'expérience
|
||||
complète.
|
||||
</Text>
|
||||
|
||||
<View style={styles.form}>
|
||||
<AppInput
|
||||
label="Email"
|
||||
value={email}
|
||||
setValue={setEmail}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
containerStyle={styles.input}
|
||||
textInputProps={{
|
||||
autoCapitalize: 'none',
|
||||
autoComplete: 'email',
|
||||
textContentType: 'emailAddress',
|
||||
name: 'email',
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppInput
|
||||
label="Mot de passe"
|
||||
value={password}
|
||||
setValue={setPassword}
|
||||
placeholder="Mot de passe"
|
||||
type="password"
|
||||
containerStyle={styles.input}
|
||||
textInputProps={{
|
||||
autoComplete: 'new-password',
|
||||
textContentType: 'newPassword',
|
||||
name: 'new-password',
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppInput
|
||||
label="Confirmer le mot de passe"
|
||||
value={confirmPassword}
|
||||
setValue={setConfirmPassword}
|
||||
placeholder="Confirmer le mot de passe"
|
||||
type="password"
|
||||
containerStyle={styles.input}
|
||||
textInputProps={{
|
||||
autoComplete: 'new-password',
|
||||
textContentType: 'newPassword',
|
||||
name: 'confirm-password',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
text="Créer mon compte"
|
||||
onPress={handleSubmit}
|
||||
contentContainerStyle={styles.primaryButton}
|
||||
/>
|
||||
|
||||
<View style={styles.socialSection}>
|
||||
<View style={styles.socialDivider}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>ou continuer avec</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
<GoogleSignInButton
|
||||
onPress={signInWithGoogle}
|
||||
disabled={isLoading}
|
||||
style={styles.socialButton}
|
||||
/>
|
||||
|
||||
{isAppleSignInAvailable ? (
|
||||
<AppleSignInButton
|
||||
onPress={signInWithApple}
|
||||
disabled={isLoading}
|
||||
style={styles.socialButton}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
type="secondary"
|
||||
text="Déjà inscrit ? Se connecter"
|
||||
onPress={handleGoBack}
|
||||
contentContainerStyle={styles.secondaryButton}
|
||||
/>
|
||||
</View>
|
||||
</PublicScreenLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
body: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
heading: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: Palette.darkPurple,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subheading: {
|
||||
...Fonts.bodySmall,
|
||||
color: 'rgba(15, 12, 20, 0.6)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
form: {
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
gap: 16,
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
},
|
||||
primaryButton: {
|
||||
marginTop: 8,
|
||||
},
|
||||
socialSection: {
|
||||
marginTop: 4,
|
||||
gap: 12,
|
||||
},
|
||||
socialDivider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: 'rgba(15, 12, 20, 0.15)',
|
||||
},
|
||||
dividerText: {
|
||||
...Fonts.tinyBold,
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(15, 12, 20, 0.45)',
|
||||
},
|
||||
socialButton: {
|
||||
width: '100%',
|
||||
},
|
||||
hint: {
|
||||
marginTop: 12,
|
||||
...Fonts.bodySmall,
|
||||
textAlign: 'center',
|
||||
color: 'rgba(15, 12, 20, 0.6)',
|
||||
},
|
||||
secondaryButton: {
|
||||
marginTop: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useRouter, usePathname } from 'expo-router';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import useSocialAuth from './useSocialAuth';
|
||||
import { useAuth } from '../providers/auth-context';
|
||||
import { useLoading } from '../providers/loading-context';
|
||||
import { useTooltip } from '../providers/tooltip-context';
|
||||
|
||||
const normalizeEmail = rawEmail =>
|
||||
typeof rawEmail === 'string' ? rawEmail.trim().toLowerCase() : '';
|
||||
|
||||
const normalizePassword = rawPassword =>
|
||||
typeof rawPassword === 'string' ? rawPassword.trim() : '';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 6;
|
||||
|
||||
const useRegisterForm = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { registerWithEmail, redirectUser } = useAuth();
|
||||
const { withLoading, isLoading } = useLoading();
|
||||
const { showTooltip } = useTooltip();
|
||||
const defaultEmail = __DEV__ ? `test@${Platform.OS}.com` : '';
|
||||
const defaultPassword = __DEV__ ? 'Minuit33' : '';
|
||||
const [email, setEmail] = useState(defaultEmail);
|
||||
const [password, setPassword] = useState(defaultPassword);
|
||||
const [confirmPassword, setConfirmPassword] = useState(defaultPassword);
|
||||
|
||||
const { signInWithGoogle, signInWithApple, isAppleSignInAvailable } =
|
||||
useSocialAuth({ defaultAction: 'register' });
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedEmail = normalizeEmail(email);
|
||||
const trimmedPassword = normalizePassword(password);
|
||||
const trimmedConfirm = normalizePassword(confirmPassword);
|
||||
|
||||
if (!trimmedEmail || !trimmedPassword || !trimmedConfirm) {
|
||||
showTooltip({
|
||||
message: 'Merci de renseigner tous les champs requis.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedPassword.length < MIN_PASSWORD_LENGTH) {
|
||||
showTooltip({
|
||||
message: `Le mot de passe doit contenir au moins ${MIN_PASSWORD_LENGTH} caractères.`,
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedPassword !== trimmedConfirm) {
|
||||
showTooltip({
|
||||
message: 'Les mots de passe ne correspondent pas.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withLoading(async () => {
|
||||
await registerWithEmail(trimmedEmail, trimmedPassword);
|
||||
});
|
||||
await redirectUser({ router, pathname, isInitial: false });
|
||||
showTooltip({
|
||||
message: 'Compte créé ! Bienvenue chez Minuit.',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
showTooltip({
|
||||
message:
|
||||
error?.message || 'Impossible de créer votre compte pour le moment.',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}, [
|
||||
confirmPassword,
|
||||
email,
|
||||
isLoading,
|
||||
password,
|
||||
redirectUser,
|
||||
registerWithEmail,
|
||||
router,
|
||||
pathname,
|
||||
showTooltip,
|
||||
withLoading,
|
||||
]);
|
||||
|
||||
const submitDisabled = useMemo(() => {
|
||||
const trimmedEmail = normalizeEmail(email);
|
||||
const trimmedPassword = normalizePassword(password);
|
||||
const trimmedConfirm = normalizePassword(confirmPassword);
|
||||
return (
|
||||
!trimmedEmail ||
|
||||
!trimmedPassword ||
|
||||
!trimmedConfirm ||
|
||||
trimmedPassword.length < MIN_PASSWORD_LENGTH ||
|
||||
isLoading
|
||||
);
|
||||
}, [confirmPassword, email, isLoading, password]);
|
||||
|
||||
return {
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
confirmPassword,
|
||||
setConfirmPassword,
|
||||
isLoading,
|
||||
submitDisabled,
|
||||
handleSubmit,
|
||||
signInWithGoogle,
|
||||
signInWithApple,
|
||||
isAppleSignInAvailable,
|
||||
};
|
||||
};
|
||||
|
||||
export default useRegisterForm;
|
||||
@@ -0,0 +1,389 @@
|
||||
import {
|
||||
GoogleSignin,
|
||||
statusCodes,
|
||||
} from '@react-native-google-signin/google-signin';
|
||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||
import { useRouter, usePathname } from 'expo-router';
|
||||
import * as Google from 'expo-auth-session/providers/google';
|
||||
import Constants from 'expo-constants';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { GOOGLE_CLIENT_IDS } from '../config/keys';
|
||||
import { useAuth } from '../providers/auth-context';
|
||||
import { useLoading } from '../providers/loading-context';
|
||||
import { useTooltip } from '../providers/tooltip-context';
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
const PLACEHOLDER_CLIENT_IDS = {
|
||||
android: 'placeholder-android-client-id',
|
||||
ios: 'placeholder-ios-client-id',
|
||||
web: 'placeholder-web-client-id',
|
||||
expo: 'placeholder-expo-client-id',
|
||||
};
|
||||
|
||||
const GOOGLE_CLIENT_ID_ENV_KEYS = {
|
||||
androidClientId: 'EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID',
|
||||
iosClientId: 'EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID',
|
||||
webClientId: 'EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID',
|
||||
expoClientId: 'EXPO_PUBLIC_GOOGLE_EXPO_CLIENT_ID',
|
||||
};
|
||||
|
||||
const isMeaningfulValue = value => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
if (
|
||||
normalized.startsWith('your-') ||
|
||||
normalized.includes('example') ||
|
||||
normalized.includes('placeholder')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const readGoogleClientIds = () => ({
|
||||
androidClientId: GOOGLE_CLIENT_IDS.android,
|
||||
iosClientId: GOOGLE_CLIENT_IDS.ios,
|
||||
webClientId: GOOGLE_CLIENT_IDS.web,
|
||||
expoClientId: GOOGLE_CLIENT_IDS.expo,
|
||||
});
|
||||
|
||||
const buildAuthSessionConfig = ids => ({
|
||||
androidClientId: ids.androidClientId || PLACEHOLDER_CLIENT_IDS.android,
|
||||
iosClientId: ids.iosClientId || PLACEHOLDER_CLIENT_IDS.ios,
|
||||
webClientId: ids.webClientId || PLACEHOLDER_CLIENT_IDS.web,
|
||||
expoClientId: ids.expoClientId || PLACEHOLDER_CLIENT_IDS.expo,
|
||||
});
|
||||
|
||||
const getRequiredGoogleKeys = ({ isNativePlatform, isExpoGo }) => {
|
||||
if (Platform.OS === 'web') {
|
||||
return ['webClientId'];
|
||||
}
|
||||
|
||||
if (isExpoGo) {
|
||||
if (Platform.OS === 'android') {
|
||||
return ['expoClientId', 'androidClientId'];
|
||||
}
|
||||
if (Platform.OS === 'ios') {
|
||||
return ['expoClientId', 'iosClientId'];
|
||||
}
|
||||
return ['expoClientId'];
|
||||
}
|
||||
|
||||
if (isNativePlatform) {
|
||||
const keys = ['webClientId'];
|
||||
if (Platform.OS === 'android') {
|
||||
keys.push('androidClientId');
|
||||
}
|
||||
if (Platform.OS === 'ios') {
|
||||
keys.push('iosClientId');
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
return ['webClientId'];
|
||||
};
|
||||
|
||||
const findMissingGoogleKeys = (ids, options) =>
|
||||
getRequiredGoogleKeys(options).filter(key => !isMeaningfulValue(ids?.[key]));
|
||||
|
||||
const formatMissingGoogleKeys = keys =>
|
||||
keys.map(key => GOOGLE_CLIENT_ID_ENV_KEYS[key] || key).join(', ');
|
||||
|
||||
const useSocialAuth = ({ defaultAction = 'login' } = {}) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { authenticateWithGoogle, authenticateWithApple, redirectUser } =
|
||||
useAuth();
|
||||
const { withLoading, isLoading } = useLoading();
|
||||
const { showTooltip } = useTooltip();
|
||||
|
||||
const [isAppleSignInAvailable, setIsAppleSignInAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
AppleAuthentication.isAvailableAsync()
|
||||
.then(isAvailable => {
|
||||
if (mounted) {
|
||||
setIsAppleSignInAvailable(isAvailable);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setIsAppleSignInAvailable(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const googleClientIds = useMemo(() => readGoogleClientIds(), []);
|
||||
const authSessionConfig = useMemo(
|
||||
() => buildAuthSessionConfig(googleClientIds),
|
||||
[googleClientIds],
|
||||
);
|
||||
|
||||
const isNativePlatform = Platform.OS === 'ios' || Platform.OS === 'android';
|
||||
const appOwnership = Constants?.appOwnership || null;
|
||||
const isExpoGo = appOwnership === 'expo';
|
||||
const shouldUseAuthSession = !isNativePlatform || isExpoGo;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNativePlatform || shouldUseAuthSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMeaningfulValue(googleClientIds.webClientId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
GoogleSignin.configure({
|
||||
webClientId: googleClientIds.webClientId,
|
||||
iosClientId: googleClientIds.iosClientId,
|
||||
offlineAccess: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to configure Google Sign-In', error);
|
||||
}
|
||||
}, [googleClientIds, isNativePlatform, shouldUseAuthSession]);
|
||||
|
||||
const [, , promptAsync] = Google.useAuthRequest(authSessionConfig);
|
||||
|
||||
const handleMissingGoogleConfig = useCallback(
|
||||
missingKeys => {
|
||||
if (!missingKeys.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
showTooltip({
|
||||
message: `La configuration Google est incomplète. Variables manquantes : ${formatMissingGoogleKeys(
|
||||
missingKeys,
|
||||
)}.`,
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
[showTooltip],
|
||||
);
|
||||
|
||||
const signInWithGoogle = useCallback(
|
||||
async (actionOverride = undefined) => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missingKeys = findMissingGoogleKeys(googleClientIds, {
|
||||
isNativePlatform,
|
||||
isExpoGo,
|
||||
});
|
||||
if (handleMissingGoogleConfig(missingKeys)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNativePlatform && !shouldUseAuthSession) {
|
||||
try {
|
||||
await withLoading(async () => {
|
||||
await GoogleSignin.hasPlayServices({
|
||||
showPlayServicesUpdateDialog: true,
|
||||
});
|
||||
|
||||
const account = await GoogleSignin.signIn();
|
||||
let idToken = account?.idToken;
|
||||
let accessToken = account?.accessToken;
|
||||
|
||||
try {
|
||||
const tokens = await GoogleSignin.getTokens();
|
||||
idToken = idToken || tokens?.idToken;
|
||||
accessToken = accessToken || tokens?.accessToken;
|
||||
} catch (_) {
|
||||
// no-op: continue with whatever tokens we already have
|
||||
}
|
||||
|
||||
if (!idToken) {
|
||||
throw new Error('Impossible de récupérer le jeton Google.');
|
||||
}
|
||||
|
||||
await authenticateWithGoogle({
|
||||
idToken,
|
||||
accessToken,
|
||||
action: actionOverride || defaultAction,
|
||||
});
|
||||
await redirectUser({ router, pathname, isInitial: false });
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === statusCodes.SIGN_IN_CANCELLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error?.code === statusCodes.IN_PROGRESS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error?.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
|
||||
showTooltip({
|
||||
message:
|
||||
'Les services Google Play ne sont pas disponibles ou nécessitent une mise à jour.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
error?.message ||
|
||||
'Impossible de terminer la connexion Google pour le moment.';
|
||||
showTooltip({
|
||||
message,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!promptAsync) {
|
||||
showTooltip({
|
||||
message:
|
||||
'La configuration Google est incomplète. Merci de vérifier vos identifiants client.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withLoading(async () => {
|
||||
const response = await promptAsync({
|
||||
useProxy: Platform.OS !== 'web',
|
||||
showInRecents: true,
|
||||
});
|
||||
|
||||
if (!response || response.type !== 'success') {
|
||||
if (response?.type && response.type !== 'dismiss') {
|
||||
showTooltip({
|
||||
message: 'Connexion Google annulée.',
|
||||
type: 'warning',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { authentication } = response;
|
||||
if (!authentication?.idToken) {
|
||||
throw new Error('Jeton Google introuvable.');
|
||||
}
|
||||
|
||||
await authenticateWithGoogle({
|
||||
idToken: authentication.idToken,
|
||||
accessToken: authentication.accessToken,
|
||||
action: actionOverride || defaultAction,
|
||||
});
|
||||
await redirectUser({ router, pathname, isInitial: false });
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error?.message ||
|
||||
'Impossible de terminer la connexion Google pour le moment.';
|
||||
showTooltip({
|
||||
message,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
authenticateWithGoogle,
|
||||
defaultAction,
|
||||
googleClientIds,
|
||||
handleMissingGoogleConfig,
|
||||
isExpoGo,
|
||||
isLoading,
|
||||
isNativePlatform,
|
||||
pathname,
|
||||
promptAsync,
|
||||
redirectUser,
|
||||
router,
|
||||
shouldUseAuthSession,
|
||||
showTooltip,
|
||||
withLoading,
|
||||
],
|
||||
);
|
||||
|
||||
const signInWithApple = useCallback(
|
||||
async (actionOverride = undefined) => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAppleSignInAvailable) {
|
||||
showTooltip({
|
||||
message:
|
||||
'La connexion avec Apple n’est pas disponible sur cet appareil.',
|
||||
type: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withLoading(async () => {
|
||||
const appleCredential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
],
|
||||
});
|
||||
|
||||
if (!appleCredential?.identityToken) {
|
||||
throw new Error('Impossible de récupérer le jeton Apple.');
|
||||
}
|
||||
|
||||
await authenticateWithApple({
|
||||
idToken: appleCredential.identityToken,
|
||||
rawNonce: appleCredential.nonce,
|
||||
action: actionOverride || defaultAction,
|
||||
});
|
||||
await redirectUser({ router, pathname, isInitial: false });
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === 'ERR_CANCELED') {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error?.message ||
|
||||
'Impossible de terminer la connexion avec Apple pour le moment.';
|
||||
showTooltip({
|
||||
message,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
authenticateWithApple,
|
||||
defaultAction,
|
||||
isAppleSignInAvailable,
|
||||
isLoading,
|
||||
pathname,
|
||||
redirectUser,
|
||||
router,
|
||||
showTooltip,
|
||||
withLoading,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
isAppleSignInAvailable,
|
||||
signInWithGoogle,
|
||||
signInWithApple,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSocialAuth;
|
||||