Files
nav-carte/pages/contribuer.vue

788 lines
23 KiB
Vue

<template>
<div class="contribuer-page">
<div class="contribuer-inner">
<!-- Retour -->
<NuxtLink to="/" class="back-link">
Retour à la carte
</NuxtLink>
<!-- En-tête -->
<div class="contribuer-header">
<h1>Proposer une ressource</h1>
<p class="contribuer-subtitle">
Tu connais une organisation utile aux architectes qui n'est pas encore référencée ?
Soumets-la ici — une IA enrichira la fiche et Jules validera sous 7 jours.
</p>
</div>
<!-- Message succès -->
<div v-if="success" class="success-block" role="status" aria-live="polite">
<div class="success-icon">✓</div>
<h2>Merci !</h2>
<p>Ta fiche est en cours de traitement.</p>
<p class="success-detail">
Une IA va scraper le site et enrichir la description.
Jules (et bientôt une équipe de modération) valide sous 7 jours.
</p>
<p v-if="trackingUrl" class="success-tracking">
Tu peux suivre l'avancement ici :<br />
<a :href="trackingUrl" class="tracking-link">{{ trackingUrl }}</a>
</p>
<button type="button" class="btn-secondary" @click="reset">
Proposer une autre fiche
</button>
</div>
<!-- Formulaire -->
<form v-else @submit.prevent="submit" class="contribuer-form" novalidate>
<!-- Nom -->
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="nom">Nom de l'organisation <span class="required">*</span></label>
<input
id="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>
<!-- URL -->
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="url">
Site web
<span class="label-hint">(optionnel — recommandé pour l'enrichissement IA)</span>
</label>
<input
id="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>
<!-- Description -->
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="description_user">
Description courte <span class="required">*</span>
<span class="label-hint">(50 à 500 caractères)</span>
</label>
<textarea
id="description_user"
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>
<!-- Échelle -->
<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="echelle"
@change="validateField('echelle')"
/>
{{ opt }}
</label>
</div>
</fieldset>
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
</div>
<!-- Fonctions -->
<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>
<!-- Territoire -->
<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="territoire"
@change="validateField('territoire')"
/>
{{ t }}
</label>
</div>
</fieldset>
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
</div>
<!-- Ville -->
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
<label for="localisation_ville">
Ville principale
<span class="label-hint">(optionnel — pour la géolocalisation sur la carte)</span>
</label>
<input
id="localisation_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>
<!-- Email -->
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="submitted_by_email">
Ton email
<span class="label-hint">(optionnel — pour le suivi de modération)</span>
</label>
<input
id="submitted_by_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>
<!-- Erreur globale -->
<div v-if="serverError" class="server-error" role="alert">
<strong>Erreur :</strong> {{ serverError }}
</div>
<!-- Actions -->
<div class="form-actions">
<NuxtLink to="/" class="btn-secondary">Annuler</NuxtLink>
<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>
</div>
</div>
</template>
<script setup lang="ts">
import { z } from 'zod'
// ── Constantes ────────────────────────────────────────────────────────────────
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
// ── Schéma Zod (côté client — miroir du serveur) ──────────────────────────────
const SubmitSchema = 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('')),
})
// ── État du formulaire ────────────────────────────────────────────────────────
const form = reactive({
nom: '',
url: '',
description_user: '',
echelle: '' as typeof ECHELLES[number] | '',
fonctions: [] as string[],
territoire: '' as typeof TERRITOIRES[number] | '',
localisation_ville: '',
submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const success = ref(false)
const serverError = ref('')
const trackingUrl = ref<string | null>(null)
// ── Validation champ par champ ────────────────────────────────────────────────
function validateField(field: string) {
const partial = SubmitSchema.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 = SubmitSchema.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
}
// ── Gestion fonctions (ordre de clic = priorité) ──────────────────────────────
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')
}
// ── Soumission ────────────────────────────────────────────────────────────────
async function submit() {
serverError.value = ''
if (!validateAll()) {
// Scroll vers la première erreur
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
const result: any = await $fetch('/api/submit', {
method: 'POST',
body: {
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,
},
})
trackingUrl.value = result.trackingUrl ?? null
success.value = true
} 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) {
// Erreurs Zod serveur → mapper sur le formulaire
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
}
}
function reset() {
Object.assign(form, {
nom: '', url: '', description_user: '', echelle: '',
fonctions: [], territoire: '', localisation_ville: '', submitted_by_email: '',
})
Object.keys(errors).forEach(k => delete errors[k])
success.value = false
serverError.value = ''
trackingUrl.value = null
}
// ── Meta ──────────────────────────────────────────────────────────────────────
useHead({ title: 'Proposer une ressource — AEP' })
</script>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.contribuer-page {
min-height: 100vh;
background: var(--nav-bg);
padding: 1.5rem 1rem 4rem;
}
.contribuer-inner {
max-width: 640px;
margin: 0 auto;
}
/* ── Retour ──────────────────────────────────────────────────────────────────── */
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--nav-primary-solid);
opacity: 0.7;
text-decoration: none;
margin-bottom: 1.5rem;
transition: opacity 0.15s;
}
.back-link:hover {
opacity: 1;
}
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
.contribuer-header {
margin-bottom: 2rem;
}
.contribuer-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.contribuer-subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted);
line-height: 1.5;
margin: 0;
}
/* ── Succès ──────────────────────────────────────────────────────────────────── */
.success-block {
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 12px;
padding: 2rem 1.5rem;
text-align: center;
}
.success-icon {
width: 48px;
height: 48px;
background: rgba(26, 34, 56, 0.1);
color: var(--nav-text);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
margin: 0 auto 1rem;
}
.success-block h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.success-block p {
font-size: 0.9rem;
color: var(--nav-text-muted);
margin: 0 0 0.5rem;
line-height: 1.5;
}
.success-detail {
font-size: 0.85rem !important;
}
.success-tracking {
font-size: 0.85rem !important;
margin-top: 1rem !important;
}
.tracking-link {
color: var(--nav-primary-solid);
font-size: 0.8rem;
word-break: break-all;
}
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
.contribuer-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Champ générique ─────────────────────────────────────────────────────────── */
.field-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-group label,
.field-group legend {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
display: block;
}
.field-group fieldset {
border: none;
padding: 0;
margin: 0;
}
.required {
color: #c0392b;
}
.label-hint {
font-weight: 400;
color: var(--nav-text-muted);
font-size: 0.8rem;
margin-left: 0.25rem;
}
.field-group input[type="text"],
.field-group input[type="url"],
.field-group input[type="email"],
.field-group textarea {
width: 100%;
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;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.field-group input:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--nav-primary-solid);
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
}
.field-group textarea {
resize: vertical;
min-height: 100px;
}
/* Erreur champ */
.field-error input,
.field-error textarea {
border-color: #c0392b !important;
}
.error-msg {
font-size: 0.8rem;
color: #c0392b;
}
.field-meta {
display: flex;
justify-content: flex-end;
}
.char-count {
font-size: 0.75rem;
color: var(--nav-text-muted);
}
.char-warn {
color: #e67e22;
}
/* ── Radio (Échelle + Territoire) ────────────────────────────────────────────── */
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.375rem;
}
.radio-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.radio-label input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.radio-label:hover {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.radio-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
/* ── Checkboxes (Fonctions) ──────────────────────────────────────────────────── */
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-top: 0.375rem;
}
@media (max-width: 400px) {
.checkbox-grid {
grid-template-columns: 1fr;
}
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
position: relative;
}
.checkbox-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-label:hover:not(.disabled) {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.checkbox-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
.checkbox-label.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.fn-order {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: var(--nav-accent);
color: var(--nav-text);
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
flex-shrink: 0;
}
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
.server-error {
padding: 0.875rem 1rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
}
/* ── Actions ──────────────────────────────────────────────────────────────────── */
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: rgba(26, 34, 56, 0.75);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.75rem 1.25rem;
background: transparent;
color: var(--nav-text-muted);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
text-decoration: none;
display: inline-flex;
align-items: center;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
.form-note {
font-size: 0.75rem;
color: var(--nav-text-muted);
text-align: center;
margin: 0;
}
/* ── Responsive ──────────────────────────────────────────────────────────────── */
@media (max-width: 480px) {
.contribuer-page {
padding: 1rem 0.75rem 3rem;
}
.form-actions {
flex-direction: column-reverse;
}
.btn-primary,
.btn-secondary {
width: 100%;
justify-content: center;
}
}
</style>