forked from yvv/decision
- 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>
290 lines
7.1 KiB
Vue
290 lines
7.1 KiB
Vue
<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>
|