Files
nav-carte/components/FormLibre.vue
T
JulesandClaude Opus 5.5 09d1dc9d5f 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>
2026-09-27 15:57:11 +02:00

241 lines
7.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>