feat(aep): M1 — formulaire /proposer assoupli (liens + texte libre + chip)

Le contributeur colle un lien (ou plusieurs) et explique en texte libre
pourquoi c'est pertinent, sans template ni minimum de caractères. Le type
devient une suggestion optionnelle (chips). Les 5 formulaires typés restent
disponibles derrière « Mode détaillé » (replié par défaut).

- utils/submitLibre.ts : schéma Zod partagé client/serveur + mapping vers les
  colonnes NocoDB existantes (aucun changement de schéma) — table orgas par
  défaut, table ressources_references si le chip « Références » est choisi.
- components/FormLibre.vue : liens dynamiques (1-10), texte libre, chips,
  email optionnel, honeypot.
- server/api/submit/index.post.ts : branche mode=libre en amont du schéma
  typé existant ; email Jules remplacé par ntfy (server/utils/notify.ts) —
  jamais l'email ni le texte du contributeur dans la notif.
- scripts/test-submit-libre.mjs : 6 cas contre le vrai schéma Zod (via jiti).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jules
2026-09-27 15:57:11 +02:00
co-authored by Claude Opus 5.5
parent 58937a04a3
commit 09d1dc9d5f
6 changed files with 696 additions and 56 deletions
+240
View File
@@ -0,0 +1,240 @@
<template>
<form @submit.prevent="submit" class="proposer-form proposer-form--libre" novalidate>
<div class="field-group" :class="{ 'field-error': errors.texte }">
<label id="libre-liens-label">Tes liens <span class="label-hint">(1 ou plusieurs — sites, PDF, articles...)</span></label>
<div class="liens-list" role="group" aria-labelledby="libre-liens-label">
<div v-for="(lien, i) in liens" :key="i" class="lien-row">
<input
:id="`libre-lien-${i}`"
v-model="liens[i]"
type="url"
inputmode="url"
placeholder="https://..."
autocomplete="off"
@blur="validateAll(false)"
/>
<button
v-if="liens.length > 1"
type="button"
class="lien-remove"
:aria-label="`Retirer ce lien`"
@click="removeLien(i)"
>×</button>
</div>
</div>
<button v-if="liens.length < 10" type="button" class="btn-add-lien" @click="addLien">+ un autre lien</button>
<span v-if="errors.texte" id="libre-texte-error" class="error-msg" role="alert">{{ errors.texte }}</span>
</div>
<div class="field-group">
<label for="libre-texte">Pourquoi c'est utile, pour toi ? <span class="label-hint">(pas de format imposé)</span></label>
<textarea
id="libre-texte"
v-model="texte"
rows="4"
placeholder="Dis-le avec tes mots : ce que ça t'a apporté, pourquoi tu le partages..."
:aria-describedby="errors.texte ? 'libre-texte-error' : undefined"
/>
</div>
<div class="field-group">
<label id="libre-type-label">Type suggéré <span class="label-hint">(optionnel — ça nous aide à trier)</span></label>
<ChipGroup aria-label="Type suggéré">
<Chip
v-for="t in TYPES_SUGGERES"
:key="t"
:label="TYPES_SUGGERES_LABELS[t]"
variant="fonction"
:active="typeSuggere === t"
@toggle="toggleType(t)"
/>
</ChipGroup>
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="libre-email">Ton email <span class="label-hint">(optionnel — pour qu'on te tienne au courant)</span></label>
<input
id="libre-email"
v-model="email"
type="email"
placeholder="ton@email.fr"
autocomplete="email"
@blur="validateAll(false)"
/>
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<!-- Honeypot anti-spam : caché visuellement et aux lecteurs d'écran, un bot le remplit. -->
<div class="hp-field" aria-hidden="true">
<label for="libre-site-web">Site web</label>
<input id="libre-site-web" v-model="siteWeb" type="text" tabindex="-1" autocomplete="off" />
</div>
<div v-if="serverError" class="server-error" role="alert" aria-live="assertive">
<strong>Erreur :</strong> {{ serverError }}
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" :disabled="submitting">
{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}
</button>
</div>
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
</form>
</template>
<script setup lang="ts">
import { LibreSubmitSchema, TYPES_SUGGERES, TYPES_SUGGERES_LABELS, type TypeSuggere } from '~/utils/submitLibre'
const props = defineProps<{ presetType?: TypeSuggere }>()
const emit = defineEmits<{ success: [] }>()
const liens = reactive<string[]>([''])
const texte = ref('')
const email = ref('')
const typeSuggere = ref<TypeSuggere | undefined>(props.presetType)
const siteWeb = ref('') // honeypot
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function addLien() {
if (liens.length < 10) liens.push('')
}
function removeLien(i: number) {
liens.splice(i, 1)
if (liens.length === 0) liens.push('')
}
function toggleType(t: TypeSuggere) {
typeSuggere.value = typeSuggere.value === t ? undefined : t
}
function currentPayload() {
return {
liens: liens.map((l) => l.trim()).filter(Boolean),
texte: texte.value,
submitted_by_email: email.value || undefined,
type_suggere: typeSuggere.value,
site_web: siteWeb.value,
}
}
function validateAll(scrollToError = true): boolean {
const result = LibreSubmitSchema.safeParse(currentPayload())
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.keys(errors).forEach((k) => delete errors[k])
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
if (scrollToError) {
nextTick(() => {
document.querySelector('.field-error')?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
return false
}
Object.keys(errors).forEach((k) => delete errors[k])
return true
}
async function submit() {
serverError.value = ''
if (!validateAll(true)) return
submitting.value = true
try {
await $fetch('/api/submit', {
method: 'POST',
body: { mode: 'libre', ...currentPayload() },
})
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) {
Object.assign(errors, Object.fromEntries(Object.entries(e.data as Record<string, string[]>).map(([k, v]) => [k, Array.isArray(v) ? v[0] : String(v)])))
serverError.value = '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';
.liens-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.375rem;
}
.lien-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.lien-row input {
flex: 1;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
color: var(--nav-text);
background: var(--nav-surface);
font-family: inherit;
box-sizing: border-box;
}
.lien-row input:focus {
outline: none;
border-color: var(--nav-primary-solid);
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
}
.lien-remove {
flex-shrink: 0;
width: 32px;
height: 32px;
border: 1px solid rgba(26, 34, 56, 0.2);
background: transparent;
border-radius: 6px;
color: var(--nav-text-muted);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.lien-remove:hover {
border-color: #c0392b;
color: #c0392b;
}
.btn-add-lien {
align-self: flex-start;
margin-top: 0.5rem;
padding: 0.4rem 0.75rem;
background: transparent;
border: 1px dashed rgba(26, 34, 56, 0.3);
border-radius: 6px;
font-size: 0.8rem;
color: var(--nav-text-muted);
cursor: pointer;
font-family: inherit;
}
.btn-add-lien:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
.hp-field {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
</style>