Fixes and tickets

This commit is contained in:
Thomas Demirdjian
2025-10-28 15:43:18 +01:00
parent 2599a25d77
commit d1be3c19a2
31 changed files with 6663 additions and 795 deletions
+9 -1
View File
@@ -37,6 +37,14 @@
{ {
"client_id": "305598753437-dtjknrpfrjt3bdd7838nga41m5i4emva.apps.googleusercontent.com", "client_id": "305598753437-dtjknrpfrjt3bdd7838nga41m5i4emva.apps.googleusercontent.com",
"client_type": 3 "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"
}
} }
] ]
} }
@@ -44,4 +52,4 @@
} }
], ],
"configuration_version": "1" "configuration_version": "1"
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+2 -4
View File
@@ -15,9 +15,6 @@
"updates": { "updates": {
"fallbackToCacheTimeout": 0 "fallbackToCacheTimeout": 0
}, },
"packagerOpts": {
"sourceExts": ["js", "json", "ts", "tsx", "jsx", "vue"]
},
"splash": { "splash": {
"image": "./assets/splash.png", "image": "./assets/splash.png",
"resizeMode": "cover", "resizeMode": "cover",
@@ -26,10 +23,11 @@
"ios": { "ios": {
"googleServicesFile": "./config/GoogleService-Info.plist", "googleServicesFile": "./config/GoogleService-Info.plist",
"supportsTablet": true, "supportsTablet": true,
"usesAppleSignIn": true,
"requireFullScreen": true, "requireFullScreen": true,
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"associatedDomains": ["applinks:minuit.starter"], "associatedDomains": ["applinks:minuit.starter"],
"bundleIdentifier": "com.omedis.musicland", "bundleIdentifier": "com.omedis.musicland.ai",
"infoPlist": { "infoPlist": {
"UISupportedInterfaceOrientations": [ "UISupportedInterfaceOrientations": [
"UIInterfaceOrientationPortrait", "UIInterfaceOrientationPortrait",
+3582
View File
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -3,21 +3,23 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CLIENT_ID</key> <key>CLIENT_ID</key>
<string>943006074419-h43c7p48nhn5f4td4oimv4dq7b1p3s29.apps.googleusercontent.com</string> <string>305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk.apps.googleusercontent.com</string>
<key>REVERSED_CLIENT_ID</key> <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> <key>API_KEY</key>
<string>AIzaSyAkl2RAzUCYTQCcYhSeij6-tXNk6z7121Y</string> <string>AIzaSyCHExqSfSbgk-hFmmh0ylMAvqzp7JqES4c</string>
<key>GCM_SENDER_ID</key> <key>GCM_SENDER_ID</key>
<string>943006074419</string> <string>305598753437</string>
<key>PLIST_VERSION</key> <key>PLIST_VERSION</key>
<string>1</string> <string>1</string>
<key>BUNDLE_ID</key> <key>BUNDLE_ID</key>
<string>com.minuit.starter</string> <string>com.omedis.musicland.ai</string>
<key>PROJECT_ID</key> <key>PROJECT_ID</key>
<string>minuitcloud</string> <string>musicland-d33f9</string>
<key>STORAGE_BUCKET</key> <key>STORAGE_BUCKET</key>
<string>minuitcloud.appspot.com</string> <string>musicland-d33f9.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key> <key>IS_ADS_ENABLED</key>
<false></false> <false></false>
<key>IS_ANALYTICS_ENABLED</key> <key>IS_ANALYTICS_ENABLED</key>
@@ -29,6 +31,6 @@
<key>IS_SIGNIN_ENABLED</key> <key>IS_SIGNIN_ENABLED</key>
<true></true> <true></true>
<key>GOOGLE_APP_ID</key> <key>GOOGLE_APP_ID</key>
<string>1:943006074419:ios:5deb4cce66c04c09658546</string> <string>1:305598753437:ios:7942793c4093dedff28274</string>
</dict> </dict>
</plist> </plist>
+9 -1
View File
@@ -37,6 +37,14 @@
{ {
"client_id": "305598753437-dtjknrpfrjt3bdd7838nga41m5i4emva.apps.googleusercontent.com", "client_id": "305598753437-dtjknrpfrjt3bdd7838nga41m5i4emva.apps.googleusercontent.com",
"client_type": 3 "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"
}
} }
] ]
} }
@@ -44,4 +52,4 @@
} }
], ],
"configuration_version": "1" "configuration_version": "1"
} }
-9
View File
@@ -25,14 +25,5 @@
"autoIncrement": "buildNumber" "autoIncrement": "buildNumber"
} }
} }
},
"submit": {
"production": {
"ios": {
"appleId": "letawny@gmail.com",
"ascAppId": "1661696886",
"appleTeamId": "MW8MX2T3ZL"
}
}
} }
} }
+3 -1
View File
@@ -1,3 +1,5 @@
exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y"; exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y";
// exports.SUNO_API_KEY = "543b5650b30d93e98d8046a830244c80"; client api key // 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_";
+746
View File
@@ -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
+44
View File
@@ -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;
+382 -274
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -24,6 +24,7 @@
"firebase-functions": "^6.4.0", "firebase-functions": "^6.4.0",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"genkit": "^1.18.0", "genkit": "^1.18.0",
"resend": "^6.3.0",
"sharp": "^0.33.4" "sharp": "^0.33.4"
}, },
"devDependencies": { "devDependencies": {
+60 -23
View File
@@ -5,11 +5,18 @@ const {
const admin = require("firebase-admin"); const admin = require("firebase-admin");
const { refList, ALERT_TYPE } = require("../index"); const { refList, ALERT_TYPE } = require("../index");
const { Expo } = require("expo-server-sdk"); 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 // Initialisation de Expo SDK
let expo = new Expo(); let expo = new Expo();
const db = admin.firestore(); const db = admin.firestore();
const EMAIL_FROM = "MusicLand <musicland@minuit.app>";
exports.sendNotificationWhenDocIsCreated = onDocumentCreated( exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
{ region: "europe-west1", document: "notifications/{notificationId}" }, { region: "europe-west1", document: "notifications/{notificationId}" },
async (event) => { async (event) => {
@@ -26,14 +33,18 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
throw new Error("Receiver and message are required"); throw new Error("Receiver and message are required");
} }
const userSnap = await db.collection(receiverCollection).doc(receiver).get(); const {
const { pushToken = null, pushTokens = [] } = userSnap?.data() || {}; pushToken = null,
pushTokens = [],
email = "",
emailNotifications = false,
} = (await db.collection(receiverCollection).doc(receiver).get()).data();
const tokensSet = new Set( const tokensSet = new Set(
[] []
.concat(Array.isArray(pushTokens) ? pushTokens : []) .concat(Array.isArray(pushTokens) ? pushTokens : [])
.concat(pushToken ? [pushToken] : []) .concat(pushToken ? [pushToken] : [])
.filter(Boolean) .filter(Boolean),
); );
const tokens = Array.from(tokensSet); const tokens = Array.from(tokensSet);
@@ -50,11 +61,31 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
message: message, message: message,
data: notifData || {}, 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) { } catch (e) {
console.log(e); console.log(e);
return e; return e;
} }
} },
); );
// Fonction pour envoyer la notification via Expo SDK // Fonction pour envoyer la notification via Expo SDK
@@ -144,7 +175,11 @@ async function sendExpoNotification({
} }
} }
async function removeInvalidTokens({ tokens = [], receiverId, receiverCollection }) { async function removeInvalidTokens({
tokens = [],
receiverId,
receiverCollection,
}) {
try { try {
if (!receiverId || !tokens.length) { if (!receiverId || !tokens.length) {
return; return;
@@ -170,7 +205,7 @@ async function removeInvalidTokens({ tokens = [], receiverId, receiverCollection
await docRef.set(updates, { merge: true }); await docRef.set(updates, { merge: true });
console.log( console.log(
"Pruned invalid push tokens", "Pruned invalid push tokens",
JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2) JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2),
); );
} catch (error) { } catch (error) {
console.log("Failed to prune invalid push tokens:", error); console.log("Failed to prune invalid push tokens:", error);
@@ -204,7 +239,7 @@ const sendNotification = async ({
const { id } = await refList.notifications.add(payload); const { id } = await refList.notifications.add(payload);
console.log( console.log(
"[sendNotification] Notification created", "[sendNotification] Notification created",
JSON.stringify({ id, receiver, receiverCollection }, null, 2) JSON.stringify({ id, receiver, receiverCollection }, null, 2),
); );
return id; return id;
@@ -224,7 +259,7 @@ exports.createProjectCommentNotification = onDocumentCreated(
try { try {
console.log( console.log(
"[createProjectCommentNotification] Trigger received", "[createProjectCommentNotification] Trigger received",
JSON.stringify(event.params || {}, null, 2) JSON.stringify(event.params || {}, null, 2),
); );
const { data: snap } = event; const { data: snap } = event;
const { projectId, commentId } = event.params || {}; const { projectId, commentId } = event.params || {};
@@ -233,21 +268,21 @@ exports.createProjectCommentNotification = onDocumentCreated(
if (!projectId || !comment) { if (!projectId || !comment) {
console.log( console.log(
"[createProjectCommentNotification] Missing project/comment data", "[createProjectCommentNotification] Missing project/comment data",
{ hasProjectId: !!projectId, hasComment: !!comment } { hasProjectId: !!projectId, hasComment: !!comment },
); );
return null; return null;
} }
console.log( console.log(
"[createProjectCommentNotification] Comment payload", "[createProjectCommentNotification] Comment payload",
JSON.stringify(comment, null, 2) JSON.stringify(comment, null, 2),
); );
const projectSnap = await refList.projects.doc(projectId).get(); const projectSnap = await refList.projects.doc(projectId).get();
if (!projectSnap.exists) { if (!projectSnap.exists) {
console.log( console.log(
"[createProjectCommentNotification] Project not found", "[createProjectCommentNotification] Project not found",
projectId projectId,
); );
return null; return null;
} }
@@ -258,7 +293,7 @@ exports.createProjectCommentNotification = onDocumentCreated(
if (!receiver || receiver === comment.userId) { if (!receiver || receiver === comment.userId) {
console.log( console.log(
"[createProjectCommentNotification] Invalid receiver", "[createProjectCommentNotification] Invalid receiver",
JSON.stringify({ receiver, commentUserId: comment.userId }) JSON.stringify({ receiver, commentUserId: comment.userId }),
); );
return null; return null;
} }
@@ -283,8 +318,8 @@ exports.createProjectCommentNotification = onDocumentCreated(
projectId, projectId,
}, },
null, null,
2 2,
) ),
); );
await sendNotification({ await sendNotification({
@@ -308,7 +343,7 @@ exports.createProjectCommentNotification = onDocumentCreated(
}); });
console.log( console.log(
"[createProjectCommentNotification] Notification creation complete" "[createProjectCommentNotification] Notification creation complete",
); );
return null; return null;
@@ -316,7 +351,7 @@ exports.createProjectCommentNotification = onDocumentCreated(
console.log("createProjectCommentNotification error:", error); console.log("createProjectCommentNotification error:", error);
return error; return error;
} }
} },
); );
exports.createProjectLikeNotification = onDocumentWritten( exports.createProjectLikeNotification = onDocumentWritten(
@@ -356,10 +391,10 @@ exports.createProjectLikeNotification = onDocumentWritten(
const beforePlaybackSet = new Set(beforePlaybackLikes); const beforePlaybackSet = new Set(beforePlaybackLikes);
const newSongLikers = afterSongLikes.filter( const newSongLikers = afterSongLikes.filter(
(uid) => uid && !beforeSongSet.has(uid) (uid) => uid && !beforeSongSet.has(uid),
); );
const newPlaybackLikers = afterPlaybackLikes.filter( const newPlaybackLikers = afterPlaybackLikes.filter(
(uid) => uid && !beforePlaybackSet.has(uid) (uid) => uid && !beforePlaybackSet.has(uid),
); );
const newLikers = []; const newLikers = [];
@@ -413,7 +448,7 @@ exports.createProjectLikeNotification = onDocumentWritten(
}); });
return null; return null;
}) }),
); );
return null; return null;
@@ -421,7 +456,7 @@ exports.createProjectLikeNotification = onDocumentWritten(
console.log("[createProjectLikeNotification] error:", error); console.log("[createProjectLikeNotification] error:", error);
return error; return error;
} }
} },
); );
exports.createNewFollowerNotification = onDocumentWritten( exports.createNewFollowerNotification = onDocumentWritten(
@@ -451,7 +486,9 @@ exports.createNewFollowerNotification = onDocumentWritten(
} }
const previousSet = new Set(beforeFollowers); const previousSet = new Set(beforeFollowers);
const newFollowers = afterFollowers.filter((uid) => !previousSet.has(uid)); const newFollowers = afterFollowers.filter(
(uid) => !previousSet.has(uid),
);
if (!newFollowers.length) { if (!newFollowers.length) {
return null; return null;
@@ -487,7 +524,7 @@ exports.createNewFollowerNotification = onDocumentWritten(
}); });
return null; return null;
}) }),
); );
return null; return null;
@@ -495,5 +532,5 @@ exports.createNewFollowerNotification = onDocumentWritten(
console.log("[createNewFollowerNotification] error:", error); console.log("[createNewFollowerNotification] error:", error);
return error; return error;
} }
} },
); );
+38 -1
View File
@@ -1,7 +1,44 @@
const admin = require("firebase-admin"); const admin = require("firebase-admin");
const { onDocumentDeleted } = require("firebase-functions/firestore"); const {
onDocumentDeleted,
onDocumentCreated,
} = require("firebase-functions/firestore");
const { refList } = require("../index"); const { refList } = require("../index");
const { deleteFolder } = require("../helpers/firebase"); 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) => { exports.onUserDelete = onDocumentDeleted("users/{userID}", async (event) => {
try { try {
+2 -1
View File
@@ -4,7 +4,7 @@
"scripts": { "scripts": {
"start": "expo start --dev-client", "start": "expo start --dev-client",
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios -d",
"web": "expo start --web", "web": "expo start --web",
"xcode": "open ios/musicland.xcworkspace", "xcode": "open ios/musicland.xcworkspace",
"clean": "npx expo prebuild --clean", "clean": "npx expo prebuild --clean",
@@ -35,6 +35,7 @@
"@stripe/stripe-js": "~3.0.0", "@stripe/stripe-js": "~3.0.0",
"deprecated-react-native-prop-types": "~3.0.1", "deprecated-react-native-prop-types": "~3.0.1",
"expo": "~52.0.47", "expo": "~52.0.47",
"expo-apple-authentication": "~7.1.3",
"expo-audio": "~0.3.5", "expo-audio": "~0.3.5",
"expo-auth-session": "~6.0.3", "expo-auth-session": "~6.0.3",
"expo-blur": "~14.0.3", "expo-blur": "~14.0.3",
@@ -8,7 +8,7 @@ import React, {
} from "react"; } from "react";
import { import {
Animated, Animated,
Image, ImageBackground,
Platform, Platform,
StyleSheet, StyleSheet,
View, View,
@@ -43,8 +43,6 @@ const STAGE_CARD_CONTENT = [
const WEB_SCROLL_INACTIVE_DELTA = 0.05; const WEB_SCROLL_INACTIVE_DELTA = 0.05;
const HOME_BACKGROUND_COLOR = "#425B87"; // Derived from the bottom color of the home hero image const HOME_BACKGROUND_COLOR = "#425B87"; // Derived from the bottom color of the home hero image
const AnimatedImage = Animated.createAnimatedComponent(Image);
const FeatureCarousel = ({ const FeatureCarousel = ({
style, style,
selectedProject, selectedProject,
@@ -117,16 +115,6 @@ const FeatureCarousel = ({
); );
const onActiveIndexChangeRef = useRef(onActiveIndexChange); const onActiveIndexChangeRef = useRef(onActiveIndexChange);
const scrollY = useRef(new Animated.Value(0)).current; 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(() => { useEffect(() => {
onActiveIndexChangeRef.current = onActiveIndexChange; onActiveIndexChangeRef.current = onActiveIndexChange;
@@ -319,10 +307,19 @@ const FeatureCarousel = ({
[updateSnapHeight] [updateSnapHeight]
); );
const hasBackgroundImage = !!backgroundImage;
const renderContent = useMemo( const renderContent = useMemo(
() => () =>
carouselItems.map((item, index) => { carouselItems.map((item, index) => (
const card = ( <View
key={item.key}
style={[
styles.slide,
hasBackgroundImage ? styles.slideTransparent : styles.slideColored,
itemHeight ? { height: itemHeight } : { minHeight: 1 },
]}
>
<View style={styles.cardWrapper}> <View style={styles.cardWrapper}>
<PersonaCard <PersonaCard
item={item} item={item}
@@ -331,55 +328,9 @@ const FeatureCarousel = ({
height={itemHeight} height={itemHeight}
/> />
</View> </View>
); </View>
)),
const containerStyle = [styles.backgroundItem, { height: itemHeight }]; [carouselItems, hasBackgroundImage, 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]
); );
const snapOffsets = useMemo(() => { const snapOffsets = useMemo(() => {
@@ -398,11 +349,39 @@ const FeatureCarousel = ({
[isWeb] [isWeb]
); );
const containerProps = hasBackgroundImage
? {
source: backgroundImage,
resizeMode: "cover",
imageStyle: styles.backgroundImage,
}
: {};
const ContainerComponent = hasBackgroundImage ? ImageBackground : View;
return ( return (
<View <ContainerComponent
style={[styles.container, isWeb && styles.containerWeb, style]} {...containerProps}
style={[
styles.container,
isWeb && styles.containerWeb,
hasBackgroundImage ? styles.containerTransparent : styles.containerColored,
style,
]}
onLayout={handleLayout} 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 <Animated.ScrollView
ref={scrollViewRef} ref={scrollViewRef}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
@@ -440,7 +419,7 @@ const FeatureCarousel = ({
baseDotSize={8} baseDotSize={8}
/> />
</BlurView> </BlurView>
</View> </ContainerComponent>
); );
}; };
@@ -451,37 +430,38 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
width: "100%", width: "100%",
flexDirection: "row", flexDirection: "row",
backgroundColor: HOME_BACKGROUND_COLOR,
}, },
containerWeb: { containerWeb: {
// paddingRight: 56, // paddingRight: 56,
}, },
containerColored: {
backgroundColor: HOME_BACKGROUND_COLOR,
},
containerTransparent: {
backgroundColor: "transparent",
},
list: { list: {
flex: 1, flex: 1,
}, },
scrollContent: { scrollContent: {
flexGrow: 1, flexGrow: 1,
}, },
backgroundItem: { backgroundImage: {
width: "100%", ...StyleSheet.absoluteFillObject,
justifyContent: "flex-start",
},
parallaxContainer: {
overflow: "hidden",
position: "relative",
backgroundColor: HOME_BACKGROUND_COLOR,
},
parallaxImage: {
position: "absolute",
left: 0,
right: 0,
}, },
backgroundGradient: { backgroundGradient: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
}, },
plainBackground: { slide: {
width: "100%",
justifyContent: "flex-start",
},
slideColored: {
backgroundColor: HOME_BACKGROUND_COLOR, backgroundColor: HOME_BACKGROUND_COLOR,
}, },
slideTransparent: {
backgroundColor: "transparent",
},
cardWrapper: { cardWrapper: {
flex: 1, flex: 1,
width: "60%", width: "60%",
+464
View File
@@ -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;
+11 -2
View File
@@ -2,20 +2,29 @@ import { Motion } from "@legendapp/motion";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import React, { useState } from "react"; import React, { useState } from "react";
import { Text, TouchableOpacity, View } from "react-native"; import { Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ItemContainer from "../components/ItemContainer/ItemContainer"; import ItemContainer from "../components/ItemContainer/ItemContainer";
import useLayoutType from "../hooks/useLayoutType"; import useLayoutType from "../hooks/useLayoutType";
import { responsiveHeight } from "../actions/responsiveSizes";
import { gutters, Palette, Style } from "../styles"; import { gutters, Palette, Style } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from "../styles/Fonts";
const TabBar = ({ state = {}, descriptors = {}, navigation = {} }) => { const TabBar = ({ state = {}, descriptors = {}, navigation = {} }) => {
const { isWeb } = useLayoutType(); const { isWeb, isIOS } = useLayoutType();
const [pressedIndex, setPressedIndex] = useState(null); 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 ( return (
<View <View
style={{ style={{
position: "absolute", position: "absolute",
bottom: isWeb ? gutters : 5, bottom: bottomOffset,
width: "90%", width: "90%",
maxWidth: 500, maxWidth: 500,
alignSelf: "center", alignSelf: "center",
+7 -1
View File
@@ -46,11 +46,17 @@ export default ({ navigation }) => {
type: "string", type: "string",
}, },
{ {
title: "Notifications", title: "Notifications push",
key: "notifications", key: "notifications",
value: !!currentUserData?.notifications, value: !!currentUserData?.notifications,
type: "boolean", type: "boolean",
}, },
{
title: "Notifications par email",
key: "emailNotifications",
value: !!currentUserData?.emailNotifications,
type: "boolean",
},
{ {
title: "Langue", title: "Langue",
key: "language", key: "language",
+19 -4
View File
@@ -371,13 +371,19 @@ const Home = ({ navigation, route }) => {
simpleRef: true, simpleRef: true,
}); });
const videoUrl = isWeb ? video?.landingWeb : video?.landing; const videoUrl = isWeb ? video?.landingWeb : video?.landing;
const homeBackgroundImage = isWeb
? background.homeBGWeb
: background.homeBG;
const landingBackgroundImage = homeBackgroundImage;
return adventureStarted ? ( return adventureStarted ? (
<Page <Page
shareBtn shareBtn
headerType="NONE" headerType="NONE"
containerStyle={{ width: "100%", maxWidth: "100%", paddingHorizontal: 0 }} containerStyle={{ width: "100%", maxWidth: "100%", paddingHorizontal: 0 }}
backgroundImg={homeBackgroundImage}
> >
<View style={styles.root}> <View style={[styles.root, isWeb ? styles.rootWeb : styles.rootNative]}>
<View <View
style={{ style={{
zIndex: 10, zIndex: 10,
@@ -418,7 +424,7 @@ const Home = ({ navigation, route }) => {
stageStates={stageStates} stageStates={stageStates}
activeIndex={activeStageIndex} activeIndex={activeStageIndex}
onActiveIndexChange={setActiveStageIndex} onActiveIndexChange={setActiveStageIndex}
backgroundImage={background.homeBGWeb} backgroundImage={homeBackgroundImage}
/> />
</View> </View>
<View <View
@@ -464,7 +470,11 @@ const Home = ({ navigation, route }) => {
</Page> </Page>
) : ( ) : (
<> <>
<Page shareBtn title="Landing Page" backgroundImg={background.homeBGWeb}> <Page
shareBtn
title="Landing Page"
backgroundImg={landingBackgroundImage}
>
<View <View
style={{ style={{
flex: 1, flex: 1,
@@ -493,7 +503,12 @@ export default Home;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
root: { root: {
flex: 1, flex: 1,
backgroundColor: "#425B87", },
rootWeb: {
backgroundColor: "transparent",
},
rootNative: {
backgroundColor: "rgba(66, 91, 135, 0.3)",
}, },
heroImage: { heroImage: {
position: "absolute", position: "absolute",
+21 -37
View File
@@ -1,13 +1,18 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { useCallback, useEffect, useState } from "react"; 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 { background } from "../assets";
import FullscreenIntroVideo from "../components/FullscreenIntroVideo"; import FullscreenIntroVideo from "../components/FullscreenIntroVideo";
import GradientButton from "../components/GradientButton"; import GradientButton from "../components/GradientButton";
import { usersRef, videosRef } from "../config/firebase"; import { videosRef } from "../config/firebase";
import useDataFromRef from "../hooks/useDataFromRef"; import useDataFromRef from "../hooks/useDataFromRef";
import { isWeb } from "../hooks/useLayoutType";
import Page from "../layouts/Page"; import Page from "../layouts/Page";
import { Routes } from "../navigation/Routes"; import { Routes } from "../navigation/Routes";
import { useUser } from "../providers/UserDataProvider"; import { useUser } from "../providers/UserDataProvider";
@@ -19,13 +24,9 @@ const LANGUAGE_STORAGE_KEY = "preferredLanguage";
export default function LandingPage() { export default function LandingPage() {
const navigation = useNavigation(); const navigation = useNavigation();
const { currentUID } = useUser(); const { currentUID } = useUser();
const [isVideoVisible, setIsVideoVisible] = useState(false); const [videoUrl, setVideoUrl] = useState(null);
const [selectedLanguage, setSelectedLanguage] = useState(null); const [selectedLanguage, setSelectedLanguage] = useState(null);
const handleStartVisit = useCallback(() => {
setIsVideoVisible(true);
}, []);
const handleSelectLanguage = useCallback((language) => { const handleSelectLanguage = useCallback((language) => {
setSelectedLanguage(language); setSelectedLanguage(language);
AsyncStorage.setItem(LANGUAGE_STORAGE_KEY, language).catch((error) => { 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(() => { const handleLaunchAdventure = useCallback(() => {
navigation.navigate(Routes.Register); navigation.navigate(Routes.Register);
}, [navigation]); }, [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({ const { data: video } = useDataFromRef({
ref: videosRef.doc("fr"), ref: videosRef.doc("fr"),
simpleRef: true, simpleRef: true,
@@ -118,12 +96,18 @@ export default function LandingPage() {
<View style={styles.actions}> <View style={styles.actions}>
<GradientButton <GradientButton
title="Découvrir Musicland" title="Découvrir Musicland"
onPress={handleStartVisit} onPress={() => setVideoUrl(video?.introWeb)}
containerStyle={styles.actionButton} containerStyle={styles.actionButton}
/> />
<GradientButton <GradientButton
title="Accueil avec Sasha" title="Présentation de Musicland"
onPress={handleSashaHome} onPress={() =>
setVideoUrl(
Platform.OS === "web"
? video?.landingWeb
: video?.landing,
)
}
containerStyle={styles.actionButton} containerStyle={styles.actionButton}
/> />
<GradientButton <GradientButton
@@ -137,9 +121,9 @@ export default function LandingPage() {
</View> </View>
</Page> </Page>
<FullscreenIntroVideo <FullscreenIntroVideo
url={isWeb ? video?.landingWeb : video?.landing} url={videoUrl}
visible={isVideoVisible} visible={!!videoUrl}
onClose={handleVideoClose} onClose={() => setVideoUrl(null)}
/> />
</> </>
); );
+100 -233
View File
@@ -1,18 +1,7 @@
/* eslint-disable react/display-name */ /* eslint-disable react/display-name */
import { GoogleSigninButton } from "@react-native-google-signin/google-signin"; import * as AppleAuthentication from "expo-apple-authentication";
import * as AuthSession from "expo-auth-session"; import React, { useCallback, useState } from "react";
import * as GoogleAuth from "expo-auth-session/providers/google"; import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
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 Svg, { Path } from "react-native-svg"; import Svg, { Path } from "react-native-svg";
import { useGlobal } from "reactn"; import { useGlobal } from "reactn";
import { handleFirebaseError } from "../actions/signupActions.js"; import { handleFirebaseError } from "../actions/signupActions.js";
@@ -21,11 +10,7 @@ import GradientButton from "../components/GradientButton.js";
import { Input } from "../components/Input.js"; import { Input } from "../components/Input.js";
import ItemContainer from "../components/ItemContainer/ItemContainer"; import ItemContainer from "../components/ItemContainer/ItemContainer";
import firebase from "../config/firebase"; import firebase from "../config/firebase";
import { import useSocialAuth from "../hooks/useSocialAuth";
GOOGLE_ANDROID_CLIENT_ID,
GOOGLE_IOS_CLIENT_ID,
GOOGLE_WEB_CLIENT_ID,
} from "../data/keys";
import { isWeb } from "../hooks/useLayoutType"; import { isWeb } from "../hooks/useLayoutType";
import Page from "../layouts/Page.js"; import Page from "../layouts/Page.js";
import { Routes } from "../navigation"; import { Routes } from "../navigation";
@@ -34,37 +19,26 @@ import { FONT_FAMILY } from "../styles/Fonts.js";
import Palette from "../styles/Palette.js"; import Palette from "../styles/Palette.js";
export default ({ navigation }) => { export default ({ navigation }) => {
WebBrowser.maybeCompleteAuthSession();
const [, setTooltip] = useGlobal("_tooltip"); const [, setTooltip] = useGlobal("_tooltip");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Let the provider compute a compliant redirect URI for native (com.googleusercontent.apps.<client-id>:/oauth2redirect) const afterLoginNavigate = useCallback(async () => {
// 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 uid = firebase.auth().currentUser?.uid; const uid = firebase.auth().currentUser?.uid;
if (!uid) throw new Error("Aucun utilisateur après connexion"); if (!uid) throw new Error("Aucun utilisateur après connexion");
navigation.reset({ index: 0, routes: [{ name: Routes.BottomTab }] }); 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 () => { const onLogin = async () => {
try { 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 ( return (
<Page <Page
width={isWeb ? 600 : null} width={isWeb ? 600 : null}
@@ -241,97 +111,95 @@ export default ({ navigation }) => {
<GradientButton <GradientButton
title="Se connecter" title="Se connecter"
containerStyle={{ containerStyle={{
width: "80%", width: "90%",
alignSelf: "center", alignSelf: "center",
}} }}
onPress={onLogin} onPress={onLogin}
disabled={loading || !email || !password} disabled={isBusy || !email || !password}
/> />
<GoogleAuthButton <View style={styles.socialWrapper}>
onPress={onLoginWithGoogle} {isAppleSignInAvailable ? (
disabled={loading} <AppleAuthButton
loading={loading} onPress={signInWithApple}
/> disabled={isBusy}
loading={socialLoading}
<View style={styles.signupWrapper}> />
<Text style={styles.signupPrompt}>Pas encore de compte ?</Text> ) : null}
<Pressable <GoogleAuthButton
onPress={() => navigate(Routes.Register)} onPress={signInWithGoogle}
style={({ pressed }) => [ disabled={isBusy}
styles.signupPressable, loading={socialLoading}
pressed && styles.linkPressed, />
]}
>
<Text style={styles.signupLink}>Créer un compte</Text>
</Pressable>
</View> </View>
</View> </View>
</ItemContainer> </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> </View>
</Page> </Page>
); );
}; };
const GoogleAuthButton = ({ onPress, disabled, loading }) => { const AppleAuthButton = ({ onPress, disabled, loading }) => {
if (isWeb) { if (isWeb) {
return ( return null;
<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 ( return (
<View style={styles.nativeButtonWrapper}> <View style={styles.nativeButtonWrapper}>
<GoogleSigninButton <AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={
AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
}
style={styles.nativeButton} style={styles.nativeButton}
size={GoogleSigninButton.Size.Wide} cornerRadius={12}
color={GoogleSigninButton.Color.Dark}
onPress={onPress} onPress={onPress}
disabled={disabled} disabled={disabled}
/> />
{loading ? ( {loading ? (
<View style={styles.nativeLoadingOverlay}> <View style={styles.nativeLoadingOverlay}>
<ActivityIndicator size="small" color={Palette.white} /> <ActivityIndicator size="small" color={Palette.black} />
</View> </View>
) : null} ) : null}
</View> </View>
); );
}; };
const getNativeGoogleRedirectUri = () => { const GoogleAuthButton = ({ onPress, disabled, loading }) => (
const clientId = Platform.select({ <Pressable
ios: GOOGLE_IOS_CLIENT_ID, accessibilityRole="button"
android: GOOGLE_ANDROID_CLIENT_ID, onPress={onPress}
default: null, disabled={disabled}
}); style={({ pressed }) => [
styles.socialButton,
if (!clientId || !clientId.includes(".apps.googleusercontent.com")) { pressed && !disabled ? styles.socialButtonPressed : null,
return undefined; disabled ? styles.socialButtonDisabled : null,
} ]}
>
const clientPrefix = clientId.replace(".apps.googleusercontent.com", ""); <View style={styles.socialButtonContent}>
return `com.googleusercontent.apps.${clientPrefix}:/oauth2redirect`; <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({ const styles = StyleSheet.create({
screen: { screen: {
@@ -347,6 +215,7 @@ const styles = StyleSheet.create({
cardContent: { cardContent: {
gap: 28, gap: 28,
paddingTop: 8, paddingTop: 8,
paddingHorizontal: 5,
width: "100%", width: "100%",
}, },
greetingSection: { greetingSection: {
@@ -384,31 +253,29 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterRegular, fontFamily: FONT_FAMILY.InterRegular,
textAlign: "right", textAlign: "right",
}, },
signupWrapper: { bottomFooter: {
flexDirection: "row", marginTop: 16,
flexWrap: "wrap", paddingHorizontal: 24,
alignItems: "center",
justifyContent: "center",
gap: 6,
}, },
signupPrompt: { bottomFooterText: {
fontSize: 14, fontSize: 14,
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular, fontFamily: FONT_FAMILY.HelveticaNeueRegular,
textAlign: "center",
}, },
signupPressable: { bottomFooterLink: {
paddingHorizontal: 2,
},
signupLink: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
color: Palette.white,
}, },
webButton: { socialWrapper: {
width: "100%",
gap: 16,
alignItems: "center",
},
socialButton: {
alignSelf: "center", alignSelf: "center",
borderRadius: 12, borderRadius: 12,
width: "80%", width: "90%",
backgroundColor: Palette.white, backgroundColor: Palette.white,
borderWidth: 1, borderWidth: 1,
borderColor: "#E3E7EF", borderColor: "#E3E7EF",
@@ -420,38 +287,38 @@ const styles = StyleSheet.create({
shadowRadius: 16, shadowRadius: 16,
elevation: 2, elevation: 2,
}, },
webButtonPressed: { socialButtonPressed: {
backgroundColor: "#F4F7FF", backgroundColor: "#F4F7FF",
}, },
webButtonDisabled: { socialButtonDisabled: {
opacity: 0.7, opacity: 0.7,
}, },
webContent: { socialButtonContent: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: 12, gap: 12,
}, },
webIconContainer: { socialIconContainer: {
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
width: 28, width: 28,
height: 28, height: 28,
}, },
webText: { socialButtonText: {
flex: 1, flex: 1,
textAlign: "center", textAlign: "center",
fontSize: 15, fontSize: 15,
color: "#202124", color: "#202124",
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
}, },
webRightSpacer: { socialRightSpacer: {
width: 24, width: 24,
height: 24, height: 24,
}, },
nativeButtonWrapper: { nativeButtonWrapper: {
alignSelf: "center", alignSelf: "center",
width: "80%", width: "90%",
height: 50, height: 50,
justifyContent: "center", justifyContent: "center",
}, },
+44 -9
View File
@@ -17,21 +17,28 @@ export default (props) => {
const [currentUserData] = useGlobal("currentUserData"); const [currentUserData] = useGlobal("currentUserData");
const [, setTooltip] = useGlobal("_tooltip"); const [, setTooltip] = useGlobal("_tooltip");
const [isSwitch, setIsSwitch] = useState(false); const [isPushSwitch, setIsPushSwitch] = useState(false);
const [isEmailSwitch, setIsEmailSwitch] = useState(false);
useEffect(() => { useEffect(() => {
setIsSwitch(!!currentUserData?.notifications); setIsPushSwitch(!!currentUserData?.notifications);
}, [currentUserData?.notifications]); }, [currentUserData?.notifications]);
const onToggle = async (next) => { useEffect(() => {
setIsEmailSwitch(!!currentUserData?.emailNotifications);
}, [currentUserData?.emailNotifications]);
const updateNotificationPreference = async (next, field, setState) => {
try { try {
setIsSwitch(next); setState(next);
const uid = currentUID || firebase.auth().currentUser?.uid; const uid = currentUID || firebase.auth().currentUser?.uid;
if (!uid) return; if (!uid) {
await usersRef.doc(uid).set({ notifications: next }, { merge: true }); throw new Error("Utilisateur non connecté");
}
await usersRef.doc(uid).set({ [field]: next }, { merge: true });
setTooltip({ type: "success", text: "Préférence enregistrée" }); setTooltip({ type: "success", text: "Préférence enregistrée" });
} catch (e) { } catch (e) {
setIsSwitch(!next); setState(!next);
setTooltip({ setTooltip({
type: "error", type: "error",
text: e?.message || "Enregistrement impossible", 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 ( return (
<Page <Page
headerType="NAVIGATE" headerType="NAVIGATE"
@@ -74,9 +87,9 @@ export default (props) => {
fontFamily: FONT_FAMILY.HelveticaNeueMedium, fontFamily: FONT_FAMILY.HelveticaNeueMedium,
}} }}
> >
Notifications Notifications push
</Text> </Text>
<Switch value={isSwitch} setValue={onToggle} /> <Switch value={isPushSwitch} setValue={onTogglePush} />
</View> </View>
<Text <Text
style={{ style={{
@@ -88,6 +101,28 @@ export default (props) => {
Nous tencourageons à activer les notifications pour découvrir les Nous tencourageons à activer les notifications pour découvrir les
derniers classements, les nouveaux sons et les meilleurs playbacks. derniers classements, les nouveaux sons et les meilleurs playbacks.
</Text> </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> </BlurView>
</View> </View>
</Page> </Page>
+345 -104
View File
@@ -1,23 +1,32 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; 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 { import {
ActivityIndicator,
FlatList, FlatList,
KeyboardAvoidingView,
Modal, Modal,
Platform,
Pressable, Pressable,
StyleSheet, StyleSheet,
Text, Text,
TextInput, TextInput,
View, View,
} from "react-native"; } 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 { background } from "../assets";
import BorderGradientButton from "../components/BorderGradientButton"; import BorderGradientButton from "../components/BorderGradientButton";
import { Input } from "../components/Input"; import { Input } from "../components/Input";
import ItemContainer from "../components/ItemContainer/ItemContainer"; import ItemContainer from "../components/ItemContainer/ItemContainer";
import COUNTRIES from "../constants/countries"; import COUNTRIES from "../constants/countries";
import firebase from "../config/firebase";
import { isWeb } from "../hooks/useLayoutType"; import { isWeb } from "../hooks/useLayoutType";
import useSocialAuth from "../hooks/useSocialAuth";
import Page from "../layouts/Page"; import Page from "../layouts/Page";
import { Routes } from "../navigation"; import { Routes } from "../navigation";
import { navigate } from "../navigation/NavigationService"; import { navigate, reset } from "../navigation/NavigationService";
import { Palette } from "../styles"; import { Palette } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts"; import { FONT_FAMILY } from "../styles/Fonts";
@@ -33,6 +42,23 @@ const Register = () => {
const [isCountryModalVisible, setIsCountryModalVisible] = useState(false); const [isCountryModalVisible, setIsCountryModalVisible] = useState(false);
const [preferredLanguage, setPreferredLanguage] = useState(null); 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(() => { useEffect(() => {
AsyncStorage.getItem(LANGUAGE_STORAGE_KEY) AsyncStorage.getItem(LANGUAGE_STORAGE_KEY)
.then((storedLanguage) => { .then((storedLanguage) => {
@@ -51,7 +77,7 @@ const Register = () => {
return COUNTRIES; return COUNTRIES;
} }
return COUNTRIES.filter((country) => return COUNTRIES.filter((country) =>
country.name.toLowerCase().includes(query) country.name.toLowerCase().includes(query),
); );
}, [countrySearch]); }, [countrySearch]);
@@ -73,6 +99,118 @@ const Register = () => {
setCountrySearch(""); setCountrySearch("");
}; };
const renderFormContent = () => (
<View style={styles.formContent}>
<Text style={styles.title}>Renseigne tes informations</Text>
{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
placeholder="Prénom"
label="Prénom"
isBlur
value={firstName}
setValue={setFirstName}
/>
<Input
placeholder="Nom"
label="Nom"
isBlur
value={lastName}
setValue={setLastName}
/>
<View style={styles.countryField}>
<Text style={styles.inputLabel}>Pays</Text>
<BlurView
tint="dark"
style={[
styles.countryBlurWrapper,
selectedCountry ? styles.countryButtonFilled : null,
]}
>
<Pressable
style={({ pressed }) => [
styles.countryButton,
pressed ? styles.countryButtonPressed : null,
]}
onPress={() => setIsCountryModalVisible(true)}
>
<Text
style={[
styles.countryButtonText,
!selectedCountry && styles.countryButtonPlaceholder,
]}
>
{selectedCountry ? selectedCountry.name : "Choisis ton pays"}
</Text>
</Pressable>
</BlurView>
</View>
<Input
placeholder="Ville"
label="Ville"
isBlur
value={city}
setValue={setCity}
/>
</View>
<Input
placeholder="Adresse mail"
label="Adresse mail"
isBlur
type="email"
value={email}
setValue={setEmail}
/>
</View>
<BorderGradientButton
title="Créer mon compte"
containerStyle={{
width: "90%",
alignSelf: "center",
}}
onPress={() =>
navigate(Routes.CreatePassword, {
email: email.trim(),
firstName: firstName.trim(),
lastName: lastName.trim(),
city: city.trim(),
country: selectedCountry,
preferredLanguage,
})
}
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 tinscrivant, tu acceptes nos conditions générales{"\n"}
dutilisation et notre politique de confidentialité.
</Text>
</View>
</View>
);
return ( return (
<Page <Page
width={isWeb ? 600 : null} width={isWeb ? 600 : null}
@@ -81,103 +219,39 @@ const Register = () => {
title="Inscription" title="Inscription"
> >
<View style={styles.pageContent}> <View style={styles.pageContent}>
<ItemContainer height={680} disableKeyboardHeight> <ItemContainer
<View style={styles.formContent}> height={isWeb ? 720 : undefined}
<Text style={styles.title}>Renseigne tes informations</Text> disableKeyboardHeight
{preferredLanguage && ( style={!isWeb ? styles.mobileItemContainer : styles.webItemContainer}
<Text style={styles.languageInfo}> >
Langue choisie :{" "} {isWeb ? (
<Text style={styles.languageInfoValue}> renderFormContent()
{preferredLanguage === "fr" ? "Français" : "English"} ) : (
</Text> <KeyboardAvoidingView
</Text> behavior={Platform.OS === "ios" ? "padding" : "height"}
)} style={styles.mobileAvoider}
<View style={{ gap: 16 }}> >
<View style={{ gap: 12 }}> <KeyboardAwareScrollView
<Input contentContainerStyle={styles.mobileScrollContent}
placeholder="Prénom" showsVerticalScrollIndicator={false}
label="Prénom" keyboardShouldPersistTaps="handled"
isBlur >
value={firstName} {renderFormContent()}
setValue={setFirstName} </KeyboardAwareScrollView>
/> </KeyboardAvoidingView>
<Input )}
placeholder="Nom"
label="Nom"
isBlur
value={lastName}
setValue={setLastName}
/>
<View style={styles.countryField}>
<Text style={styles.inputLabel}>Pays</Text>
<Pressable
style={[
styles.countryButton,
selectedCountry ? styles.countryButtonFilled : null,
]}
onPress={() => setIsCountryModalVisible(true)}
>
<Text
style={[
styles.countryButtonText,
!selectedCountry && styles.countryButtonPlaceholder,
]}
>
{selectedCountry
? selectedCountry.name
: "Choisis ton pays"}
</Text>
</Pressable>
</View>
<Input
placeholder="Ville"
label="Ville"
isBlur
value={city}
setValue={setCity}
/>
</View>
<Input
placeholder="Adresse mail"
label="Adresse mail"
isBlur
type="email"
value={email}
setValue={setEmail}
/>
</View>
<BorderGradientButton
title="Créer mon compte"
containerStyle={{
width: "80%",
alignSelf: "center",
}}
onPress={() =>
navigate(Routes.CreatePassword, {
email: email.trim(),
firstName: firstName.trim(),
lastName: lastName.trim(),
city: city.trim(),
country: selectedCountry,
preferredLanguage,
})
}
disabled={!isFormValid}
/>
<View style={styles.footer}>
<Text style={styles.terms}>
En tinscrivant, tu acceptes nos conditions générales{"\n"}
dutilisation 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>
</ItemContainer> </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> </View>
<Modal <Modal
transparent 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; export default Register;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@@ -232,6 +376,22 @@ const styles = StyleSheet.create({
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
webItemContainer: {
width: "100%",
maxWidth: 680,
},
mobileItemContainer: {
width: "100%",
minHeight: 720,
},
mobileAvoider: {
flex: 1,
},
mobileScrollContent: {
flexGrow: 1,
justifyContent: "center",
paddingVertical: 24,
},
formContent: { formContent: {
gap: 32, gap: 32,
paddingTop: 5, paddingTop: 5,
@@ -251,17 +411,19 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
}, },
countryButton: { countryBlurWrapper: {
borderRadius: 12, borderRadius: 12,
borderWidth: 1,
borderColor: Palette.ultraLightWhite,
backgroundColor: Palette.glass, backgroundColor: Palette.glass,
overflow: "hidden",
},
countryButton: {
minHeight: 50, minHeight: 50,
justifyContent: "center", justifyContent: "center",
paddingHorizontal: 16, paddingHorizontal: 16,
width: "100%",
}, },
countryButtonFilled: { countryButtonPressed: {
borderColor: Palette.primary, opacity: 0.85,
}, },
countryButtonText: { countryButtonText: {
fontSize: 14, fontSize: 14,
@@ -297,6 +459,85 @@ const styles = StyleSheet.create({
loginLink: { loginLink: {
fontFamily: FONT_FAMILY.InterSemiBold, 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: { modalOverlay: {
flex: 1, flex: 1,
backgroundColor: Palette.transparentBlack, backgroundColor: Palette.transparentBlack,
+189
View File
@@ -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&apos;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,
},
});
+124
View File
@@ -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;
+389
View File
@@ -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 nest 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;