This commit is contained in:
2026-07-01 12:39:59 +02:00
parent b054e36df0
commit 8a0fa70c10
26 changed files with 1804 additions and 724 deletions
+299
View File
@@ -0,0 +1,299 @@
import { useState, useRef } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Gamepad2, 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'
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">
<div className="rounded-xl bg-accent/20 p-2.5 text-accent">
<Gamepad2 size={24} />
</div>
<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>
)
}