feat: refine creation and registration flows
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 864 KiB |
@@ -0,0 +1,46 @@
|
||||
# Design QA — Pochette MusicLand Playbacker
|
||||
|
||||
- Source visual truth: `/Users/sacha/Library/Containers/net.whatsapp.WhatsApp/Data/tmp/documents/AC89FF71-250F-4949-9D82-F89EE91CF735/PHOTO-2026-07-28-13-57-44.jpg`
|
||||
- Supplied base asset: `/Users/sacha/Projets/MusicLand/src/assets/UI/musiclandPlaybackCover.png`
|
||||
- Implementation screenshot: `/Users/sacha/Projets/MusicLand/design-qa-assets/musicland-cover-preview.png`
|
||||
- Side-by-side comparison: `/Users/sacha/Projets/MusicLand/design-qa-assets/musicland-cover-comparison.png`
|
||||
- State: deterministic cover with a profile picture, artist name `username`, and `PLAYBACKER`
|
||||
- Viewport / output: square cover at 1024 × 1024 px
|
||||
- Density normalization: the 894 × 890 px source photo was cropped to its 661 × 661 px cover region, then normalized to 1024 × 1024 px. The implementation output is native 1024 × 1024 px at density 1.
|
||||
|
||||
## Full-view comparison evidence
|
||||
|
||||
The comparison places the normalized source on the left and the deterministic implementation on the right. The MusicLand artwork, central profile-photo placement, artist-name hierarchy, `PLAYBACKER` label, rounded corners, palette, and square composition align with the supplied target.
|
||||
|
||||
## Focused-region comparison evidence
|
||||
|
||||
The full cover is the focused component for this request, so a separate crop was not needed. The critical regions remain clearly readable at 1024 px:
|
||||
|
||||
- top MusicLand brand;
|
||||
- central circular profile picture inside the supplied ring;
|
||||
- artist name below the profile picture;
|
||||
- `PLAYBACKER` label at the bottom.
|
||||
|
||||
## Required fidelity surfaces
|
||||
|
||||
- Fonts and typography: white, light sans-serif text matches the reference hierarchy. Long artist names use a smaller server-side size and the app preview scales the text to one line.
|
||||
- Spacing and layout rhythm: the profile, artist name, and bottom label follow the same vertical order and approximate positions as the source.
|
||||
- Colors and visual tokens: the supplied blue-purple-pink artwork is reused directly without recoloring or recreated gradients.
|
||||
- Image quality and asset fidelity: the supplied PNG is used as the source asset. The final server output is rendered at 1024 × 1024 px. The profile image is center-cropped and circularly masked.
|
||||
- Copy and content: `MUSICLAND`, the selected artist name, and `PLAYBACKER` are the only cover content.
|
||||
|
||||
## Findings
|
||||
|
||||
No actionable P0, P1, or P2 visual mismatch remains.
|
||||
|
||||
## Comparison history
|
||||
|
||||
1. Initial output placed the artist name and `PLAYBACKER` too high and too large.
|
||||
2. Font sizes and vertical positions were adjusted.
|
||||
3. The revised side-by-side comparison shows the intended hierarchy and spacing.
|
||||
|
||||
## Residual test gaps
|
||||
|
||||
The in-app browser blocked local `localhost` navigation during QA, so the surrounding step-2 screen could not be captured there. The web bundle completed successfully, and the actual deterministic cover renderer was executed locally to produce the implementation evidence above.
|
||||
|
||||
final result: passed
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
+189
-261
@@ -5,261 +5,201 @@ const logger = require('firebase-functions/logger')
|
||||
const axios = require('axios')
|
||||
const sharp = require('sharp')
|
||||
const crypto = require('crypto')
|
||||
const path = require('path')
|
||||
|
||||
// Imports internes
|
||||
const { GEMINI_API_KEY } = require('../config/secrets')
|
||||
const { generateImageV2 } = require('../helpers/gemini')
|
||||
const { generatePicturePrompt } = require('../helpers/prompts')
|
||||
const { ALERT_TYPE, refList } = require('../index')
|
||||
const { sendNotification } = require('./notifications')
|
||||
|
||||
// Configuration
|
||||
const bucket = admin.storage().bucket()
|
||||
const BRAND_TEXT = 'By Musicland.ai'
|
||||
const BRAND_FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif'
|
||||
const BRAND_BG_COLOR = 'rgba(0,0,0,0.65)'
|
||||
const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER'
|
||||
const COVER_OPTION_ID = 'musicland-playbacker'
|
||||
const COVER_SIZE = 1024
|
||||
const PROFILE_SIZE = 228
|
||||
const TEMPLATE_PATH = path.join(__dirname, '../assets/musiclandPlaybackCover.png')
|
||||
const FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif'
|
||||
|
||||
const escapeSvgText = (value = '') =>
|
||||
String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
const buildBrandSvg = ({ width = 1024, height = 1024, text = BRAND_TEXT } = {}) => {
|
||||
const safeWidth = Math.max(320, Math.round(width))
|
||||
const safeHeight = Math.max(320, Math.round(height))
|
||||
const margin = Math.round(safeWidth * 0.04)
|
||||
const fontSize = Math.max(22, Math.round(safeWidth * 0.045))
|
||||
const letterSpacing = Math.max(1, Math.round(fontSize * 0.06))
|
||||
const strokeWidth = Math.max(2, Math.round(fontSize * 0.08))
|
||||
const shadowDy = Math.max(2, Math.round(fontSize * 0.08))
|
||||
const shadowBlur = Math.max(4, Math.round(fontSize * 0.18))
|
||||
const paddingX = Math.max(10, Math.round(fontSize * 0.7))
|
||||
const paddingY = Math.max(6, Math.round(fontSize * 0.4))
|
||||
const approxCharWidth = fontSize * 0.62
|
||||
const safeText = escapeSvgText(text)
|
||||
const textLength = safeText.length
|
||||
const textWidth =
|
||||
approxCharWidth * textLength + letterSpacing * Math.max(0, textLength - 1)
|
||||
const rectWidth = Math.max(0, Math.round(textWidth + paddingX * 2))
|
||||
const rectHeight = Math.round(fontSize + paddingY * 2)
|
||||
const rectX = Math.max(0, Math.round(safeWidth - margin - rectWidth))
|
||||
const rectY = Math.max(0, Math.round(safeHeight - margin - rectHeight))
|
||||
const textX = rectX + rectWidth / 2
|
||||
const baselineShift = Math.round(fontSize * 0.3)
|
||||
const textY = rectY + rectHeight / 2 + baselineShift
|
||||
const rectRadius = Math.max(6, Math.round(rectHeight * 0.35))
|
||||
const normalizeArtistName = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return 'MusicLand'
|
||||
}
|
||||
const trimmedValue = value.trim()
|
||||
return trimmedValue || 'MusicLand'
|
||||
}
|
||||
|
||||
const getArtistFontSize = (artistName) => {
|
||||
if (artistName.length <= 16) return 52
|
||||
if (artistName.length <= 24) return 44
|
||||
return 36
|
||||
}
|
||||
|
||||
const buildTextOverlay = (artistName) => {
|
||||
const safeArtistName = escapeSvgText(artistName)
|
||||
const artistFontSize = getArtistFontSize(artistName)
|
||||
|
||||
return Buffer.from(`
|
||||
<svg width="${safeWidth}" height="${safeHeight}" viewBox="0 0 ${safeWidth} ${safeHeight}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="brandShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="${shadowDy}" stdDeviation="${shadowBlur}" flood-color="rgba(0,0,0,0.55)" />
|
||||
</filter>
|
||||
</defs>
|
||||
<rect
|
||||
x="${rectX}"
|
||||
y="${rectY}"
|
||||
width="${rectWidth}"
|
||||
height="${rectHeight}"
|
||||
rx="${rectRadius}"
|
||||
ry="${rectRadius}"
|
||||
fill="${BRAND_BG_COLOR}"
|
||||
/>
|
||||
<svg width="${COVER_SIZE}" height="${COVER_SIZE}" viewBox="0 0 ${COVER_SIZE} ${COVER_SIZE}" xmlns="http://www.w3.org/2000/svg">
|
||||
<text
|
||||
x="${textX}"
|
||||
y="${textY}"
|
||||
x="512"
|
||||
y="824"
|
||||
text-anchor="middle"
|
||||
font-family="${BRAND_FONT_FAMILY}"
|
||||
font-size="${fontSize}"
|
||||
font-style="italic"
|
||||
font-weight="700"
|
||||
letter-spacing="${letterSpacing}"
|
||||
fill="rgba(255,255,255,0.95)"
|
||||
stroke="rgba(0,0,0,0.45)"
|
||||
stroke-width="${strokeWidth}"
|
||||
paint-order="stroke"
|
||||
filter="url(#brandShadow)"
|
||||
>${safeText}</text>
|
||||
dominant-baseline="middle"
|
||||
font-family="${FONT_FAMILY}"
|
||||
font-size="${artistFontSize}"
|
||||
font-weight="400"
|
||||
letter-spacing="3"
|
||||
fill="#FFFFFF"
|
||||
>${safeArtistName}</text>
|
||||
<text
|
||||
x="512"
|
||||
y="963"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
font-family="${FONT_FAMILY}"
|
||||
font-size="54"
|
||||
font-weight="400"
|
||||
letter-spacing="5"
|
||||
fill="#FFFFFF"
|
||||
>PLAYBACKER</text>
|
||||
</svg>`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilitaires Strings
|
||||
*/
|
||||
const pickFirstNonEmpty = (...values) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value.trim()
|
||||
const buildCircularMask = (size) => {
|
||||
const mask = Buffer.alloc(size * size * 4)
|
||||
const radius = size / 2
|
||||
const center = radius - 0.5
|
||||
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const offset = (y * size + x) * 4
|
||||
const distance = Math.sqrt((x - center) ** 2 + (y - center) ** 2)
|
||||
const alpha = distance <= radius ? 255 : 0
|
||||
mask[offset] = 255
|
||||
mask[offset + 1] = 255
|
||||
mask[offset + 2] = 255
|
||||
mask[offset + 3] = alpha
|
||||
}
|
||||
}
|
||||
return ''
|
||||
|
||||
return mask
|
||||
}
|
||||
|
||||
const combineNames = (...parts) =>
|
||||
parts
|
||||
.map((part) => (typeof part === 'string' ? part.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim()
|
||||
async function loadCoverIdentity(project) {
|
||||
const artistName = normalizeArtistName(project.userName)
|
||||
const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
|
||||
|
||||
/**
|
||||
* Résolution intelligente du nom d'artiste
|
||||
*/
|
||||
async function resolveArtistName(project = {}) {
|
||||
// 1. Vérification directe sur le projet ou le snapshot "owner"
|
||||
const owner = project?.owner || {}
|
||||
const direct = pickFirstNonEmpty(
|
||||
project?.artistName,
|
||||
project?.userName,
|
||||
owner?.artistName,
|
||||
owner?.userName,
|
||||
owner?.displayName
|
||||
)
|
||||
if (direct) return direct
|
||||
|
||||
// 2. Fallback : Récupération depuis la collection Users
|
||||
const userId = typeof project?.userId === 'string' ? project.userId.trim() : ''
|
||||
if (!userId) return ''
|
||||
if (!userId) {
|
||||
return { artistName, profilePictureURL: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const userSnapshot = await refList.users.doc(userId).get()
|
||||
if (!userSnapshot?.exists) return ''
|
||||
|
||||
const userData = userSnapshot.data() || {}
|
||||
return (
|
||||
pickFirstNonEmpty(
|
||||
userData.artistName,
|
||||
userData.userName,
|
||||
userData.displayName,
|
||||
combineNames(userData.firstName, userData.lastName)
|
||||
) || ''
|
||||
)
|
||||
const userData = userSnapshot.exists ? userSnapshot.data() : null
|
||||
return {
|
||||
artistName: normalizeArtistName(project.userName || userData?.userName),
|
||||
profilePictureURL: userData?.profilePictureURL || null,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ [Cover] Artist name resolution failed', {
|
||||
projectId: project?.id,
|
||||
logger.warn('[Cover] Unable to load profile picture', {
|
||||
projectId: project.id,
|
||||
error: error.message,
|
||||
})
|
||||
return ''
|
||||
return { artistName, profilePictureURL: null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute le texte de marque en filigrane sur l'image générée
|
||||
*/
|
||||
async function buildCoverWithBrandText(backgroundUrl, targetPath) {
|
||||
logger.info('🖼️ [Cover] Compositing brand text...')
|
||||
async function buildProfileOverlay(profilePictureURL) {
|
||||
if (!profilePictureURL) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// Téléchargement background
|
||||
const bgResponse = await axios.get(backgroundUrl, { responseType: 'arraybuffer' })
|
||||
|
||||
const baseImage = sharp(bgResponse.data)
|
||||
const metadata = await baseImage.metadata()
|
||||
const width = metadata.width || 1024
|
||||
const height = metadata.height || 1024
|
||||
const brandOverlay = buildBrandSvg({ width, height, text: BRAND_TEXT })
|
||||
|
||||
// Composition
|
||||
const stampedBuffer = await baseImage
|
||||
const response = await axios.get(profilePictureURL, { responseType: 'arraybuffer' })
|
||||
const circularMask = buildCircularMask(PROFILE_SIZE)
|
||||
return await sharp(response.data)
|
||||
.resize(PROFILE_SIZE, PROFILE_SIZE, { fit: 'cover', position: 'centre' })
|
||||
.ensureAlpha()
|
||||
.composite([{ input: brandOverlay, left: 0, top: 0, blend: 'over' }])
|
||||
.composite([
|
||||
{
|
||||
input: circularMask,
|
||||
raw: {
|
||||
width: PROFILE_SIZE,
|
||||
height: PROFILE_SIZE,
|
||||
channels: 4,
|
||||
},
|
||||
blend: 'dest-in',
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
// Upload vers Storage
|
||||
const token = crypto.randomUUID()
|
||||
const file = bucket.file(targetPath)
|
||||
|
||||
await file.save(stampedBuffer, {
|
||||
resumable: false,
|
||||
metadata: {
|
||||
contentType: 'image/png',
|
||||
cacheControl: 'public, max-age=31536000',
|
||||
metadata: { firebaseStorageDownloadTokens: token },
|
||||
},
|
||||
})
|
||||
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
|
||||
} catch (error) {
|
||||
logger.error('❌ [Cover] buildCoverWithBrandText failed', error)
|
||||
// En cas d'échec du texte, on renvoie l'URL originale pour ne pas tout perdre
|
||||
return backgroundUrl
|
||||
logger.warn('[Cover] Unable to load profile image, keeping default icon', {
|
||||
error: error.message,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cœur de la logique de génération
|
||||
*/
|
||||
async function performCoverGeneration(project) {
|
||||
const t0 = Date.now()
|
||||
const artistName = await resolveArtistName(project)
|
||||
async function uploadCover(buffer, targetPath) {
|
||||
const token = crypto.randomUUID()
|
||||
const file = bucket.file(targetPath)
|
||||
|
||||
const baseTimestamp = Date.now()
|
||||
const GENERATION_COUNT = 2 // Nombre de variantes simultanées
|
||||
|
||||
// Création d'un tableau de promesses pour exécuter les tâches en parallèle
|
||||
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(async (_, index) => {
|
||||
const uniqueSuffix = `${baseTimestamp}-${index}`
|
||||
const storageBasePath = `users/${project.userId}/projects/${project.id}`
|
||||
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`
|
||||
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`
|
||||
const prompt = generatePicturePrompt({
|
||||
...project,
|
||||
artistName,
|
||||
variantSeed: `${project.id || 'project'}-${uniqueSuffix}`,
|
||||
})
|
||||
|
||||
logger.info('🎨 [Cover] Prompt generated', {
|
||||
projectId: project.id,
|
||||
artistName,
|
||||
variant: uniqueSuffix,
|
||||
promptPreview: prompt.slice(0, 100) + '...',
|
||||
})
|
||||
|
||||
try {
|
||||
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
|
||||
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath)
|
||||
|
||||
if (!generatedUrl) throw new Error("URL vide retournée par l'IA")
|
||||
|
||||
// 2. Ajout du Logo
|
||||
const finalCoverUrl = await buildCoverWithBrandText(generatedUrl, stampedPath)
|
||||
|
||||
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`)
|
||||
|
||||
return {
|
||||
id: uniqueSuffix,
|
||||
generatedUrl,
|
||||
finalUrl: finalCoverUrl,
|
||||
promptUsed: prompt,
|
||||
}
|
||||
} catch (e) {
|
||||
// On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all
|
||||
logger.error(`❌ [Cover] Option ${index + 1} failed`, {
|
||||
error: e.message,
|
||||
})
|
||||
return null // On retourne null pour filtrer plus tard
|
||||
}
|
||||
await file.save(buffer, {
|
||||
resumable: false,
|
||||
metadata: {
|
||||
contentType: 'image/png',
|
||||
cacheControl: 'public, max-age=31536000',
|
||||
metadata: { firebaseStorageDownloadTokens: token },
|
||||
},
|
||||
})
|
||||
|
||||
// Attente de la résolution de toutes les générations
|
||||
const results = await Promise.all(generationPromises)
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
|
||||
}
|
||||
|
||||
// On garde uniquement les tentatives réussies (non null)
|
||||
const options = results.filter(Boolean)
|
||||
|
||||
if (options.length === 0) {
|
||||
throw new Error('Toutes les tentatives de génération ont échoué.')
|
||||
async function prepareMusiclandCover(project) {
|
||||
const startedAt = Date.now()
|
||||
const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
|
||||
if (!userId) {
|
||||
throw new Error('Utilisateur du projet introuvable')
|
||||
}
|
||||
|
||||
// Sauvegarde dans Firestore
|
||||
const [firstOption] = options
|
||||
const { artistName, profilePictureURL } = await loadCoverIdentity(project)
|
||||
const profileOverlay = await buildProfileOverlay(profilePictureURL)
|
||||
const overlays = [{ input: buildTextOverlay(artistName), left: 0, top: 0 }]
|
||||
|
||||
if (profileOverlay) {
|
||||
overlays.unshift({ input: profileOverlay, left: 398, top: 454 })
|
||||
}
|
||||
|
||||
const coverBuffer = await sharp(TEMPLATE_PATH)
|
||||
.resize(COVER_SIZE, COVER_SIZE, { fit: 'cover', kernel: sharp.kernel.lanczos3 })
|
||||
.ensureAlpha()
|
||||
.composite(overlays)
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
const storagePath = `users/${userId}/projects/${project.id}/cover-musicland-playbacker.png`
|
||||
const coverUrl = await uploadCover(coverBuffer, storagePath)
|
||||
const option = {
|
||||
id: COVER_OPTION_ID,
|
||||
finalUrl: coverUrl,
|
||||
generatedUrl: null,
|
||||
}
|
||||
|
||||
await refList.projects.doc(project.id).set(
|
||||
{
|
||||
cover: {
|
||||
generatedBackground: firstOption.generatedUrl,
|
||||
result: firstOption.finalUrl,
|
||||
// selectedOptionId: firstOption.id, // disable default selection
|
||||
options, // Sauvegarde de toutes les variantes réussies
|
||||
template: COVER_TEMPLATE,
|
||||
result: coverUrl,
|
||||
generatedBackground: null,
|
||||
selectedOptionId: COVER_OPTION_ID,
|
||||
options: [option],
|
||||
},
|
||||
coverStatus: 'GENERATED',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
@@ -267,82 +207,68 @@ async function performCoverGeneration(project) {
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
logger.info('🏁 [Cover] Process complete', {
|
||||
logger.info('[Cover] MusicLand cover prepared', {
|
||||
projectId: project.id,
|
||||
successCount: options.length,
|
||||
duration: Date.now() - t0,
|
||||
duration: Date.now() - startedAt,
|
||||
})
|
||||
|
||||
return firstOption.finalUrl
|
||||
return coverUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* TRIGGER FIRESTORE
|
||||
* Déclenché à la création d'un document dans 'tasks/{taskId}'
|
||||
*/
|
||||
exports.onTaskCreateGenerateCover = onDocumentCreated(
|
||||
{
|
||||
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
|
||||
timeoutSeconds: 120,
|
||||
memory: '1GiB',
|
||||
document: 'tasks/{taskId}',
|
||||
secrets: [GEMINI_API_KEY],
|
||||
},
|
||||
async (event) => {
|
||||
const data = event.data?.data() || {}
|
||||
const { type, projectId } = data
|
||||
const taskId = event.params.taskId
|
||||
|
||||
if (!projectId) return // Ignorer les tâches mal formées
|
||||
if (!['cover', 'combine'].includes(type)) return // Ignorer les autres types de tâches
|
||||
if (!projectId || !['cover', 'combine'].includes(type)) return
|
||||
|
||||
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId })
|
||||
if (type === 'combine') {
|
||||
await event.data.ref.update({
|
||||
status: 'CANCELLED',
|
||||
error: "La personnalisation photo n'est plus disponible.",
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`[Task ${taskId}] MusicLand cover started`, { projectId })
|
||||
|
||||
try {
|
||||
// 1. Validation & Setup
|
||||
if (type === 'combine') {
|
||||
// Feature désactivée pour le moment
|
||||
await event.data.ref.update({
|
||||
status: 'CANCELLED',
|
||||
error: "La personnalisation photo n'est plus disponible.",
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mise à jour statut projet
|
||||
await refList.projects.doc(projectId).update({
|
||||
coverStatus: 'GENERATING',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
// 2. Chargement Projet
|
||||
const projectSnap = await refList.projects.doc(projectId).get()
|
||||
if (!projectSnap.exists) throw new Error('Projet introuvable')
|
||||
|
||||
const project = { id: projectId, ...projectSnap.data() }
|
||||
|
||||
// Idempotency check (si déjà généré, on ne refait pas)
|
||||
if (Array.isArray(project?.cover?.options) && project.cover.options.length > 0) {
|
||||
logger.warn('⚠️ [Task] Cover already exists. Skipping.')
|
||||
await refList.projects.doc(projectId).update({ coverStatus: 'GENERATED' })
|
||||
await event.data.ref.update({
|
||||
status: 'DONE',
|
||||
info: 'Already generated',
|
||||
})
|
||||
return
|
||||
const projectSnapshot = await refList.projects.doc(projectId).get()
|
||||
if (!projectSnapshot.exists) {
|
||||
throw new Error('Projet introuvable')
|
||||
}
|
||||
|
||||
// 3. Exécution Génération
|
||||
const coverUrl = await performCoverGeneration(project)
|
||||
const project = { id: projectId, ...projectSnapshot.data() }
|
||||
const existingCover = project.cover || {}
|
||||
let coverUrl = existingCover.template === COVER_TEMPLATE ? existingCover.result : null
|
||||
|
||||
if (!coverUrl) {
|
||||
coverUrl = await prepareMusiclandCover(project)
|
||||
} else {
|
||||
await refList.projects.doc(projectId).update({
|
||||
coverStatus: 'GENERATED',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Finalisation Tâche
|
||||
await event.data.ref.update({
|
||||
status: 'DONE',
|
||||
coverUrl,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
// 5. Notification
|
||||
if (project.userId) {
|
||||
const projectTitle = project.title || 'ton projet'
|
||||
await sendNotification({
|
||||
@@ -350,28 +276,30 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
|
||||
receiver: project.userId,
|
||||
receiverCollection: 'users',
|
||||
title: 'Pochette prête !',
|
||||
message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
|
||||
message: `La pochette pour "${projectTitle}" est prête.`,
|
||||
data: {
|
||||
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
|
||||
projectId,
|
||||
projectTitle,
|
||||
coverUrl,
|
||||
},
|
||||
}).catch((err) => logger.warn('Notification failed', err))
|
||||
}).catch((error) => logger.warn('Notification failed', error))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`🔥 [Task ${taskId}] Failed`, error)
|
||||
|
||||
// Mise à jour erreur Tâche
|
||||
await event.data.ref.set({ status: 'ERROR', error: error.message }, { merge: true })
|
||||
|
||||
// Mise à jour erreur Projet
|
||||
logger.error(`[Task ${taskId}] MusicLand cover failed`, error)
|
||||
await event.data.ref.set(
|
||||
{
|
||||
status: 'ERROR',
|
||||
error: error.message,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
await refList.projects.doc(projectId).update({
|
||||
coverStatus: 'ERROR',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
// Notification Erreur
|
||||
const projectData = (await refList.projects.doc(projectId).get()).data()
|
||||
if (projectData?.userId) {
|
||||
await sendNotification({
|
||||
@@ -379,7 +307,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
|
||||
receiver: projectData.userId,
|
||||
receiverCollection: 'users',
|
||||
title: 'Échec pochette',
|
||||
message: `Impossible de générer la pochette pour "${projectData.title || 'ton projet'}".`,
|
||||
message: `Impossible de préparer la pochette pour "${projectData.title || 'ton projet'}".`,
|
||||
data: {
|
||||
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
|
||||
projectId,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
+3
-1
@@ -105,6 +105,7 @@ import placeholder3 from './UI/placeholder3.png'
|
||||
import placeholder4 from './UI/placeholder4.jpg'
|
||||
import profile from './UI/profile.jpg'
|
||||
import musiclandClub from './UI/musiclandClub.png'
|
||||
import musiclandPlaybackCover from './UI/musiclandPlaybackCover.png'
|
||||
import frenchFlag from './icons/frenchFlag.png'
|
||||
import englishFlag from './icons/englishFlag.png'
|
||||
|
||||
@@ -259,6 +260,7 @@ export const img = {
|
||||
profile,
|
||||
goodVibe,
|
||||
musiclandClub,
|
||||
musiclandPlaybackCover,
|
||||
}
|
||||
|
||||
export const cardsImg = {
|
||||
@@ -278,4 +280,4 @@ export const planBadges = {
|
||||
starter: require('./icons/premiumBadge.png'),
|
||||
pro: require('./icons/starterBadge.png'),
|
||||
premium: require('./icons/proBadge.png'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { MaterialIcons } from '@expo/vector-icons'
|
||||
import { VideoView, useVideoPlayer } from 'expo-video'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
|
||||
const BackgroundVideo = ({ source }) => {
|
||||
const [muted, setMuted] = useState(false)
|
||||
const resolvedSource = useMemo(
|
||||
() => (source ? (typeof source === 'string' ? { uri: source } : source) : null),
|
||||
[source]
|
||||
)
|
||||
|
||||
const player = useVideoPlayer(resolvedSource, (nextPlayer) => {
|
||||
nextPlayer.loop = true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) {
|
||||
player?.pause?.()
|
||||
return
|
||||
}
|
||||
|
||||
player.muted = muted
|
||||
player.play()
|
||||
}, [muted, player, source])
|
||||
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View pointerEvents="box-none" style={styles.container}>
|
||||
<VideoView
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
contentFit="cover"
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View pointerEvents="none" style={styles.scrim} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
|
||||
onPress={() => setMuted((currentMuted) => !currentMuted)}
|
||||
style={({ pressed }) => [styles.soundButton, pressed && styles.soundButtonPressed]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={muted ? 'volume-off' : 'volume-up'}
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
<Text style={styles.soundButtonText}>{muted ? 'Activer le son' : 'Couper le son'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BackgroundVideo)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
scrim: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(5, 7, 12, 0.52)',
|
||||
},
|
||||
soundButton: {
|
||||
position: 'absolute',
|
||||
top: 80,
|
||||
left: 16,
|
||||
zIndex: 30,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.42)',
|
||||
backgroundColor: 'rgba(7, 9, 14, 0.68)',
|
||||
},
|
||||
soundButtonPressed: {
|
||||
opacity: 0.78,
|
||||
},
|
||||
soundButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { MaterialIcons } from '@expo/vector-icons'
|
||||
import { Asset } from 'expo-asset'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
|
||||
const resolveModuleUri = async (source) => {
|
||||
const asset = Asset.fromModule(source)
|
||||
|
||||
if (!asset.localUri && !asset.uri) {
|
||||
await asset.downloadAsync()
|
||||
}
|
||||
|
||||
return asset.localUri ?? asset.uri ?? null
|
||||
}
|
||||
|
||||
const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
|
||||
const videoRef = useRef(null)
|
||||
const [uri, setUri] = useState(null)
|
||||
const [muted, setMuted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
if (!source) {
|
||||
setUri(null)
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof source === 'string') {
|
||||
setUri(source)
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadSource = async () => {
|
||||
try {
|
||||
const nextUri = await resolveModuleUri(source)
|
||||
if (isMounted) {
|
||||
setUri(nextUri)
|
||||
}
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setUri(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSource()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [source])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
|
||||
if (!video || !uri) {
|
||||
return
|
||||
}
|
||||
|
||||
const playResult = video.play()
|
||||
playResult?.catch?.((error) => {
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
setMuted(true)
|
||||
video.muted = true
|
||||
video.play()?.catch?.(() => {})
|
||||
}
|
||||
})
|
||||
}, [uri])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
|
||||
if (!video) {
|
||||
return
|
||||
}
|
||||
|
||||
video.muted = muted
|
||||
if (video.paused) {
|
||||
video.play()?.catch?.(() => {})
|
||||
}
|
||||
}, [muted])
|
||||
|
||||
if (!uri) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View pointerEvents="box-none" style={styles.container}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={uri}
|
||||
style={styles.video}
|
||||
playsInline
|
||||
autoPlay
|
||||
loop
|
||||
muted={muted}
|
||||
controls={false}
|
||||
/>
|
||||
<View pointerEvents="none" style={styles.scrim} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
|
||||
onPress={() => setMuted((currentMuted) => !currentMuted)}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.soundButton,
|
||||
soundButtonStyle,
|
||||
hovered && styles.soundButtonHovered,
|
||||
pressed && styles.soundButtonPressed,
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={muted ? 'volume-off' : 'volume-up'}
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
<Text style={styles.soundButtonText}>{muted ? 'Activer le son' : 'Couper le son'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BackgroundVideo)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
video: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
scrim: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(5, 7, 12, 0.52)',
|
||||
},
|
||||
soundButton: {
|
||||
position: 'absolute',
|
||||
top: 78,
|
||||
left: 24,
|
||||
zIndex: 30,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.42)',
|
||||
backgroundColor: 'rgba(7, 9, 14, 0.68)',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
soundButtonHovered: {
|
||||
backgroundColor: 'rgba(7, 9, 14, 0.82)',
|
||||
},
|
||||
soundButtonPressed: {
|
||||
opacity: 0.78,
|
||||
},
|
||||
soundButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Image as ExpoImage } from 'expo-image'
|
||||
import React from 'react'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import { img } from '../assets'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
|
||||
const MusiclandPlaybackCover = ({
|
||||
artistName,
|
||||
profilePictureURL,
|
||||
coverUrl,
|
||||
style,
|
||||
}) => {
|
||||
const displayName =
|
||||
typeof artistName === 'string' && artistName.trim() ? artistName.trim() : 'MusicLand'
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<ExpoImage
|
||||
source={coverUrl ? { uri: coverUrl } : img.musiclandPlaybackCover}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory-disk"
|
||||
priority="high"
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
{!coverUrl && profilePictureURL ? (
|
||||
<ExpoImage
|
||||
source={{ uri: profilePictureURL }}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory-disk"
|
||||
style={styles.profilePicture}
|
||||
/>
|
||||
) : null}
|
||||
{!coverUrl ? (
|
||||
<>
|
||||
<Text
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.55}
|
||||
numberOfLines={1}
|
||||
style={styles.artistName}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={styles.playbacker}>PLAYBACKER</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
profilePicture: {
|
||||
position: 'absolute',
|
||||
width: '22.3%',
|
||||
aspectRatio: 1,
|
||||
left: '38.85%',
|
||||
top: '44.3%',
|
||||
borderRadius: 999,
|
||||
},
|
||||
artistName: {
|
||||
position: 'absolute',
|
||||
top: '76.5%',
|
||||
left: '16%',
|
||||
width: '68%',
|
||||
color: '#FFFFFF',
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 21,
|
||||
lineHeight: 26,
|
||||
letterSpacing: 1.2,
|
||||
textAlign: 'center',
|
||||
},
|
||||
playbacker: {
|
||||
position: 'absolute',
|
||||
bottom: '3%',
|
||||
left: '10%',
|
||||
width: '80%',
|
||||
color: '#FFFFFF',
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 20,
|
||||
lineHeight: 25,
|
||||
letterSpacing: 1.4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
export default React.memo(MusiclandPlaybackCover)
|
||||
@@ -33,7 +33,7 @@ const useNavigateToMusicDetails = () => {
|
||||
if (resolvedProject && isOwner) {
|
||||
selectProject?.(projectId)
|
||||
const stageAction = getStageAction('beatmaker', resolvedProject)
|
||||
const targetRoute = stageAction?.route || Routes.Compose
|
||||
const targetRoute = stageAction?.route || Routes.ComposeSong
|
||||
navigate(targetRoute, stageAction?.params)
|
||||
return true
|
||||
}
|
||||
|
||||
+40
-17
@@ -72,8 +72,8 @@ const generateNonce = (length = 32) => {
|
||||
return result
|
||||
}
|
||||
|
||||
const ensureUserDocument = async (user, overrides = {}) => {
|
||||
if (!user?.uid) return
|
||||
const ensureUserDocument = async (user, overrides = {}, isNewUser = false) => {
|
||||
if (!user?.uid) return null
|
||||
|
||||
try {
|
||||
const docRef = usersRef.doc(user.uid)
|
||||
@@ -88,6 +88,9 @@ const ensureUserDocument = async (user, overrides = {}) => {
|
||||
payload.createdAt = firebase.firestore.FieldValue.serverTimestamp()
|
||||
if (user.email) payload.email = user.email
|
||||
payload.emailNotifications = true
|
||||
if (isNewUser) {
|
||||
payload.registrationFlowRequired = true
|
||||
}
|
||||
} else if (typeof existingData?.emailNotifications === 'undefined') {
|
||||
payload.emailNotifications = true
|
||||
}
|
||||
@@ -122,8 +125,16 @@ const ensureUserDocument = async (user, overrides = {}) => {
|
||||
}
|
||||
|
||||
await docRef.set(payload, { merge: true })
|
||||
return {
|
||||
isNewUser,
|
||||
userData: {
|
||||
...existingData,
|
||||
...payload,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('ensureUserDocument error', error?.message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,10 +232,14 @@ const useSocialAuth = ({ onSuccess } = {}) => {
|
||||
|
||||
const credential = firebase.auth.GoogleAuthProvider.credential(idToken)
|
||||
|
||||
await firebase.auth().signInWithCredential(credential)
|
||||
await ensureUserDocument(firebase.auth().currentUser)
|
||||
const userCredential = await firebase.auth().signInWithCredential(credential)
|
||||
const authResult = await ensureUserDocument(
|
||||
userCredential?.user,
|
||||
{},
|
||||
userCredential?.additionalUserInfo?.isNewUser === true
|
||||
)
|
||||
if (typeof onSuccess === 'function') {
|
||||
await onSuccess()
|
||||
await onSuccess(authResult)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Google sign-in error', error?.message)
|
||||
@@ -298,10 +313,14 @@ const useSocialAuth = ({ onSuccess } = {}) => {
|
||||
const provider = new firebase.auth.GoogleAuthProvider()
|
||||
provider.addScope('profile')
|
||||
provider.addScope('email')
|
||||
await firebase.auth().signInWithPopup(provider)
|
||||
await ensureUserDocument(firebase.auth().currentUser)
|
||||
const userCredential = await firebase.auth().signInWithPopup(provider)
|
||||
const authResult = await ensureUserDocument(
|
||||
userCredential?.user,
|
||||
{},
|
||||
userCredential?.additionalUserInfo?.isNewUser === true
|
||||
)
|
||||
if (typeof onSuccess === 'function') {
|
||||
await onSuccess()
|
||||
await onSuccess(authResult)
|
||||
}
|
||||
setIsLoading(false)
|
||||
} catch (error) {
|
||||
@@ -398,17 +417,21 @@ const useSocialAuth = ({ onSuccess } = {}) => {
|
||||
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,
|
||||
})
|
||||
const authResult = await ensureUserDocument(
|
||||
userCredential?.user,
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
displayName:
|
||||
userCredential?.user?.displayName ||
|
||||
[firstName, lastName].filter(Boolean).join(' ') ||
|
||||
null,
|
||||
},
|
||||
userCredential?.additionalUserInfo?.isNewUser === true
|
||||
)
|
||||
|
||||
if (typeof onSuccess === 'function') {
|
||||
await onSuccess()
|
||||
await onSuccess(authResult)
|
||||
}
|
||||
setIsLoading(false)
|
||||
} catch (error) {
|
||||
|
||||
+4
-1
@@ -49,6 +49,8 @@ export default ({
|
||||
connect = false,
|
||||
blurIntensity = 0,
|
||||
backgroundColor = '#000',
|
||||
backgroundContent = null,
|
||||
coinBadgeContainerStyle = null,
|
||||
showCoin = true,
|
||||
showReturnHome = false,
|
||||
onReturnHome = null,
|
||||
@@ -209,8 +211,9 @@ export default ({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{backgroundContent}
|
||||
{coinBadgeContainerVisible ? (
|
||||
<View style={styles.coinBadgeContainer}>
|
||||
<View style={[styles.coinBadgeContainer, coinBadgeContainerStyle]}>
|
||||
{showCoinBadge ? (
|
||||
<View style={styles.coinRow}>
|
||||
<Pressable
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import CreatePassword from '../screens/CreatePassword'
|
||||
import CreatePseudo from '../screens/CreatePseudo'
|
||||
import RegistrationChoice from '../screens/RegistrationChoice'
|
||||
import ForgotPassword from '../screens/ForgotPassword'
|
||||
import LandingPage from '../screens/LandingPage'
|
||||
import AllMyList from '../screens/Library/AllMyList'
|
||||
@@ -95,6 +96,7 @@ const baseScreens = [
|
||||
{ name: Routes.Onboarding, component: Onboarding },
|
||||
{ name: Routes.Login, component: Login, title: 'Connexion' },
|
||||
{ name: Routes.LandingPage, component: LandingPage },
|
||||
{ name: Routes.RegistrationChoice, component: RegistrationChoice },
|
||||
{
|
||||
name: Routes.ForgotPassword,
|
||||
component: ForgotPassword,
|
||||
|
||||
@@ -7,6 +7,7 @@ export const Routes = {
|
||||
Register: 'Register',
|
||||
CreatePassword: 'CreatePassword',
|
||||
CreatePseudo: 'CreatePseudo',
|
||||
RegistrationChoice: 'RegistrationChoice',
|
||||
LandingPage: 'LandingPage',
|
||||
BottomTab: 'BottomTab',
|
||||
Welcome: 'Welcome',
|
||||
|
||||
@@ -56,6 +56,7 @@ const HIDDEN_ROUTE_NAMES = new Set([
|
||||
Routes.Login,
|
||||
Routes.ResetPassword,
|
||||
Routes.Register,
|
||||
Routes.RegistrationChoice,
|
||||
])
|
||||
|
||||
const noopAsync = async () => {}
|
||||
|
||||
@@ -13,7 +13,7 @@ import firebase, { usersRef } from '../config/firebase'
|
||||
import Page from '../layouts/Page'
|
||||
import { isWeb } from '../hooks/useLayoutType'
|
||||
import { Routes } from '../navigation'
|
||||
import { navigate } from '../navigation/NavigationService'
|
||||
import { reset } from '../navigation/NavigationService'
|
||||
import { Palette } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
|
||||
@@ -115,13 +115,14 @@ const CreatePassword = () => {
|
||||
...(birthDate ? { birthDate } : {}),
|
||||
...(country?.code ? { countryCode: country.code } : {}),
|
||||
...(country?.name ? { countryName: country.name } : {}),
|
||||
registrationFlowRequired: true,
|
||||
createdAt: firebase.firestore.FieldValue.serverTimestamp(),
|
||||
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
setTooltip({ text: 'Compte créé, continuons', type: 'success' })
|
||||
navigate(Routes.BottomTab)
|
||||
reset({ index: 0, routes: [{ name: Routes.RegistrationChoice }] })
|
||||
} catch (e) {
|
||||
console.log('Register error', e?.message)
|
||||
const message =
|
||||
|
||||
@@ -295,7 +295,7 @@ const Home = ({ navigation, route }) => {
|
||||
if (selectedProject?.id !== currentProject.id) {
|
||||
selectProject(currentProject.id)
|
||||
}
|
||||
const targetRoute = beatmakerStage?.route || Routes.Compose
|
||||
const targetRoute = beatmakerStage?.route || Routes.ComposeSong
|
||||
navigate(targetRoute, beatmakerStage?.params)
|
||||
},
|
||||
},
|
||||
@@ -324,7 +324,7 @@ const Home = ({ navigation, route }) => {
|
||||
const songwriterAction = useMemo(() => getStageAction('songwriter', null), [])
|
||||
|
||||
const handleStartNew = useCallback(async () => {
|
||||
const targetRoute = songwriterAction?.route || Routes.WritingLyrics
|
||||
const targetRoute = songwriterAction?.route || Routes.CreateLyricsWithAi
|
||||
const targetParams = songwriterAction?.params
|
||||
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,8 @@ const ClubCard = ({ onPress, containerStyle = null, variant = 'compact' }) => {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Club MusicLand"
|
||||
style={({ pressed }) => [
|
||||
styles.card,
|
||||
cardBaseStyle,
|
||||
|
||||
@@ -174,8 +174,8 @@ const StageCard = ({
|
||||
isMobileVariant ? { textAlign: 'center' } : { textAlign: 'left', marginRight: 10 },
|
||||
{
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
fontSize: 14,
|
||||
lineHeight: 21,
|
||||
color: Palette.white,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { background } from '../assets'
|
||||
import GradientButton from '../components/GradientButton.js'
|
||||
import { Input } from '../components/Input.js'
|
||||
import ItemContainer from '../components/ItemContainer/ItemContainer'
|
||||
import firebase from '../config/firebase'
|
||||
import firebase, { usersRef } from '../config/firebase'
|
||||
import useSocialAuth from '../hooks/useSocialAuth'
|
||||
import { isWeb } from '../hooks/useLayoutType'
|
||||
import Page from '../layouts/Page.js'
|
||||
@@ -17,16 +17,22 @@ import { Routes } from '../navigation'
|
||||
import { navigate } from '../navigation/NavigationService.js'
|
||||
import { FONT_FAMILY } from '../styles/Fonts.js'
|
||||
import Palette from '../styles/Palette.js'
|
||||
import { getRouteAfterAuthentication } from '../utils/registrationFlow'
|
||||
|
||||
export default ({ navigation }) => {
|
||||
const [, setTooltip] = useGlobal('_tooltip')
|
||||
const [email, setEmail] = useState(__DEV__ ? 'az@az.az' : '')
|
||||
const [password, setPassword] = useState(__DEV__ ? 'Minuit33' : '')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const afterLoginNavigate = useCallback(async () => {
|
||||
const afterLoginNavigate = useCallback(async (authResult) => {
|
||||
const uid = firebase.auth().currentUser?.uid
|
||||
if (!uid) throw new Error('Aucun utilisateur après connexion')
|
||||
navigation.reset({ index: 0, routes: [{ name: Routes.BottomTab }] })
|
||||
const userData =
|
||||
authResult?.userData || (await usersRef.doc(uid).get()).data() || {}
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: getRouteAfterAuthentication(userData) }],
|
||||
})
|
||||
}, [navigation])
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { Image, StyleSheet, View } from 'react-native'
|
||||
import { ai, background } from '../../assets'
|
||||
import React, { useCallback } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { background } from '../../assets'
|
||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
import Page from '../../layouts/Page'
|
||||
@@ -15,13 +15,7 @@ import { gutters } from '../../styles'
|
||||
const Playback = ({ route, navigation }) => {
|
||||
const { project, returnToAdventureModal = false } = route.params || {}
|
||||
const { videos } = useUser()
|
||||
// const benhaiUrl = isWeb ? videos?.benhaiWeb || null : videos?.benhai;
|
||||
const theoUrl = isWeb ? videos?.theoWeb : videos?.theo
|
||||
const [showIntro, setShowIntro] = useState(theoUrl)
|
||||
|
||||
const handleCloseIntro = () => {
|
||||
setShowIntro(null)
|
||||
}
|
||||
|
||||
const handleBackPress = useCallback(() => {
|
||||
if (returnToAdventureModal) {
|
||||
@@ -39,6 +33,13 @@ const Playback = ({ route, navigation }) => {
|
||||
<Page
|
||||
headerType="NONE"
|
||||
backgroundImg={isWeb ? background.playbackWeb : background.playbackMobile}
|
||||
backgroundContent={
|
||||
<BackgroundVideo
|
||||
source={theoUrl}
|
||||
soundButtonStyle={isWeb ? styles.rightSideControl : undefined}
|
||||
/>
|
||||
}
|
||||
containerStyle={isWeb ? styles.videoPage : undefined}
|
||||
>
|
||||
{/* <Image source={ai.theo} style={styles.img} resizeMode="contain" /> */}
|
||||
<MusicLandHeader onPressBack={handleBackPress} progress={25} />
|
||||
@@ -58,7 +59,6 @@ const Playback = ({ route, navigation }) => {
|
||||
{/* <BorderGradientButton title="Importer une vidéo" /> */}
|
||||
</View>
|
||||
</View>
|
||||
<FullscreenIntroVideo url={showIntro} visible={showIntro} onClose={handleCloseIntro} />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -66,10 +66,12 @@ const Playback = ({ route, navigation }) => {
|
||||
export default Playback
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
img: {
|
||||
width: '100%',
|
||||
height: '70%',
|
||||
position: 'absolute',
|
||||
bottom: -40,
|
||||
videoPage: {
|
||||
alignSelf: 'flex-start',
|
||||
marginLeft: '4%',
|
||||
},
|
||||
rightSideControl: {
|
||||
right: 24,
|
||||
left: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Linking, StyleSheet, Text, View } from 'react-native'
|
||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
|
||||
import { background } from '../../assets'
|
||||
import AppCheckbox from '../../components/AppCheckbox'
|
||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
@@ -15,11 +16,12 @@ import { Routes } from '../../navigation'
|
||||
import { useUser } from '../../providers/UserDataProvider'
|
||||
import { Palette, gutters } from '../../styles'
|
||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||
import { isWeb } from '../../hooks/useLayoutType'
|
||||
|
||||
const PublishYoutube = () => {
|
||||
const route = useRoute()
|
||||
const routeProjectId = route?.params?.projectId ?? null
|
||||
const { userProjects = [], selectedProject } = useUser()
|
||||
const { userProjects = [], selectedProject, videos } = useUser()
|
||||
const { setTooltip } = useMinuit()
|
||||
|
||||
const project = useMemo(() => {
|
||||
@@ -33,6 +35,7 @@ const PublishYoutube = () => {
|
||||
}, [routeProjectId, selectedProject, userProjects])
|
||||
|
||||
const projectId = project?.id || routeProjectId || null
|
||||
const introVideo = isWeb ? videos?.benhaiWeb : videos?.benhai
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true)
|
||||
@@ -194,7 +197,17 @@ const PublishYoutube = () => {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Page backgroundImg={background.playbackBG2} headerType="NONE">
|
||||
<Page
|
||||
backgroundImg={background.playbackBG2}
|
||||
backgroundContent={
|
||||
<BackgroundVideo
|
||||
source={introVideo}
|
||||
soundButtonStyle={isWeb ? styles.rightSideControl : undefined}
|
||||
/>
|
||||
}
|
||||
containerStyle={isWeb ? styles.videoPage : undefined}
|
||||
headerType="NONE"
|
||||
>
|
||||
<MusicLandHeader progress={100} onPressBack={goBack} />
|
||||
<View
|
||||
style={{
|
||||
@@ -293,6 +306,13 @@ const PublishYoutube = () => {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
videoPage: {
|
||||
alignSelf: 'center',
|
||||
},
|
||||
rightSideControl: {
|
||||
right: 24,
|
||||
left: 'auto',
|
||||
},
|
||||
consentContainer: {
|
||||
gap: 6,
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Routes } from '../navigation'
|
||||
import { navigate, reset } from '../navigation/NavigationService'
|
||||
import { Palette } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
import { getRouteAfterAuthentication } from '../utils/registrationFlow'
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = 'preferredLanguage'
|
||||
|
||||
@@ -39,10 +40,13 @@ const Register = () => {
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
const [, setTooltip] = useGlobal('_tooltip')
|
||||
|
||||
const afterSocialAuth = useCallback(async () => {
|
||||
const afterSocialAuth = useCallback(async (authResult) => {
|
||||
const uid = firebase.auth().currentUser?.uid
|
||||
if (!uid) throw new Error('Aucun utilisateur après connexion')
|
||||
reset({ index: 0, routes: [{ name: Routes.BottomTab }] })
|
||||
reset({
|
||||
index: 0,
|
||||
routes: [{ name: getRouteAfterAuthentication(authResult?.userData) }],
|
||||
})
|
||||
}, [])
|
||||
|
||||
const {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import { useGlobal } from 'reactn'
|
||||
import { background } from '../assets'
|
||||
import BorderGradientButton from '../components/BorderGradientButton'
|
||||
import GradientButton from '../components/GradientButton'
|
||||
import ItemContainer from '../components/ItemContainer/ItemContainer'
|
||||
import { isWeb } from '../hooks/useLayoutType'
|
||||
import Page from '../layouts/Page'
|
||||
import { Routes } from '../navigation'
|
||||
import { navigate, reset } from '../navigation/NavigationService'
|
||||
import { Palette } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
import {
|
||||
completeRegistrationFlow,
|
||||
getExperienceReset,
|
||||
isGlobalListeningLaunchPeriodActive,
|
||||
} from '../utils/registrationFlow'
|
||||
|
||||
const RegistrationChoice = () => {
|
||||
const [loadingChoice, setLoadingChoice] = useState(null)
|
||||
const [, setTooltip] = useGlobal('_tooltip')
|
||||
|
||||
const openPayment = useCallback((experience) => {
|
||||
navigate(Routes.Payments, {
|
||||
registrationFlow: true,
|
||||
registrationChoice: experience,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleListen = useCallback(async () => {
|
||||
if (!isGlobalListeningLaunchPeriodActive()) {
|
||||
openPayment('listen')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingChoice('listen')
|
||||
await completeRegistrationFlow('listen')
|
||||
reset(getExperienceReset('listen'))
|
||||
} catch (error) {
|
||||
setTooltip({
|
||||
type: 'error',
|
||||
text: error?.message || 'Impossible d’ouvrir l’espace Écoute',
|
||||
})
|
||||
} finally {
|
||||
setLoadingChoice(null)
|
||||
}
|
||||
}, [openPayment, setTooltip])
|
||||
|
||||
const handleCreation = useCallback(() => {
|
||||
openPayment('creation')
|
||||
}, [openPayment])
|
||||
|
||||
return (
|
||||
<Page
|
||||
width={isWeb ? 720 : null}
|
||||
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
|
||||
headerType="NONE"
|
||||
title="Choisis ton expérience"
|
||||
>
|
||||
<View style={styles.pageContent}>
|
||||
<ItemContainer
|
||||
height={isWeb ? 480 : undefined}
|
||||
disableKeyboardHeight
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.heading}>
|
||||
<Text style={styles.title}>Que veux-tu faire en premier ?</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Tu pourras passer librement de la création à l’écoute après cette étape.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.choice}>
|
||||
<View style={styles.choiceText}>
|
||||
<Text style={styles.choiceTitle}>Création</Text>
|
||||
<Text style={styles.choiceDescription}>
|
||||
Commence ton parcours avec les 10 crédits offerts à ton inscription.
|
||||
</Text>
|
||||
</View>
|
||||
<BorderGradientButton
|
||||
title="Créer"
|
||||
onPress={handleCreation}
|
||||
disabled={loadingChoice !== null}
|
||||
containerStyle={styles.button}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.choice}>
|
||||
<View style={styles.choiceText}>
|
||||
<Text style={styles.choiceTitle}>Écoute</Text>
|
||||
<Text style={styles.choiceDescription}>
|
||||
Retrouve les créations de la communauté dans l’espace Streaming.
|
||||
</Text>
|
||||
</View>
|
||||
<GradientButton
|
||||
title={loadingChoice === 'listen' ? 'Ouverture...' : 'Écouter'}
|
||||
onPress={handleListen}
|
||||
disabled={loadingChoice !== null}
|
||||
containerStyle={styles.button}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ItemContainer>
|
||||
</View>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pageContent: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
content: {
|
||||
gap: 24,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
heading: {
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
title: {
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
color: Palette.gray,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
choice: {
|
||||
gap: 16,
|
||||
},
|
||||
choiceText: {
|
||||
gap: 6,
|
||||
},
|
||||
choiceTitle: {
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 19,
|
||||
},
|
||||
choiceDescription: {
|
||||
color: Palette.gray,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
button: {
|
||||
width: '100%',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: Palette.ultraLightWhite,
|
||||
},
|
||||
})
|
||||
|
||||
export default RegistrationChoice
|
||||
@@ -6,6 +6,7 @@ import { Routes } from '../navigation'
|
||||
|
||||
import { SplashAnimationContext } from '../providers/SplashAnimationProvider'
|
||||
import { UserDataContext } from '../providers/UserDataProvider'
|
||||
import { getRouteAfterAuthentication } from '../utils/registrationFlow'
|
||||
|
||||
export default ({ navigation }) => {
|
||||
const { setCurrentUserData } = useContext(UserDataContext)
|
||||
@@ -28,7 +29,7 @@ export default ({ navigation }) => {
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: Routes.BottomTab,
|
||||
name: getRouteAfterAuthentication(user),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRoute } from '@react-navigation/native'
|
||||
import { Dimensions, Modal, Text, View } from 'react-native'
|
||||
import SwiperFlatList from 'react-native-swiper-flatlist'
|
||||
import { background, icons } from '../../assets'
|
||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
@@ -41,8 +42,14 @@ const ComposeSong = () => {
|
||||
const [progress, setProgress] = useState(18)
|
||||
const [containerLayout, setContainerLayout] = useState(null)
|
||||
const route = useRoute()
|
||||
const { selectedProjectId, selectedProject, updateProjectData, currentUserData, currentUID } =
|
||||
useUser()
|
||||
const {
|
||||
selectedProjectId,
|
||||
selectedProject,
|
||||
updateProjectData,
|
||||
currentUserData,
|
||||
currentUID,
|
||||
videos,
|
||||
} = useUser()
|
||||
|
||||
// Selections state
|
||||
const [genres, setGenres] = useState([])
|
||||
@@ -286,7 +293,11 @@ const ComposeSong = () => {
|
||||
])
|
||||
|
||||
return (
|
||||
<Page backgroundImg={background.studioBG2} headerType="NONE">
|
||||
<Page
|
||||
backgroundImg={background.studioBG2}
|
||||
backgroundContent={<BackgroundVideo source={videos?.malik} />}
|
||||
headerType="NONE"
|
||||
>
|
||||
<MusicLandHeader onPressBack={onPressBack} progress={progress} logo={icons.musicLandStudio} />
|
||||
<View style={{ flex: 1, paddingBottom: gutters, gap: 48 }}>
|
||||
<View
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRoute } from '@react-navigation/native'
|
||||
import { FlatList, Modal, Text, View, useWindowDimensions } from 'react-native'
|
||||
import { FlatList, Modal, StyleSheet, Text, View, useWindowDimensions } from 'react-native'
|
||||
import { background } from '../../assets'
|
||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
@@ -47,8 +48,14 @@ const ComposeSong = () => {
|
||||
}, [windowWidth])
|
||||
const containerWidth = parentLayout?.width || estimatedContainerWidth
|
||||
const route = useRoute()
|
||||
const { selectedProjectId, selectedProject, updateProjectData, currentUserData, currentUID } =
|
||||
useUser()
|
||||
const {
|
||||
selectedProjectId,
|
||||
selectedProject,
|
||||
updateProjectData,
|
||||
currentUserData,
|
||||
currentUID,
|
||||
videos,
|
||||
} = useUser()
|
||||
|
||||
const [genres, setGenres] = useState([])
|
||||
const [voice, setVoice] = useState({})
|
||||
@@ -288,7 +295,18 @@ const ComposeSong = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Page backgroundImg={background.studioBG2} headerType="NONE">
|
||||
<Page
|
||||
backgroundImg={background.studioBG2}
|
||||
backgroundContent={
|
||||
<BackgroundVideo
|
||||
source={videos?.malikWeb}
|
||||
soundButtonStyle={styles.rightSideControl}
|
||||
/>
|
||||
}
|
||||
coinBadgeContainerStyle={styles.rightSideCredits}
|
||||
containerStyle={styles.videoQuestionPage}
|
||||
headerType="NONE"
|
||||
>
|
||||
<MusicLandHeader
|
||||
onPressBack={onPressBack}
|
||||
progress={progress}
|
||||
@@ -479,3 +497,19 @@ const ComposeSong = () => {
|
||||
}
|
||||
|
||||
export default ComposeSong
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
videoQuestionPage: {
|
||||
alignSelf: 'flex-start',
|
||||
marginLeft: '4%',
|
||||
},
|
||||
rightSideControl: {
|
||||
right: 24,
|
||||
left: 'auto',
|
||||
},
|
||||
rightSideCredits: {
|
||||
right: 24,
|
||||
left: 'auto',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -111,7 +111,7 @@ const Studio = () => {
|
||||
} else {
|
||||
if (!selectedProject?.id) return
|
||||
selectProject(selectedProject.id)
|
||||
navigate(Routes.Compose)
|
||||
navigate(Routes.ComposeSong)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -130,7 +130,7 @@ const Studio = () => {
|
||||
/>
|
||||
{!selectedProject?.coverUrl && (
|
||||
<GradientButton
|
||||
title="Générer la pochette"
|
||||
title="Voir ma pochette"
|
||||
containerStyle={{
|
||||
marginTop: responsiveHeight(2),
|
||||
}}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BlurView } from 'expo-blur'
|
||||
import { Image as ExpoImage } from 'expo-image'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import GradientButton from '../components/GradientButton'
|
||||
import BorderGradientButton from '../components/BorderGradientButton'
|
||||
import CreditAmount from '../components/CreditAmount'
|
||||
import Page from '../layouts/Page'
|
||||
import { background, icons, planBadges } from '../assets'
|
||||
@@ -22,7 +23,7 @@ import { FONT_FAMILY } from '../styles/Fonts'
|
||||
import { isWeb } from '../hooks/useLayoutType'
|
||||
import { useStripe } from '../providers/StripeProvider'
|
||||
import { useUserData } from '../providers/UserDataProvider'
|
||||
import { goBack, navigate } from '../navigation/NavigationService'
|
||||
import { goBack, navigate, reset } from '../navigation/NavigationService'
|
||||
import { Routes } from '../navigation/Routes'
|
||||
import {
|
||||
COIN_PACK_DETAILS,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
SUBSCRIPTION_PLAN_SYNONYMS,
|
||||
SUBSCRIPTION_PRICE_ID_BY_PERIOD,
|
||||
} from '../utils/subscriptionCardDisplay'
|
||||
import { completeRegistrationFlow, getExperienceReset } from '../utils/registrationFlow'
|
||||
|
||||
const formatCurrency = (amount, currency = 'eur') => {
|
||||
if (typeof amount !== 'number') {
|
||||
@@ -302,6 +304,10 @@ export default function Subscriptions() {
|
||||
const route = useRoute()
|
||||
const insets = useSafeAreaInsets()
|
||||
const isMobile = !isWeb
|
||||
const isRegistrationFlow = route?.params?.registrationFlow === true
|
||||
const registrationChoice = route?.params?.registrationChoice
|
||||
const canSkipRegistrationPayment =
|
||||
isRegistrationFlow && registrationChoice === 'creation'
|
||||
const initialPack = React.useMemo(() => {
|
||||
const rawPack = route?.params?.pack
|
||||
return typeof rawPack === 'string' ? rawPack.toLowerCase() : null
|
||||
@@ -318,12 +324,13 @@ export default function Subscriptions() {
|
||||
createSubscriptionCheckout,
|
||||
createCoinPackCheckout,
|
||||
} = useStripe()
|
||||
const { currentUserData } = useUserData()
|
||||
const { currentUserData, hasActiveSubscription } = useUserData()
|
||||
const [selectedPeriodKey, setSelectedPeriodKey] = React.useState('monthly')
|
||||
const [selectedPriceId, setSelectedPriceId] = React.useState(null)
|
||||
const [processingPriceId, setProcessingPriceId] = React.useState(null)
|
||||
const [errorMessage, setErrorMessage] = React.useState(null)
|
||||
const initialPackHandledRef = React.useRef(false)
|
||||
const registrationCompletedRef = React.useRef(false)
|
||||
|
||||
const normalizedPlansByPeriod = React.useMemo(
|
||||
() => ({
|
||||
@@ -344,7 +351,43 @@ export default function Subscriptions() {
|
||||
const hasAnyPlan = availablePeriods.length > 0
|
||||
const isLoadingPlans = isCatalogLoading && !hasAnyPlan
|
||||
const combinedErrorMessage = errorMessage || catalogError
|
||||
const screenTitle = isCoinPackView ? COIN_PACK_SECTION_TITLE : 'Rejoignez le club MusicLand'
|
||||
const screenTitle = isCoinPackView
|
||||
? COIN_PACK_SECTION_TITLE
|
||||
: isRegistrationFlow
|
||||
? 'Choisis ton accès MusicLand'
|
||||
: 'Rejoignez le club MusicLand'
|
||||
|
||||
const finishRegistrationFlow = React.useCallback(async () => {
|
||||
if (
|
||||
!isRegistrationFlow ||
|
||||
registrationCompletedRef.current ||
|
||||
(registrationChoice !== 'listen' && registrationChoice !== 'creation')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
registrationCompletedRef.current = true
|
||||
try {
|
||||
await completeRegistrationFlow(registrationChoice)
|
||||
reset(getExperienceReset(registrationChoice))
|
||||
} catch (error) {
|
||||
registrationCompletedRef.current = false
|
||||
setErrorMessage(error?.message || 'Impossible de terminer ton inscription.')
|
||||
}
|
||||
}, [isRegistrationFlow, registrationChoice])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isRegistrationFlow && hasActiveSubscription) {
|
||||
finishRegistrationFlow()
|
||||
}
|
||||
}, [finishRegistrationFlow, hasActiveSubscription, isRegistrationFlow])
|
||||
|
||||
const handleSkipRegistrationPayment = React.useCallback(() => {
|
||||
if (!canSkipRegistrationPayment) {
|
||||
return
|
||||
}
|
||||
finishRegistrationFlow()
|
||||
}, [canSkipRegistrationPayment, finishRegistrationFlow])
|
||||
|
||||
React.useEffect(() => {
|
||||
initialPackHandledRef.current = false
|
||||
@@ -721,9 +764,23 @@ export default function Subscriptions() {
|
||||
disabled={isActionDisabled}
|
||||
gradientStyle={[styles.actionButtonGradient, styles.mobileActionButtonGradient]}
|
||||
/>
|
||||
{canSkipRegistrationPayment ? (
|
||||
<BorderGradientButton
|
||||
title="Continuer avec mes 10 crédits"
|
||||
onPress={handleSkipRegistrationPayment}
|
||||
containerStyle={styles.skipPaymentButton}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}, [actionButtonTitle, handleCheckout, isActionDisabled, mobileActionSafePadding])
|
||||
}, [
|
||||
actionButtonTitle,
|
||||
canSkipRegistrationPayment,
|
||||
handleCheckout,
|
||||
handleSkipRegistrationPayment,
|
||||
isActionDisabled,
|
||||
mobileActionSafePadding,
|
||||
])
|
||||
|
||||
const renderWebBackButton = React.useCallback(() => {
|
||||
if (isMobile) return null
|
||||
@@ -803,7 +860,7 @@ export default function Subscriptions() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
{coinPacks && coinPacks.length > 0 ? (
|
||||
{!isRegistrationFlow && coinPacks && coinPacks.length > 0 ? (
|
||||
<View style={styles.creditsSection}>
|
||||
<Text style={styles.sectionTitle}>{COIN_PACK_SECTION_TITLE}</Text>
|
||||
<View style={styles.creditsGrid}>
|
||||
@@ -871,6 +928,13 @@ export default function Subscriptions() {
|
||||
disabled={isActionDisabled}
|
||||
gradientStyle={styles.actionButtonGradient}
|
||||
/>
|
||||
{canSkipRegistrationPayment ? (
|
||||
<BorderGradientButton
|
||||
title="Continuer avec mes 10 crédits"
|
||||
onPress={handleSkipRegistrationPayment}
|
||||
containerStyle={styles.skipPaymentButton}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text>
|
||||
@@ -1440,6 +1504,9 @@ const styles = StyleSheet.create({
|
||||
shadowRadius: 12,
|
||||
elevation: 12,
|
||||
},
|
||||
skipPaymentButton: {
|
||||
width: '100%',
|
||||
},
|
||||
mobileActionButtonGradient: {
|
||||
width: '100%',
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Dimensions, KeyboardAvoidingView, Platform, View } from 'react-native'
|
||||
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||
import { SwiperFlatList } from 'react-native-swiper-flatlist'
|
||||
import { background } from '../../assets'
|
||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from '../../data/data'
|
||||
@@ -26,7 +27,7 @@ const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
|
||||
const CTA_BUTTON_MAX_WIDTH = 500
|
||||
|
||||
const CreateLyricsWithAi = () => {
|
||||
const { selectedProject } = useUser()
|
||||
const { selectedProject, videos } = useUser()
|
||||
const scrollRef = useRef(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [progress, setProgress] = useState(16)
|
||||
@@ -341,9 +342,16 @@ const CreateLyricsWithAi = () => {
|
||||
}, [selectedIndex, parentLayout?.height])
|
||||
|
||||
const isCtaVisible = selectedIndex !== 8
|
||||
const isQuestionStep = selectedIndex < MAX_STEP_INDEX
|
||||
|
||||
return (
|
||||
<Page headerType="NONE" backgroundImg={background.writingMobileBackground}>
|
||||
<Page
|
||||
headerType="NONE"
|
||||
backgroundImg={isQuestionStep ? null : background.writingMobileBackground}
|
||||
backgroundContent={
|
||||
isQuestionStep ? <BackgroundVideo source={videos?.celine} /> : null
|
||||
}
|
||||
>
|
||||
<View
|
||||
onLayout={(event) => {
|
||||
const nextHeight = event?.nativeEvent?.layout?.height
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Platform, View } from 'react-native'
|
||||
import { Platform, StyleSheet, View } from 'react-native'
|
||||
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||
import { background } from '../../assets'
|
||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from '../../data/data'
|
||||
@@ -24,7 +25,7 @@ const MAX_STEP_INDEX = 8
|
||||
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
|
||||
|
||||
const CreateLyricsWithAi = () => {
|
||||
const { selectedProject } = useUser()
|
||||
const { selectedProject, videos } = useUser()
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const handleLyricsError = React.useCallback(() => {
|
||||
setSelectedIndex(7)
|
||||
@@ -385,13 +386,18 @@ const CreateLyricsWithAi = () => {
|
||||
]
|
||||
|
||||
const currentStep = steps[selectedIndex]
|
||||
const isQuestionStep = selectedIndex < MAX_STEP_INDEX
|
||||
|
||||
return (
|
||||
<Page
|
||||
headerType="NONE"
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 64 : 100}
|
||||
backgroundImg={background.libraryBgWeb}
|
||||
backgroundImg={isQuestionStep ? null : background.libraryBgWeb}
|
||||
backgroundContent={
|
||||
isQuestionStep ? <BackgroundVideo source={videos?.celineWeb} /> : null
|
||||
}
|
||||
containerStyle={isQuestionStep ? styles.videoQuestionPage : undefined}
|
||||
>
|
||||
<MusicLandHeader onPressBack={onPressBack} progress={progress} />
|
||||
<View
|
||||
@@ -429,3 +435,10 @@ const CreateLyricsWithAi = () => {
|
||||
}
|
||||
|
||||
export default CreateLyricsWithAi
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
videoQuestionPage: {
|
||||
alignSelf: 'flex-end',
|
||||
marginRight: '4%',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -328,7 +328,7 @@ const Lyrics = ({ navigation }) => {
|
||||
return
|
||||
}
|
||||
const handleNavigateToStudio = () => {
|
||||
const targetRoute = beatmakerStage?.route || Routes.Compose
|
||||
const targetRoute = beatmakerStage?.route || Routes.ComposeSong
|
||||
navigate(targetRoute, {
|
||||
...(beatmakerStage?.params || {}),
|
||||
fromLyrics: true,
|
||||
|
||||
@@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
|
||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import { Input } from '../../components/Input'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
@@ -19,7 +18,6 @@ import { background } from '../../assets'
|
||||
|
||||
export const ChooseCoverType = () => {
|
||||
const isIOS = Platform.OS === 'ios'
|
||||
const [showIntro, setShowIntro] = useState(true)
|
||||
const [choiceVisible, setChoiceVisible] = useState(false)
|
||||
const [pseudoVisible, setPseudoVisible] = useState(false)
|
||||
const [choiceDismissAction, setChoiceDismissAction] = useState(null)
|
||||
@@ -33,12 +31,6 @@ export const ChooseCoverType = () => {
|
||||
const { setTooltip } = useMinuit()
|
||||
|
||||
const projectId = selectedProject?.id || selectedProjectId || null
|
||||
const hasFinalCover = !!selectedProject?.coverUrl
|
||||
const hasGeneratedOptions = Array.isArray(selectedProject?.cover?.options)
|
||||
? selectedProject.cover.options.length > 0
|
||||
: false
|
||||
const generateCoverLabel = hasFinalCover ? 'Modifier la pochette' : 'Générer la pochette'
|
||||
|
||||
const hasArtistPreference = useMemo(() => {
|
||||
if (currentUserData?.userName) return true
|
||||
const pref = currentUserData?.artistNamePreference
|
||||
@@ -108,13 +100,9 @@ export const ChooseCoverType = () => {
|
||||
[currentUID, projectId]
|
||||
)
|
||||
|
||||
const handleGenerateCover = useCallback(() => {
|
||||
if (hasFinalCover || hasGeneratedOptions) {
|
||||
navigate(Routes.ValidateCover)
|
||||
return
|
||||
}
|
||||
const handleShowCover = useCallback(() => {
|
||||
ensureArtistPreference(() => navigate(Routes.PouchReady))
|
||||
}, [ensureArtistPreference, hasFinalCover, hasGeneratedOptions])
|
||||
}, [ensureArtistPreference])
|
||||
|
||||
const closeChoiceModal = useCallback(() => {
|
||||
setChoiceVisible(false)
|
||||
@@ -276,15 +264,11 @@ export const ChooseCoverType = () => {
|
||||
onPress={handlePickUserImage}
|
||||
/> */}
|
||||
<GradientButton
|
||||
title={generateCoverLabel}
|
||||
onPress={handleGenerateCover}
|
||||
title="Voir ma pochette"
|
||||
onPress={handleShowCover}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{/*<FullscreenIntroVideo*/}
|
||||
{/* visible={showIntro}*/}
|
||||
{/* onClose={() => setShowIntro(false)}*/}
|
||||
{/*/>*/}
|
||||
<Modal
|
||||
visible={choiceVisible}
|
||||
transparent
|
||||
|
||||
+215
-873
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
export const createMusiclandPlaybackCover = async () => {
|
||||
throw new Error('cover_client_rendering_is_web_only')
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Asset } from 'expo-asset'
|
||||
import { img } from '../assets'
|
||||
|
||||
const COVER_SIZE = 1024
|
||||
const PROFILE_SIZE = 228
|
||||
|
||||
const loadImage = (source) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = new window.Image()
|
||||
image.crossOrigin = 'anonymous'
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => reject(new Error('cover_image_load_failed'))
|
||||
image.src = source
|
||||
})
|
||||
|
||||
const drawCenteredText = (context, text, y, fontSize, letterSpacing) => {
|
||||
const characters = Array.from(text)
|
||||
const charactersWidth = characters.reduce(
|
||||
(width, character) => width + context.measureText(character).width,
|
||||
0
|
||||
)
|
||||
const totalWidth = charactersWidth + Math.max(characters.length - 1, 0) * letterSpacing
|
||||
let x = (COVER_SIZE - totalWidth) / 2
|
||||
|
||||
characters.forEach((character) => {
|
||||
context.fillText(character, x, y)
|
||||
x += context.measureText(character).width + letterSpacing
|
||||
})
|
||||
}
|
||||
|
||||
const getArtistFontSize = (artistName) => {
|
||||
if (artistName.length <= 16) return 52
|
||||
if (artistName.length <= 24) return 44
|
||||
return 36
|
||||
}
|
||||
|
||||
const drawProfilePicture = (context, profilePicture) => {
|
||||
const sourceRatio = profilePicture.width / profilePicture.height
|
||||
let sourceX = 0
|
||||
let sourceY = 0
|
||||
let sourceWidth = profilePicture.width
|
||||
let sourceHeight = profilePicture.height
|
||||
|
||||
if (sourceRatio > 1) {
|
||||
sourceWidth = profilePicture.height
|
||||
sourceX = (profilePicture.width - sourceWidth) / 2
|
||||
} else {
|
||||
sourceHeight = profilePicture.width
|
||||
sourceY = (profilePicture.height - sourceHeight) / 2
|
||||
}
|
||||
|
||||
context.save()
|
||||
context.beginPath()
|
||||
context.arc(512, 568, PROFILE_SIZE / 2, 0, Math.PI * 2)
|
||||
context.clip()
|
||||
context.drawImage(
|
||||
profilePicture,
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
398,
|
||||
454,
|
||||
PROFILE_SIZE,
|
||||
PROFILE_SIZE
|
||||
)
|
||||
context.restore()
|
||||
}
|
||||
|
||||
export const createMusiclandPlaybackCover = async ({ artistName, profilePictureURL }) => {
|
||||
const templateAsset = Asset.fromModule(img.musiclandPlaybackCover)
|
||||
await templateAsset.downloadAsync()
|
||||
const assetUri = templateAsset.localUri || templateAsset.uri
|
||||
if (!assetUri) {
|
||||
throw new Error('cover_template_missing')
|
||||
}
|
||||
|
||||
const [template, profilePicture] = await Promise.all([
|
||||
loadImage(assetUri),
|
||||
profilePictureURL ? loadImage(profilePictureURL) : Promise.resolve(null),
|
||||
])
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = COVER_SIZE
|
||||
canvas.height = COVER_SIZE
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
context.drawImage(template, 0, 0, COVER_SIZE, COVER_SIZE)
|
||||
if (profilePicture) {
|
||||
drawProfilePicture(context, profilePicture)
|
||||
}
|
||||
|
||||
context.fillStyle = '#FFFFFF'
|
||||
context.textBaseline = 'middle'
|
||||
context.font = `400 ${getArtistFontSize(artistName)}px Inter, Arial, sans-serif`
|
||||
drawCenteredText(context, artistName, 824, getArtistFontSize(artistName), 3)
|
||||
context.font = '400 54px Inter, Arial, sans-serif'
|
||||
drawCenteredText(context, 'PLAYBACKER', 963, 54, 5)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
return
|
||||
}
|
||||
reject(new Error('cover_export_failed'))
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
@@ -136,7 +136,7 @@ const getStageDescription = (key, metadata) => {
|
||||
return 'Commencer la création de votre morceau'
|
||||
case 'director':
|
||||
if (!hasCover) {
|
||||
return 'Générez une cover pour débloquer le playback'
|
||||
return 'Validez votre pochette pour débloquer le playback'
|
||||
}
|
||||
if (isPlaybackGenerating) {
|
||||
return 'Votre playback est en cours de préparation'
|
||||
@@ -222,18 +222,17 @@ export const getStageAction = (key, project) => {
|
||||
switch (key) {
|
||||
case 'songwriter':
|
||||
return {
|
||||
route: getLyricsCount(project) > 0 ? Routes.Lyrics : Routes.WritingLyrics,
|
||||
route: getLyricsCount(project) > 0 ? Routes.Lyrics : Routes.CreateLyricsWithAi,
|
||||
}
|
||||
case 'beatmaker': {
|
||||
if (!project) {
|
||||
return { route: Routes.Compose }
|
||||
return { route: Routes.ComposeSong }
|
||||
}
|
||||
|
||||
const musicStatus = project?.musicStatus || null
|
||||
const songUrl = project?.songUrl || null
|
||||
const coverStatus = project?.coverStatus || null
|
||||
const cover = project?.cover || {}
|
||||
const hasCoverBackground = !!cover?.generatedBackground
|
||||
const hasCoverResult = !!cover?.result
|
||||
const hasFinalCover = !!project?.coverUrl
|
||||
|
||||
@@ -245,26 +244,18 @@ export const getStageAction = (key, project) => {
|
||||
if (musicStatus === 'GENERATED') {
|
||||
return { route: Routes.SongReady }
|
||||
}
|
||||
return { route: Routes.Compose }
|
||||
return { route: Routes.ComposeSong }
|
||||
}
|
||||
|
||||
if (coverStatus === 'GENERATING') {
|
||||
return { route: Routes.PouchReady }
|
||||
}
|
||||
|
||||
if (!hasCoverBackground) {
|
||||
if (!hasCoverResult) {
|
||||
return { route: Routes.ChooseCoverType }
|
||||
}
|
||||
|
||||
if (!hasCoverResult) {
|
||||
return { route: Routes.PouchReady }
|
||||
}
|
||||
|
||||
if (!hasFinalCover) {
|
||||
return { route: Routes.ValidateCover }
|
||||
}
|
||||
|
||||
return { route: Routes.ChooseCoverType }
|
||||
return { route: hasFinalCover ? Routes.ChooseCoverType : Routes.PouchReady }
|
||||
}
|
||||
case 'director':
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import firebase, { usersRef } from '../config/firebase'
|
||||
import { Routes } from '../navigation/Routes'
|
||||
|
||||
export const MUSICLAND_LAUNCH_DATE = null
|
||||
|
||||
export const isGlobalListeningLaunchPeriodActive = (now = new Date()) => {
|
||||
if (!MUSICLAND_LAUNCH_DATE) {
|
||||
return false
|
||||
}
|
||||
|
||||
const launchDate = new Date(MUSICLAND_LAUNCH_DATE)
|
||||
if (Number.isNaN(launchDate.getTime())) {
|
||||
return false
|
||||
}
|
||||
|
||||
const launchPeriodEnd = new Date(launchDate)
|
||||
launchPeriodEnd.setMonth(launchPeriodEnd.getMonth() + 1)
|
||||
|
||||
return now >= launchDate && now < launchPeriodEnd
|
||||
}
|
||||
|
||||
export const getRouteAfterAuthentication = (userData = {}) =>
|
||||
userData?.registrationFlowRequired === true
|
||||
? Routes.RegistrationChoice
|
||||
: Routes.BottomTab
|
||||
|
||||
export const getExperienceReset = (experience) => {
|
||||
if (experience === 'listen') {
|
||||
return {
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: Routes.BottomTab,
|
||||
params: { screen: Routes.HitParade },
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: Routes.BottomTab,
|
||||
params: {
|
||||
screen: Routes.HomeStack,
|
||||
params: { screen: Routes.Home },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export const completeRegistrationFlow = async (experience) => {
|
||||
const uid = firebase.auth().currentUser?.uid
|
||||
if (!uid) {
|
||||
throw new Error('Aucun utilisateur connecté')
|
||||
}
|
||||
|
||||
if (experience !== 'listen' && experience !== 'creation') {
|
||||
throw new Error('Expérience MusicLand inconnue')
|
||||
}
|
||||
|
||||
await usersRef.doc(uid).set(
|
||||
{
|
||||
registrationFlowRequired: false,
|
||||
initialExperience: experience,
|
||||
registrationFlowCompletedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
||||
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user