forked from yvv/decision
v2 : les 14 écrans — le tour complet de la démocratie d'exercice
- Aujourd'hui (Fil 13 sections + capture sticky) + Le chemin (tunnel 2 gestes, Q0 inline, 3 chips, dérogation asymétrique, alternatives réglage/consignation) - Registre + fiche décision (timeline, périmètre premier/second lieu auditable, affluence non-ignorable, S'instruire condensé, éléments + cartographie de clôture, épreuve du réel, Remettre en question, PV A4, gravure) - Salle de vote 5 modalités (consentement, nuancé+histogramme, binaire hérité avec jauge inertielle, Réglage collectif complet — faisceau, médiane basse, Pour moi, Explorer, cristallisation-geste —, élection à départage humain) - Textes (Pacte en clair, document vivant, diff, vue projetée, Atelier des formules porté du v1 sur le moteur unique) + Mandats (faits comptés, feux de la rampe, wizard 3 étapes) + Observatoire (consigner→observer→protocoliser) - Onboarding 7 gabarits + Données locales (export/import, attributs, atelier) - voting→framing gardé (Reformuler d'un réglage figé non cristallisé) - Pages v1 supprimées (login, documents, mandates, protocols, sanctuary, tools, decisions/new) — 342 tests verts, build zéro erreur Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* /creer — onboarding en 3 étapes (layout bare) : le premier usage EST le
|
||||
* tutoriel. Étape 1 identité, étape 2 gabarit, étape 3 personnes.
|
||||
* Atterrit sur / où la première décision « Adopter notre Pacte » attend.
|
||||
*/
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import type { SeedName } from '~/stores/collective'
|
||||
import type { TemplateId } from '~/data/templates'
|
||||
import { CREATE_SIGNATURE, FIRST_DECISION, OPENING_DREAM } from '~/lexicon'
|
||||
import { COLOR_CHOICES } from '~/components/onboarding/OnboardingIdentity.vue'
|
||||
import { TEMPLATE_ICONS } from '~/components/onboarding/OnboardingTemplates.vue'
|
||||
|
||||
definePageMeta({ layout: 'bare' })
|
||||
|
||||
const store = useCollectiveStore()
|
||||
|
||||
const step = ref<1 | 2 | 3>(1)
|
||||
const name = ref('')
|
||||
const color = ref(COLOR_CHOICES[0]!.hex)
|
||||
const template = ref<TemplateId | null>(null)
|
||||
const meName = ref('')
|
||||
const members = ref<string[]>([])
|
||||
const busy = ref(false)
|
||||
const issues = ref<string[]>([])
|
||||
|
||||
const STEP_TITLES: Record<1 | 2 | 3, string> = {
|
||||
1: 'Donne-lui un nom, une couleur, une ambiance.',
|
||||
2: 'Choisis un point de départ — tout reste amendable par décision.',
|
||||
3: 'Toi, puis les premières personnes autour de la table.',
|
||||
}
|
||||
|
||||
const canContinue = computed(() => {
|
||||
if (busy.value) return false
|
||||
if (step.value === 1) return name.value.trim().length > 0
|
||||
if (step.value === 2) return template.value !== null
|
||||
return meName.value.trim().length > 0
|
||||
})
|
||||
|
||||
function next() {
|
||||
if (!canContinue.value) return
|
||||
issues.value = []
|
||||
if (step.value < 3) step.value++
|
||||
else void create()
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (busy.value || step.value === 1) return
|
||||
issues.value = []
|
||||
step.value--
|
||||
}
|
||||
|
||||
/** « Les Jardins du Canal » → « les-jardins-du-canal ». */
|
||||
function slugify(text: string): string {
|
||||
return (
|
||||
text
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'collectif'
|
||||
)
|
||||
}
|
||||
|
||||
function keepBlockingIssues(all: { level: string; message: string }[]): boolean {
|
||||
const blocking = all.filter(i => i.level === 'error').map(i => i.message)
|
||||
issues.value = blocking
|
||||
return blocking.length > 0
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!template.value || busy.value) return
|
||||
busy.value = true
|
||||
const result = await store.createFromTemplate(template.value, {
|
||||
name: name.value.trim(),
|
||||
slug: slugify(name.value),
|
||||
color: color.value,
|
||||
icon: TEMPLATE_ICONS[template.value],
|
||||
meName: meName.value.trim(),
|
||||
memberNames: members.value,
|
||||
})
|
||||
if (keepBlockingIssues(result.issues) || !result.state) {
|
||||
busy.value = false
|
||||
return
|
||||
}
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
async function explore(seed: SeedName) {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
issues.value = []
|
||||
const result = await store.loadSeed(seed)
|
||||
if (keepBlockingIssues(result.issues) || !result.state) {
|
||||
busy.value = false
|
||||
return
|
||||
}
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key !== 'Enter') return
|
||||
const target = ev.target as HTMLElement | null
|
||||
if (target && ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(target.tagName)) return
|
||||
next()
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="creer">
|
||||
<header class="creer__head">
|
||||
<h1 class="creer__dream">{{ OPENING_DREAM }}</h1>
|
||||
<p class="creer__step-title">{{ STEP_TITLES[step] }}</p>
|
||||
</header>
|
||||
|
||||
<OnboardingIdentity
|
||||
v-if="step === 1"
|
||||
v-model:name="name"
|
||||
v-model:color="color"
|
||||
@next="next"
|
||||
/>
|
||||
<OnboardingTemplates
|
||||
v-else-if="step === 2"
|
||||
v-model="template"
|
||||
@next="next"
|
||||
@seed="explore"
|
||||
/>
|
||||
<OnboardingMembers
|
||||
v-else
|
||||
v-model:me-name="meName"
|
||||
v-model:members="members"
|
||||
/>
|
||||
|
||||
<ul v-if="issues.length" class="creer__issues">
|
||||
<li v-for="msg in issues" :key="msg">
|
||||
<UIcon name="i-lucide-circle-alert" /> {{ msg }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<footer class="creer__nav">
|
||||
<button v-if="step > 1" type="button" class="ld-btn ld-btn--quiet" :disabled="busy" @click="back">
|
||||
<UIcon name="i-lucide-arrow-left" /> Retour
|
||||
</button>
|
||||
<span v-else class="creer__spacer" />
|
||||
|
||||
<span class="creer__dots" role="status" :aria-label="`Étape ${step} sur 3`">
|
||||
<span
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="creer__dot"
|
||||
:class="{ 'creer__dot--active': step === n, 'creer__dot--done': step > n }"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<button type="button" class="ld-btn" :disabled="!canContinue" @click="next">
|
||||
<template v-if="step < 3">
|
||||
Continuer <UIcon name="i-lucide-arrow-right" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<UIcon name="i-lucide-sparkles" /> Fonder le collectif
|
||||
</template>
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<p v-if="step === 3" class="creer__hint">
|
||||
La première décision t'attendra : « {{ FIRST_DECISION }} ».
|
||||
</p>
|
||||
<p v-if="step === 3" class="creer__signature">{{ CREATE_SIGNATURE }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.creer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(1.5rem, 4vw, 2.25rem);
|
||||
width: min(100%, 56rem);
|
||||
margin-inline: auto;
|
||||
padding-bottom: 3.5rem;
|
||||
}
|
||||
|
||||
.creer__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.creer__dream {
|
||||
margin: 0;
|
||||
font-size: clamp(1.6rem, 5vw, 2.6rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.creer__step-title {
|
||||
margin: 0;
|
||||
font-size: clamp(0.9375rem, 2.5vw, 1.0625rem);
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.creer__issues {
|
||||
margin: 0;
|
||||
padding: 0.875rem 1.1rem;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
border-radius: var(--r-input);
|
||||
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
|
||||
color: var(--mood-error);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.creer__issues li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.creer__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.creer__spacer {
|
||||
min-width: 5.5rem;
|
||||
}
|
||||
|
||||
.creer__dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.creer__dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-text-muted);
|
||||
opacity: 0.3;
|
||||
transition: transform 0.12s ease, opacity 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.creer__dot--active {
|
||||
background: var(--mood-accent);
|
||||
opacity: 1;
|
||||
transform: scale(1.25);
|
||||
}
|
||||
.creer__dot--done {
|
||||
background: var(--mood-accent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.creer__hint {
|
||||
margin: -0.75rem 0 0;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.creer__signature {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
opacity: 0.65;
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.creer__nav {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.creer__dots {
|
||||
order: -1;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.creer__spacer { display: none; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user