Files
nav-carte/components/FormEcosysteme.vue
Jules Neny c860dabdc0 feat(proposer): interface 5 onglets + soumission Références vers NocoDB
- Page /proposer avec 5 onglets (Écosystème/Réseau/Jobs/Outils/Références RAG)
- 5 form components + form-styles.css partagé
- Submit multi-type avec submission_type vers NocoDB
- Server route references.post.ts réécrite pour NocoDB (table ressources_references)
- redirect 301 /contribuer → /proposer
- Suppression references-pending.json
2026-06-23 12:13:33 +02:00

185 lines
8.9 KiB
Vue

<template>
<form @submit.prevent="submit" class="proposer-form" novalidate>
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="eco-nom">Nom de l'organisation <span class="required">*</span></label>
<input id="eco-nom" v-model="form.nom" type="text" placeholder="Ex : UNSFA, Maison de l'Architecture..." autocomplete="organization" @blur="validateField('nom')" />
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="eco-url">Site web <span class="label-hint">(optionnel — recommandé pour l'enrichissement IA)</span></label>
<input id="eco-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="eco-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
<textarea id="eco-desc" v-model="form.description_user" rows="4" placeholder="Présente l'organisation en quelques mots : ses missions, son public, ce qu'elle apporte..." @blur="validateField('description_user')" />
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
</div>
</div>
<div class="field-group" :class="{ 'field-error': errors.echelle }">
<fieldset>
<legend>Échelle <span class="required">*</span> <span class="label-hint">(une seule)</span></legend>
<div class="radio-group">
<label v-for="opt in ECHELLES" :key="opt" class="radio-label" :class="{ active: form.echelle === opt }">
<input type="radio" :value="opt" v-model="form.echelle" name="eco-echelle" @change="validateField('echelle')" />{{ opt }}
</label>
</div>
</fieldset>
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.fonctions }">
<fieldset>
<legend>Fonctions <span class="required">*</span> <span class="label-hint">(1 à 5 l'ordre de clic = priorité)</span></legend>
<div class="checkbox-grid">
<label v-for="fn in FONCTIONS" :key="fn" class="checkbox-label" :class="{ active: form.fonctions.includes(fn), disabled: !form.fonctions.includes(fn) && form.fonctions.length >= 5 }">
<input type="checkbox" :value="fn" :checked="form.fonctions.includes(fn)" :disabled="!form.fonctions.includes(fn) && form.fonctions.length >= 5" @change="toggleFonction(fn)" />
<span class="fn-order" v-if="form.fonctions.includes(fn)">{{ form.fonctions.indexOf(fn) + 1 }}</span>{{ fn }}
</label>
</div>
</fieldset>
<span v-if="errors.fonctions" class="error-msg" role="alert">{{ errors.fonctions }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.territoire }">
<fieldset>
<legend>Territoire <span class="required">*</span></legend>
<div class="radio-group">
<label v-for="t in TERRITOIRES" :key="t" class="radio-label" :class="{ active: form.territoire === t }">
<input type="radio" :value="t" v-model="form.territoire" name="eco-territoire" @change="validateField('territoire')" />{{ t }}
</label>
</div>
</fieldset>
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
<label for="eco-ville">Ville principale <span class="label-hint">(optionnel — pour la géolocalisation sur la carte)</span></label>
<input id="eco-ville" v-model="form.localisation_ville" type="text" placeholder="Ex : Paris, Lyon, Bordeaux..." />
<span v-if="errors.localisation_ville" class="error-msg" role="alert">{{ errors.localisation_ville }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="eco-email">Ton email <span class="label-hint">(optionnel — pour le suivi de modération)</span></label>
<input id="eco-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
<div class="form-actions">
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer la fiche ' }}</button>
</div>
<p class="form-note">Ta fiche sera examinée par l'équipe avant publication.</p>
</form>
</template>
<script setup lang="ts">
import { z } from 'zod'
const ECHELLES = ['National', 'Régional', 'Local'] as const
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] as const
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale'] as const
const Schema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Sélectionne une échelle' }) }),
fonctions: z.array(z.string()).min(1, 'Sélectionne au moins une fonction').max(5, 'Maximum 5 fonctions'),
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Sélectionne un territoire' }) }),
localisation_ville: z.string().max(100).optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
const emit = defineEmits<{ success: [] }>()
const form = reactive({
nom: '', url: '', description_user: '', echelle: '' as string,
fonctions: [] as string[], territoire: '' as string,
localisation_ville: '', submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function validateField(field: string) {
const partial = Schema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = Schema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
function toggleFonction(fn: string) {
const idx = form.fonctions.indexOf(fn)
if (idx >= 0) form.fonctions.splice(idx, 1)
else if (form.fonctions.length < 5) form.fonctions.push(fn)
validateField('fonctions')
}
async function submit() {
serverError.value = ''
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit', {
method: 'POST',
body: {
submission_type: 'ecosysteme',
nom: form.nom,
url: form.url || undefined,
description_user: form.description_user,
echelle: form.echelle,
fonctions: form.fonctions,
territoire: form.territoire,
localisation_ville: form.localisation_ville || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
emit('success')
} catch (e: any) {
const status = e?.status ?? e?.statusCode
if (status === 429) {
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
} else if (status === 422 && e?.data) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@import './form-styles.css';
</style>