fa4bf546b4
Nouveau logo : console de jeu portable avec une coche sur l'écran, sur un badge dégradé indigo→teal (couleurs d'accent de l'app). - Composant réutilisable Logo.tsx (id de dégradé unique via useId) - public/logo.svg + favicon dans index.html - Remplace l'icône Gamepad2 dans la sidebar, le login et l'onboarding Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
import { useState, useRef } from 'react'
|
|
import { AnimatePresence, motion } from 'framer-motion'
|
|
import { ArrowRight, ArrowLeft, Check, Loader2, Sparkles, Camera } from 'lucide-react'
|
|
import { supa } from '../lib/supabase'
|
|
import { useApp } from '../lib/AppContext'
|
|
import { useFeedback } from '../lib/feedback'
|
|
import { uploadAvatar } from '../lib/avatar'
|
|
import {
|
|
MemberRole,
|
|
Profile,
|
|
POLE_LABELS,
|
|
poleColor,
|
|
SELF_ASSIGNABLE_POLES,
|
|
CRITICAL_POLES,
|
|
memberPoles
|
|
} from '../lib/types'
|
|
import { Avatar } from '../components/ui'
|
|
import Logo from '../components/Logo'
|
|
|
|
const COLORS = ['#7c5cff', '#00d2b8', '#ff5c8a', '#ffa53c', '#3ca8ff', '#9bdb4d', '#e15cff', '#ff6b6b']
|
|
|
|
export default function Onboarding(): JSX.Element {
|
|
const { profile, refreshProfile } = useApp()
|
|
const { toast } = useFeedback()
|
|
|
|
// Rôles critiques déjà attribués par l'admin (ex : chef de projet) : on les
|
|
// conserve, mais le membre ne peut pas se les donner lui-même.
|
|
const keptCritical = memberPoles(profile ?? {}).filter((p) => CRITICAL_POLES.includes(p))
|
|
|
|
// Découpe le nom existant (« Prénom Nom ») s'il y en a un.
|
|
const existingName = profile && profile.full_name !== 'Membre' ? profile.full_name : ''
|
|
const [step, setStep] = useState(0)
|
|
const [firstName, setFirstName] = useState(existingName.split(' ')[0] ?? '')
|
|
const [lastName, setLastName] = useState(existingName.split(' ').slice(1).join(' '))
|
|
const [color, setColor] = useState(profile?.avatar_color ?? COLORS[0])
|
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(profile?.avatar_url ?? null)
|
|
const [uploading, setUploading] = useState(false)
|
|
const [poles, setPoles] = useState<MemberRole[]>(
|
|
memberPoles(profile ?? {}).filter((p) => SELF_ASSIGNABLE_POLES.includes(p))
|
|
)
|
|
const [busy, setBusy] = useState(false)
|
|
const fileRef = useRef<HTMLInputElement>(null)
|
|
|
|
const togglePole = (p: MemberRole): void =>
|
|
setPoles((cur) => (cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p]))
|
|
|
|
const fullName = `${firstName.trim()} ${lastName.trim()}`.trim()
|
|
const previewProfile = {
|
|
full_name: fullName || '?',
|
|
avatar_color: color,
|
|
avatar_url: avatarUrl
|
|
} as unknown as Profile
|
|
|
|
const pickPhoto = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
|
const file = e.target.files?.[0]
|
|
if (!file || !profile) return
|
|
if (!file.type.startsWith('image/')) {
|
|
toast('Choisis un fichier image.', 'error')
|
|
return
|
|
}
|
|
setUploading(true)
|
|
try {
|
|
const url = await uploadAvatar(file, profile.id)
|
|
setAvatarUrl(url)
|
|
} catch (err) {
|
|
toast((err as Error).message, 'error')
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
const steps = ['Ton nom', 'Ta photo', 'Tes pôles']
|
|
const canNext = step === 0 ? firstName.trim().length > 0 : true
|
|
|
|
const finish = async (): Promise<void> => {
|
|
if (!profile) return
|
|
setBusy(true)
|
|
const roles = Array.from(new Set([...poles, ...keptCritical]))
|
|
const finalRoles = roles.length ? roles : (['other'] as MemberRole[])
|
|
const { error } = await supa()
|
|
.from('profiles')
|
|
.update({
|
|
full_name: fullName || 'Membre',
|
|
avatar_color: color,
|
|
avatar_url: avatarUrl,
|
|
roles: finalRoles,
|
|
role: finalRoles[0],
|
|
onboarded: true
|
|
})
|
|
.eq('id', profile.id)
|
|
if (error) {
|
|
setBusy(false)
|
|
toast(error.message, 'error')
|
|
return
|
|
}
|
|
await refreshProfile()
|
|
// Le passage onboarded=true bascule automatiquement vers l'app.
|
|
}
|
|
|
|
const next = (): void => {
|
|
if (step < steps.length - 1) setStep((s) => s + 1)
|
|
else finish()
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full items-center justify-center p-6">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
|
|
className="card w-full max-w-lg"
|
|
>
|
|
{/* En-tête */}
|
|
<div className="mb-5 flex items-center gap-3">
|
|
<Logo size={44} className="rounded-xl shadow-sm" />
|
|
<div>
|
|
<h1 className="text-lg font-semibold text-ink">Bienvenue dans l'équipe 👋</h1>
|
|
<p className="text-sm text-muted">Configurons ton profil en 3 étapes.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Barre de progression */}
|
|
<div className="mb-6 flex gap-1.5">
|
|
{steps.map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className={`h-1.5 flex-1 rounded-full transition-colors ${
|
|
i <= step ? 'bg-accent' : 'bg-panel2'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={step}
|
|
initial={{ opacity: 0, x: 24 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -24 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{step === 0 && (
|
|
<div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="label">Prénom</label>
|
|
<input
|
|
className="input"
|
|
autoFocus
|
|
placeholder="Alex"
|
|
value={firstName}
|
|
onChange={(e) => setFirstName(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && canNext && next()}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Nom</label>
|
|
<input
|
|
className="input"
|
|
placeholder="Martin"
|
|
value={lastName}
|
|
onChange={(e) => setLastName(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && canNext && next()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="mt-2 text-xs text-subtle">
|
|
C'est le nom que tes coéquipiers verront sur les tâches.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{step === 1 && (
|
|
<div>
|
|
<label className="label">Ta photo de profil</label>
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative">
|
|
<Avatar profile={previewProfile} size={72} />
|
|
{uploading && (
|
|
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
|
|
<Loader2 className="animate-spin text-white" size={22} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={pickPhoto}
|
|
/>
|
|
<button
|
|
className="btn-ghost"
|
|
onClick={() => fileRef.current?.click()}
|
|
disabled={uploading}
|
|
>
|
|
<Camera size={16} /> {avatarUrl ? 'Changer la photo' : 'Ajouter une photo'}
|
|
</button>
|
|
{avatarUrl && (
|
|
<button
|
|
className="ml-2 text-xs text-muted hover:text-rose-600"
|
|
onClick={() => setAvatarUrl(null)}
|
|
>
|
|
Retirer
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Repli : couleur des initiales si aucune photo */}
|
|
{!avatarUrl && (
|
|
<div className="mt-4">
|
|
<p className="label">Ou choisis une couleur</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{COLORS.map((c) => (
|
|
<button
|
|
key={c}
|
|
onClick={() => setColor(c)}
|
|
className={`h-8 w-8 rounded-full transition-transform hover:scale-110 ${
|
|
color === c ? 'ring-2 ring-ink ring-offset-2 ring-offset-panel' : ''
|
|
}`}
|
|
style={{ background: c }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<p className="mt-3 text-xs text-subtle">
|
|
Optionnel — tu pourras la changer plus tard depuis « Équipe ».
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<div>
|
|
<label className="label">Dans quel(s) pôle(s) travailles-tu ?</label>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{SELF_ASSIGNABLE_POLES.map((p) => {
|
|
const on = poles.includes(p)
|
|
return (
|
|
<button
|
|
key={p}
|
|
type="button"
|
|
onClick={() => togglePole(p)}
|
|
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
on
|
|
? `${poleColor(p)} border-transparent ring-2 ring-accent/40`
|
|
: 'border-border text-muted hover:border-accent hover:text-ink'
|
|
}`}
|
|
>
|
|
{on && <Check size={13} />}
|
|
{POLE_LABELS[p]}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
{keptCritical.length > 0 && (
|
|
<p className="mt-3 flex items-center gap-1.5 text-xs text-accent2">
|
|
<Sparkles size={13} />
|
|
Ton rôle {keptCritical.map((p) => POLE_LABELS[p]).join(', ')} t'a été attribué par
|
|
l'administrateur.
|
|
</p>
|
|
)}
|
|
<p className="mt-2 text-xs text-subtle">
|
|
Tu recevras une notification quand une tâche est confiée à un de tes pôles.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
|
|
{/* Navigation */}
|
|
<div className="mt-6 flex items-center justify-between">
|
|
{step > 0 ? (
|
|
<button className="btn-ghost" onClick={() => setStep((s) => s - 1)} disabled={busy}>
|
|
<ArrowLeft size={16} /> Retour
|
|
</button>
|
|
) : (
|
|
<span />
|
|
)}
|
|
<button className="btn-primary" onClick={next} disabled={!canNext || busy}>
|
|
{busy && <Loader2 className="animate-spin" size={16} />}
|
|
{step < steps.length - 1 ? (
|
|
<>
|
|
Continuer <ArrowRight size={16} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<Check size={16} /> Terminer
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)
|
|
}
|