V1
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user