Compare commits
6 Commits
04b023518a
...
feat/bifro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d06c09a476 | ||
|
|
4291fe7529 | ||
|
|
e5d57e0053 | ||
|
|
3cf9f721a7 | ||
|
|
e61114d6ec | ||
|
|
c860dabdc0 |
12
app.vue
12
app.vue
@@ -114,9 +114,9 @@
|
|||||||
>
|
>
|
||||||
Signaler
|
Signaler
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<!-- Proposer une ressource -->
|
<!-- Proposer une ressource (5 onglets) -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/contribuer"
|
to="/proposer"
|
||||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
>
|
>
|
||||||
@@ -143,13 +143,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Mobile : contribuer icône -->
|
<!-- Mobile : proposer icône -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/contribuer"
|
to="/proposer"
|
||||||
class="sm:hidden p-2 rounded-lg"
|
class="sm:hidden p-2 rounded-lg"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
title="Contribuer une fiche"
|
title="Proposer une ressource"
|
||||||
aria-label="Contribuer"
|
aria-label="Proposer"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
|||||||
184
components/FormEcosysteme.vue
Normal file
184
components/FormEcosysteme.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<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>
|
||||||
134
components/FormJobs.vue
Normal file
134
components/FormJobs.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||||
|
<label for="job-nom">Nom de la plateforme / job board <span class="required">*</span></label>
|
||||||
|
<input id="job-nom" v-model="form.nom" type="text" placeholder="Ex : Houzz, Architoo, BOAMP..." @blur="validateField('nom')" />
|
||||||
|
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="job-type">Type de plateforme <span class="label-hint">(optionnel)</span></label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label v-for="t in TYPES" :key="t" class="radio-label" :class="{ active: form.type_plateforme === t }">
|
||||||
|
<input type="radio" :value="t" v-model="form.type_plateforme" name="job-type" />{{ LABELS[t] }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="job-url">Site web <span class="required">*</span></label>
|
||||||
|
<input id="job-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="job-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
||||||
|
<textarea id="job-desc" v-model="form.description_user" rows="4" placeholder="Décris la plateforme : qui la gère, quel type de missions, quel modèle économique..." @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.submitted_by_email }">
|
||||||
|
<label for="job-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
||||||
|
<input id="job-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 →' }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const TYPES = ['mise-en-relation', 'appel-offre-public', 'communaute-pro'] as const
|
||||||
|
const LABELS: Record<string, string> = { 'mise-en-relation': 'Mise en relation (B2C)', 'appel-offre-public': 'Appels d\'offres publics', 'communaute-pro': 'Communauté pro' }
|
||||||
|
|
||||||
|
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://)'),
|
||||||
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
|
type_plateforme: z.string().optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ success: [] }>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nom: '', url: '', description_user: '', type_plateforme: '', 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'job',
|
||||||
|
nom: form.nom,
|
||||||
|
url: form.url,
|
||||||
|
description_user: form.description_user,
|
||||||
|
type_plateforme: form.type_plateforme || 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>
|
||||||
134
components/FormOutils.vue
Normal file
134
components/FormOutils.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||||
|
<label for="out-nom">Nom de l'outil <span class="required">*</span></label>
|
||||||
|
<input id="out-nom" v-model="form.nom" type="text" placeholder="Ex : Simulateur Autonomie, FreeCAD..." @blur="validateField('nom')" />
|
||||||
|
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Catégorie <span class="label-hint">(optionnelle)</span></label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label v-for="c in CATEGORIES" :key="c" class="radio-label" :class="{ active: form.categorie === c }">
|
||||||
|
<input type="radio" :value="c" v-model="form.categorie" name="out-categorie" />{{ LABELS[c] }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="out-url">Site web / lien <span class="label-hint">(optionnel — si pas de lien direct)</span></label>
|
||||||
|
<input id="out-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="out-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
||||||
|
<textarea id="out-desc" v-model="form.description_user" rows="4" placeholder="Décris l'outil : à quoi il sert, pour quel usage, ce qui le rend utile..." @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.submitted_by_email }">
|
||||||
|
<label for="out-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
||||||
|
<input id="out-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 →' }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const CATEGORIES = ['simulateur', 'logiciel', 'guide', 'autre'] as const
|
||||||
|
const LABELS: Record<string, string> = { simulateur: 'Simulateur', logiciel: 'Logiciel / app', guide: 'Guide / ressource', autre: 'Autre' }
|
||||||
|
|
||||||
|
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').optional().or(z.literal('')),
|
||||||
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
|
categorie: z.string().optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ success: [] }>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nom: '', url: '', description_user: '', categorie: '', 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'outil',
|
||||||
|
nom: form.nom,
|
||||||
|
url: form.url || undefined,
|
||||||
|
description_user: form.description_user,
|
||||||
|
categorie: form.categorie || 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>
|
||||||
142
components/FormReferences.vue
Normal file
142
components/FormReferences.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.titre }">
|
||||||
|
<label for="ref-titre">Titre du livre / document <span class="required">*</span></label>
|
||||||
|
<input id="ref-titre" v-model="form.titre" type="text" placeholder="Ex : Repenser l'architecture..." @blur="validateField('titre')" />
|
||||||
|
<span v-if="errors.titre" class="error-msg" role="alert">{{ errors.titre }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.auteur }">
|
||||||
|
<label for="ref-auteur">Auteur·ice(s) <span class="required">*</span></label>
|
||||||
|
<input id="ref-auteur" v-model="form.auteur" type="text" placeholder="Ex : Philippe Rahm, Doina Petrescu..." @blur="validateField('auteur')" />
|
||||||
|
<span v-if="errors.auteur" class="error-msg" role="alert">{{ errors.auteur }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="ref-url">Lien <span class="label-hint">(optionnel — éditeur, статье, PDF…)</span></label>
|
||||||
|
<input id="ref-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 }">
|
||||||
|
<label for="ref-desc">Description de l'ouvrage <span class="required">*</span> <span class="label-hint">(50 à 1000 caractères)</span></label>
|
||||||
|
<textarea id="ref-desc" v-model="form.description" rows="4" placeholder="Présente l'ouvrage : thèse principale, approche, pourquoi c'est important..." @blur="validateField('description')" />
|
||||||
|
<div class="field-meta">
|
||||||
|
<span v-if="errors.description" class="error-msg" role="alert">{{ errors.description }}</span>
|
||||||
|
<span v-else class="char-count" :class="{ 'char-warn': form.description.length > 900 }">{{ form.description.length }}/1000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="ref-pertinence">Pertinence pour le RAG <span class="label-hint">(optionnel — en quoi cette référence est utile ?)</span></label>
|
||||||
|
<textarea id="ref-pertinence" v-model="form.pertinence_rag" rows="2" placeholder="Ex : Excellente source sur l'architecture participative..." maxlength="500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="ref-hashtags">Hashtags <span class="label-hint">(optionnel — séparés par des virgules)</span></label>
|
||||||
|
<input id="ref-hashtags" v-model="form.hashtags" type="text" placeholder="Ex : écologie, participation, matériaux bio..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||||
|
<label for="ref-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
||||||
|
<input id="ref-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 référence →' }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-note">Ta référence sera examinée avant d'être intégrée au fonds documentaire RAG.</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
titre: z.string().min(3, 'Minimum 3 caractères').max(200, 'Maximum 200 caractères').trim(),
|
||||||
|
auteur: z.string().min(2, 'Minimum 2 caractères').max(200, 'Maximum 200 caractères').trim(),
|
||||||
|
url: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||||
|
description: z.string().min(50, 'Minimum 50 caractères').max(1000, 'Maximum 1000 caractères').trim(),
|
||||||
|
pertinence_rag: z.string().max(500).optional(),
|
||||||
|
hashtags: z.string().max(300).optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ success: [] }>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
titre: '', auteur: '', url: '', description: '',
|
||||||
|
pertinence_rag: '', hashtags: '', 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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/reference', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
titre: form.titre,
|
||||||
|
auteur: form.auteur,
|
||||||
|
url: form.url || undefined,
|
||||||
|
description: form.description,
|
||||||
|
pertinence_rag: form.pertinence_rag || undefined,
|
||||||
|
hashtags: form.hashtags || 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>
|
||||||
158
components/FormReseau.vue
Normal file
158
components/FormReseau.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||||
|
<label for="res-nom">Nom du réseau / collectif / ressource <span class="required">*</span></label>
|
||||||
|
<input id="res-nom" v-model="form.nom" type="text" placeholder="Ex : Collectif des Arpenteurs, Archi'Résilience..." @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.type_principal }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Type <span class="label-hint">(optionnel)</span></legend>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label v-for="t in TYPES" :key="t" class="radio-label" :class="{ active: form.type_principal === t }">
|
||||||
|
<input type="radio" :value="t" v-model="form.type_principal" name="res-type" />{{ LABELS[t] }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="res-url">Site web <span class="label-hint">(optionnel)</span></label>
|
||||||
|
<input id="res-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="res-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
||||||
|
<textarea id="res-desc" v-model="form.description_user" rows="4" placeholder="Présente ce réseau/collectif : son objet, son échelle, ce qui le distingue..." @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">
|
||||||
|
<label for="res-ville">Ville principale <span class="label-hint">(optionnel)</span></label>
|
||||||
|
<input id="res-ville" v-model="form.localisation_ville" type="text" placeholder="Ex : Paris, Lyon..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="res-projet-lien">Lien vers un projet / réalisation <span class="label-hint">(optionnel)</span></label>
|
||||||
|
<input id="res-projet-lien" v-model="form.projet_lien" type="url" placeholder="https://..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="res-projet-adresse">Adresse du projet <span class="label-hint">(optionnel)</span></label>
|
||||||
|
<input id="res-projet-adresse" v-model="form.projet_adresse" type="text" placeholder="Ex : 12 rue des Lilas, 75000 Paris" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||||
|
<label for="res-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
||||||
|
<input id="res-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 →' }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const TYPES = ['agence', 'collectif', 'reseau', 'asso', 'ressource'] as const
|
||||||
|
const LABELS: Record<string, string> = { agence: 'Agence', collectif: 'Collectif', reseau: 'Réseau', asso: 'Association', ressource: 'Ressource' }
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
|
type_principal: z.string().optional(),
|
||||||
|
url: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||||
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
|
localisation_ville: z.string().max(100).optional(),
|
||||||
|
projet_lien: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||||
|
projet_adresse: z.string().max(200).optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ success: [] }>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nom: '', type_principal: '', url: '', description_user: '',
|
||||||
|
localisation_ville: '', projet_lien: '', projet_adresse: '', 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'reseau',
|
||||||
|
nom: form.nom,
|
||||||
|
type_principal: form.type_principal || undefined,
|
||||||
|
url: form.url || undefined,
|
||||||
|
description_user: form.description_user,
|
||||||
|
localisation_ville: form.localisation_ville || undefined,
|
||||||
|
projet_lien: form.projet_lien || undefined,
|
||||||
|
projet_adresse: form.projet_adresse || 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>
|
||||||
240
components/form-styles.css
Normal file
240
components/form-styles.css
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
.proposer-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-error {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: #fdf0ee;
|
||||||
|
border: 1px solid #e74c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.form-actions { flex-direction: column-reverse; }
|
||||||
|
.btn-primary { width: 100%; justify-content: center; }
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ export default defineNuxtConfig({
|
|||||||
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
||||||
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
||||||
mistralApiKey: process.env.MISTRAL_API_KEY,
|
mistralApiKey: process.env.MISTRAL_API_KEY,
|
||||||
|
bifrostUrl: process.env.BIFROST_URL || 'http://127.0.0.1:8080',
|
||||||
|
bifrostVk: process.env.BIFROST_VK,
|
||||||
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
||||||
resendApiKey: process.env.RESEND_API_KEY,
|
resendApiKey: process.env.RESEND_API_KEY,
|
||||||
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
||||||
@@ -24,6 +26,7 @@ export default defineNuxtConfig({
|
|||||||
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
|
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
|
||||||
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
|
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
|
||||||
ragPeUrl: process.env.RAG_PE_URL || 'http://localhost:9621',
|
ragPeUrl: process.env.RAG_PE_URL || 'http://localhost:9621',
|
||||||
|
referencesTableId: process.env.REFERENCES_TABLE_ID || process.env.NUXT_REFERENCES_TABLE_ID || '',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
||||||
|
|||||||
@@ -1,795 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="contribuer-page">
|
<div />
|
||||||
<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 on validera sous 7 jours.
|
|
||||||
</p>
|
|
||||||
<p class="contribuer-hint">
|
|
||||||
Si tu n'as pas le temps de tout remplir, laisse-nous juste le lien — on extraira les infos du site.
|
|
||||||
Mais une description de toi, c'est toujours plus vivant et plus précis.
|
|
||||||
</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>
|
|
||||||
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { z } from 'zod'
|
navigateTo('/proposer', { redirectCode: 301 })
|
||||||
|
|
||||||
// ── 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>
|
</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 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contribuer-hint {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
opacity: 0.75;
|
|
||||||
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>
|
|
||||||
|
|||||||
194
pages/proposer.vue
Normal file
194
pages/proposer.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div class="proposer-page">
|
||||||
|
<div class="proposer-inner">
|
||||||
|
<NuxtLink to="/" class="back-link">← Retour à la carte</NuxtLink>
|
||||||
|
|
||||||
|
<div class="proposer-header">
|
||||||
|
<h1>Proposer une ressource</h1>
|
||||||
|
<p class="proposer-subtitle">
|
||||||
|
Tu connais quelque chose d'utile qui n'est pas encore référencé sur AEP ?
|
||||||
|
Choisis la catégorie ci-dessous et soumets-le — on valide sous 7 jours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="success" class="success-block" role="status" aria-live="polite">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h2>Merci !</h2>
|
||||||
|
<p>Ta proposition est en attente de modération.</p>
|
||||||
|
<p class="success-detail">Jules valide manuellement chaque entrée avant publication.</p>
|
||||||
|
<button type="button" class="btn-secondary" @click="reset">Proposer autre chose</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="tabs-bar">
|
||||||
|
<button
|
||||||
|
v-for="tab in TABS"
|
||||||
|
:key="tab.id"
|
||||||
|
type="button"
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ 'tab-btn--active': activeTab === tab.id }"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>{{ tab.label }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<FormEcosysteme v-if="activeTab === 'ecosysteme'" @success="onSuccess" />
|
||||||
|
<FormReseau v-else-if="activeTab === 'reseau'" @success="onSuccess" />
|
||||||
|
<FormJobs v-else-if="activeTab === 'jobs'" @success="onSuccess" />
|
||||||
|
<FormOutils v-else-if="activeTab === 'outils'" @success="onSuccess" />
|
||||||
|
<FormReferences v-else-if="activeTab === 'references'" @success="onSuccess" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'ecosysteme', label: 'Écosystème / Entraide' },
|
||||||
|
{ id: 'reseau', label: 'Réseau AEP' },
|
||||||
|
{ id: 'jobs', label: 'Jobs' },
|
||||||
|
{ id: 'outils', label: 'Outils' },
|
||||||
|
{ id: 'references', label: 'Références RAG' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const activeTab = ref<string>('ecosysteme')
|
||||||
|
const success = ref(false)
|
||||||
|
|
||||||
|
function onSuccess() {
|
||||||
|
success.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
success.value = false
|
||||||
|
activeTab.value = 'ecosysteme'
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: 'Proposer une ressource — AEP' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.proposer-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 1.5rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
.proposer-inner {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
.proposer-header { margin-bottom: 1.5rem; }
|
||||||
|
.proposer-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.proposer-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--nav-bg-alt);
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { color: var(--nav-text); }
|
||||||
|
.tab-btn--active {
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-bottom-color: var(--nav-primary-solid);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tab-content {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.proposer-page { padding: 1rem 0.75rem 3rem; }
|
||||||
|
.tabs-bar { overflow-x: auto; flex-wrap: nowrap; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
// @ts-ignore — JSON import résolu par Rollup
|
// @ts-ignore — JSON import résolu par Rollup
|
||||||
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
interface Structure {
|
interface Structure {
|
||||||
id: string
|
id: string
|
||||||
@@ -61,6 +62,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const question: string = (body?.question ?? '').trim()
|
const question: string = (body?.question ?? '').trim()
|
||||||
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
|
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
|
||||||
|
const tier = pickBifrostTier(body?.mode)
|
||||||
|
|
||||||
const structures: Structure[] = ((reseauxData as any).structures ?? [])
|
const structures: Structure[] = ((reseauxData as any).structures ?? [])
|
||||||
const keywords = extractKeywords(question)
|
const keywords = extractKeywords(question)
|
||||||
@@ -82,18 +84,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' })
|
const bifrostVk = config.bifrostVk as string
|
||||||
|
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
const res = await $fetch<BifrostChatResponse>(
|
||||||
'https://api.mistral.ai/v1/chat/completions',
|
`${bifrostUrl}/v1/chat/completions`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
|
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: tier.model,
|
||||||
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700,
|
max_tokens: 700,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// @ts-ignore — JSON import résolu par Vite/Rollup
|
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||||
import taffData from '../../public/data/plateformes-taff.json'
|
import taffData from '../../public/data/plateformes-taff.json'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
interface PlateformeMinimal {
|
interface PlateformeMinimal {
|
||||||
id: string
|
id: string
|
||||||
@@ -66,6 +67,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!question || question.length < 5) {
|
if (!question || question.length < 5) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
||||||
}
|
}
|
||||||
|
const tier = pickBifrostTier(body?.mode)
|
||||||
|
|
||||||
// Données bundlées statiquement à la compilation (import JSON)
|
// Données bundlées statiquement à la compilation (import JSON)
|
||||||
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
||||||
@@ -91,20 +93,22 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
if (!mistralApiKey) {
|
const bifrostVk = config.bifrostVk as string
|
||||||
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
|
if (!bifrostVk) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'Clé Bifrost manquante.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
const res = await $fetch<BifrostChatResponse>(
|
||||||
'https://api.mistral.ai/v1/chat/completions',
|
`${bifrostUrl}/v1/chat/completions`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
|
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: tier.model,
|
||||||
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700,
|
max_tokens: 700,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
||||||
|
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -131,18 +132,19 @@ async function logUsage(params: {
|
|||||||
nocodbUrl: string
|
nocodbUrl: string
|
||||||
nocodbToken: string
|
nocodbToken: string
|
||||||
statsTableId: string
|
statsTableId: string
|
||||||
|
model: string
|
||||||
tokensIn: number
|
tokensIn: number
|
||||||
tokensOut: number
|
tokensOut: number
|
||||||
coutEur: number
|
coutEur: number
|
||||||
}) {
|
}) {
|
||||||
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params
|
const { nocodbUrl, nocodbToken, statsTableId, model, tokensIn, tokensOut, coutEur } = params
|
||||||
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||||
try {
|
try {
|
||||||
await $fetch(logUrl, {
|
await $fetch(logUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
|
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model,
|
||||||
endpoint: 'chatbot',
|
endpoint: 'chatbot',
|
||||||
tokens_in: tokensIn,
|
tokens_in: tokensIn,
|
||||||
tokens_out: tokensOut,
|
tokens_out: tokensOut,
|
||||||
@@ -180,6 +182,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const question: string = (body?.question ?? '').trim()
|
const question: string = (body?.question ?? '').trim()
|
||||||
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
||||||
|
const tier = pickBifrostTier(body?.mode)
|
||||||
|
|
||||||
if (!question || question.length < 3) {
|
if (!question || question.length < 3) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -247,32 +250,32 @@ export default defineEventHandler(async (event) => {
|
|||||||
JSON.stringify(fichesContext, null, 0),
|
JSON.stringify(fichesContext, null, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 6. Appel Mistral Small
|
// 6. Appel Bifrost (gateway LLM — remplace l'appel Mistral direct)
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
|
const bifrostVk = config.bifrostVk as string
|
||||||
|
|
||||||
if (!mistralApiKey) {
|
if (!bifrostVk) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Clé API Mistral manquante.',
|
statusMessage: 'Clé Bifrost manquante.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
let tokensIn = 0
|
let tokensIn = 0
|
||||||
let tokensOut = 0
|
let tokensOut = 0
|
||||||
|
let realModel = tier.model
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mistralRes = await $fetch<{
|
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
|
||||||
choices: { message: { content: string } }[]
|
|
||||||
usage?: { prompt_tokens: number; completion_tokens: number }
|
|
||||||
}>('https://api.mistral.ai/v1/chat/completions', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${mistralApiKey}`,
|
'x-bf-vk': bifrostVk,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: tier.model,
|
||||||
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 600,
|
max_tokens: 600,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
@@ -283,11 +286,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
|
mistralRaw = bifrostRes.choices?.[0]?.message?.content ?? '{}'
|
||||||
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
|
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
|
||||||
tokensOut = mistralRes.usage?.completion_tokens ?? 0
|
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
|
||||||
|
realModel =
|
||||||
|
bifrostRes.extra_fields?.resolved_model_used ??
|
||||||
|
(bifrostRes.extra_fields?.provider
|
||||||
|
? `${bifrostRes.extra_fields.provider}/${tier.model.split('/').slice(1).join('/')}`
|
||||||
|
: tier.model)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e)
|
console.error('[chatbot] Erreur Bifrost:', e?.message ?? e)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 502,
|
statusCode: 502,
|
||||||
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
|
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
|
||||||
@@ -317,11 +325,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Log usage (non bloquant)
|
// 8. Log usage (non bloquant)
|
||||||
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut)
|
// Coût réel calculable seulement si le provider ayant répondu est Mistral —
|
||||||
|
// les autres tiers Bifrost (Groq, Cerebras, Gemini free tier, Cohere) sont free-tier,
|
||||||
|
// donc cout_eur=0 pour ne pas fausser le circuit breaker avec un tarif Mistral inapplicable.
|
||||||
|
const isMistral = realModel.toLowerCase().startsWith('mistral')
|
||||||
|
const coutEur = isMistral ? calcCoutMistralSmall(tokensIn, tokensOut) : 0
|
||||||
logUsage({
|
logUsage({
|
||||||
nocodbUrl: config.nocodbUrl as string,
|
nocodbUrl: config.nocodbUrl as string,
|
||||||
nocodbToken: config.nocodbToken as string,
|
nocodbToken: config.nocodbToken as string,
|
||||||
statsTableId,
|
statsTableId,
|
||||||
|
model: realModel,
|
||||||
tokensIn,
|
tokensIn,
|
||||||
tokensOut,
|
tokensOut,
|
||||||
coutEur,
|
coutEur,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/submit
|
* POST /api/submit
|
||||||
*
|
*
|
||||||
* Soumission d'une nouvelle organisation par un utilisateur.
|
* Soumission multi-type (ecosysteme / reseau / job / outil).
|
||||||
* - Validation Zod côté serveur
|
* - Validation Zod côté serveur (champs communs + par type)
|
||||||
* - Rate limit Redis : 3 soumissions / IP / jour
|
* - Rate limit Redis : 3 soumissions / IP / jour
|
||||||
* - Géocodage Nominatim (optionnel — fallback silencieux)
|
* - Géocodage Nominatim (optionnel — fallback silencieux)
|
||||||
* - INSERT NocoDB : moderation_status=pending, ai_processed=false
|
* - INSERT NocoDB : moderation_status=pending, ai_processed=false
|
||||||
@@ -15,62 +15,49 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkRateLimit } from '~/server/utils/rateLimit'
|
import { checkRateLimit } from '~/server/utils/rateLimit'
|
||||||
|
|
||||||
// ── Schéma Zod ────────────────────────────────────────────────────────────────
|
const SUBMISSION_TYPES = ['ecosysteme', 'reseau', 'job', 'outil'] as const
|
||||||
|
|
||||||
const FONCTIONS = [
|
const FONCTIONS = [
|
||||||
'Juridique',
|
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
|
||||||
'Technique',
|
'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale',
|
||||||
'Économique',
|
|
||||||
'Administratif',
|
|
||||||
'Chantier',
|
|
||||||
'Comptabilité',
|
|
||||||
'Développement',
|
|
||||||
'Formation',
|
|
||||||
'Gestion d\'agence',
|
|
||||||
'Santé mentale',
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
||||||
|
|
||||||
const TERRITOIRES = [
|
const TERRITOIRES = [
|
||||||
'Métropole',
|
'Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte',
|
||||||
'Guadeloupe',
|
|
||||||
'Martinique',
|
|
||||||
'Guyane',
|
|
||||||
'La Réunion',
|
|
||||||
'Mayotte',
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const TYPES_PRINCIPAUX = ['agence', 'collectif', 'reseau', 'asso', 'ressource'] as const
|
||||||
|
|
||||||
|
const TYPES_PLATEFORME = ['mise-en-relation', 'appel-offre-public', 'communaute-pro'] as const
|
||||||
|
|
||||||
|
const CATEGORIES_OUTIL = ['simulateur', 'logiciel', 'guide', 'autre'] as const
|
||||||
|
|
||||||
export const SubmitSchema = z.object({
|
export const SubmitSchema = z.object({
|
||||||
|
// Champs communs
|
||||||
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
url: z
|
url: z.string().url('URL invalide').optional().or(z.literal('')).transform(v => v || undefined),
|
||||||
.string()
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
.url('URL invalide')
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')).transform(v => v || undefined),
|
||||||
.optional()
|
submission_type: z.enum(SUBMISSION_TYPES, { errorMap: () => ({ message: 'Type de soumission invalide' }) }),
|
||||||
.or(z.literal(''))
|
|
||||||
.transform((v) => v || undefined),
|
// Champs écosystème
|
||||||
description_user: z
|
echelle: z.enum(ECHELLES).optional(),
|
||||||
.string()
|
fonctions: z.array(z.enum(FONCTIONS)).min(1).max(5).optional(),
|
||||||
.min(50, 'Minimum 50 caractères')
|
territoire: z.enum(TERRITOIRES).optional(),
|
||||||
.max(500, 'Maximum 500 caractères')
|
localisation_ville: z.string().max(100).optional().transform(v => v?.trim() || undefined),
|
||||||
.trim(),
|
|
||||||
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Échelle invalide' }) }),
|
// Champs réseau
|
||||||
fonctions: z
|
type_principal: z.enum(TYPES_PRINCIPAUX).optional(),
|
||||||
.array(z.enum(FONCTIONS))
|
projet_lien: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||||
.min(1, 'Sélectionne au moins une fonction')
|
projet_adresse: z.string().max(200).optional().transform(v => v?.trim() || undefined),
|
||||||
.max(5, 'Maximum 5 fonctions'),
|
|
||||||
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Territoire invalide' }) }),
|
// Champs job
|
||||||
localisation_ville: z
|
type_plateforme: z.enum(TYPES_PLATEFORME).optional(),
|
||||||
.string()
|
|
||||||
.max(100)
|
// Champs outil
|
||||||
.optional()
|
categorie: z.enum(CATEGORIES_OUTIL).optional(),
|
||||||
.transform((v) => v?.trim() || undefined),
|
|
||||||
submitted_by_email: z
|
|
||||||
.string()
|
|
||||||
.email('Email invalide')
|
|
||||||
.optional()
|
|
||||||
.or(z.literal(''))
|
|
||||||
.transform((v) => v || undefined),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export type SubmitInput = z.infer<typeof SubmitSchema>
|
export type SubmitInput = z.infer<typeof SubmitSchema>
|
||||||
@@ -148,22 +135,36 @@ export default defineEventHandler(async (event) => {
|
|||||||
const nocoBaseId = config.nocodbBase
|
const nocoBaseId = config.nocodbBase
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
|
submission_type: data.submission_type,
|
||||||
nom: data.nom,
|
nom: data.nom,
|
||||||
url: data.url || null,
|
url: data.url || null,
|
||||||
description_user: data.description_user,
|
description_user: data.description_user,
|
||||||
echelle: data.echelle,
|
|
||||||
tags_fonction: data.fonctions.join(','),
|
|
||||||
territoire: data.territoire,
|
|
||||||
localisation_ville: data.localisation_ville || null,
|
|
||||||
submitted_by_email: data.submitted_by_email || null,
|
submitted_by_email: data.submitted_by_email || null,
|
||||||
moderation_status: 'pending',
|
moderation_status: 'pending',
|
||||||
scrape_status: data.url ? 'pending' : 'no_link',
|
|
||||||
ai_processed: false,
|
ai_processed: false,
|
||||||
submitted_at: new Date().toISOString(),
|
submitted_at: new Date().toISOString(),
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Champs spécifiques selon le type
|
||||||
|
if (data.submission_type === 'ecosysteme') {
|
||||||
|
payload.echelle = data.echelle
|
||||||
|
payload.tags_fonction = data.fonctions?.join(',')
|
||||||
|
payload.territoire = data.territoire
|
||||||
|
payload.localisation_ville = data.localisation_ville || null
|
||||||
|
payload.scrape_status = data.url ? 'pending' : 'no_link'
|
||||||
|
} else if (data.submission_type === 'reseau') {
|
||||||
|
payload.type_principal = data.type_principal
|
||||||
|
payload.projet_lien = data.projet_lien || null
|
||||||
|
payload.projet_adresse = data.projet_adresse || null
|
||||||
|
payload.localisation_ville = data.localisation_ville || null
|
||||||
|
} else if (data.submission_type === 'job') {
|
||||||
|
payload.type_plateforme = data.type_plateforme
|
||||||
|
} else if (data.submission_type === 'outil') {
|
||||||
|
payload.categorie = data.categorie
|
||||||
|
}
|
||||||
|
|
||||||
// NocoDB v1 endpoint
|
// NocoDB v1 endpoint
|
||||||
const insertUrl = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`
|
const insertUrl = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`
|
||||||
|
|
||||||
|
|||||||
74
server/api/submit/reference.post.ts
Normal file
74
server/api/submit/reference.post.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { checkRateLimit } from '~/server/utils/rateLimit'
|
||||||
|
|
||||||
|
const ReferenceSchema = z.object({
|
||||||
|
titre: z.string().min(3, 'Minimum 3 caractères').max(200, 'Maximum 200 caractères').trim(),
|
||||||
|
auteur: z.string().min(2, 'Minimum 2 caractères').max(200, 'Maximum 200 caractères').trim(),
|
||||||
|
url: z.string().url('URL invalide').optional().or(z.literal('')).transform(v => v || undefined),
|
||||||
|
description: z.string().min(50, 'Minimum 50 caractères').max(1000, 'Maximum 1000 caractères').trim(),
|
||||||
|
pertinence_rag: z.string().max(500, 'Maximum 500 caractères').optional().transform(v => v?.trim() || undefined),
|
||||||
|
hashtags: z.string().max(300, 'Maximum 300 caractères').optional().transform(v => v?.trim() || undefined),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')).transform(v => v || undefined),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ReferenceInput = z.infer<typeof ReferenceSchema>
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() || event.node.req.socket?.remoteAddress || '0.0.0.0'
|
||||||
|
|
||||||
|
const allowed = await checkRateLimit(ip, 'submit-reference', 3)
|
||||||
|
if (!allowed) {
|
||||||
|
throw createError({ statusCode: 429, statusMessage: 'Trop de soumissions. Réessaie demain.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event)
|
||||||
|
const parsed = ReferenceSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 422,
|
||||||
|
statusMessage: 'Validation échouée',
|
||||||
|
data: parsed.error.flatten().fieldErrors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
titre: data.titre,
|
||||||
|
auteur: data.auteur,
|
||||||
|
url: data.url || null,
|
||||||
|
description: data.description,
|
||||||
|
pertinence_rag: data.pertinence_rag || null,
|
||||||
|
hashtags: data.hashtags ? data.hashtags.split(',').map(h => h.trim()).filter(Boolean).join(',') : null,
|
||||||
|
submitted_by_email: data.submitted_by_email || null,
|
||||||
|
moderation_status: 'pending',
|
||||||
|
submitted_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${config.nocodbBase}/${config.referencesTableId}`
|
||||||
|
|
||||||
|
let insertedRecord: any
|
||||||
|
try {
|
||||||
|
insertedRecord = await $fetch(insertUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'xc-token': config.nocodbToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[reference] Erreur NocoDB insert:', e?.message ?? e)
|
||||||
|
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur — réessaie dans quelques instants.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
referenceId,
|
||||||
|
message: 'Ta référence est en attente de modération.',
|
||||||
|
}
|
||||||
|
})
|
||||||
47
server/utils/bifrost.ts
Normal file
47
server/utils/bifrost.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Bifrost — gateway LLM (remplace les appels directs Mistral)
|
||||||
|
* Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions
|
||||||
|
* Auth : header x-bf-vk
|
||||||
|
*
|
||||||
|
* 2 tiers validés (Mission M3, build Bifrost) :
|
||||||
|
* RAPIDE — défaut, pas de toggle UI mode rapide/approfondi sur le site actuellement
|
||||||
|
* APPROFONDI — activable via body.mode === 'approfondi' (prêt pour un futur toggle front)
|
||||||
|
*
|
||||||
|
* ⚠ openrouter-oai exclu (bug Bifrost confirmé — 404 HTML sur modèles avec slash)
|
||||||
|
* ⚠ gemini-oai exige le préfixe "models/" (sinon 403 silencieux)
|
||||||
|
* ⚠ cerebras/gemma-4-31b RETIRÉ du tier RAPIDE (M4, 15/07) : en JSON mode avec un contexte
|
||||||
|
* réel (prompt + ~20 fiches), le modèle part en boucle de répétition dégénérée ("1 1 1...")
|
||||||
|
* dans ~75% des cas où il sert de fallback, produit un JSON invalide, et Bifrost ne bascule
|
||||||
|
* PAS plus loin dans la chaîne (HTTP 200 côté provider = pas une erreur pour Bifrost). Bug
|
||||||
|
* constaté en prod sur chatbot-reseaux (1 échec/3), reproduit 3/4 sur appel direct Bifrost.
|
||||||
|
* Cerebras reste configuré dans Bifrost (dispo pour tier CODE ou reconfiguration future),
|
||||||
|
* juste plus dans cette chaîne tant que ce n'est pas fiabilisé.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BIFROST_TIER_RAPIDE = {
|
||||||
|
model: 'groq/llama-3.1-8b-instant',
|
||||||
|
fallbacks: [
|
||||||
|
'gemini-oai/models/gemini-2.5-flash-lite',
|
||||||
|
'cohere/command-r-08-2024',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BIFROST_TIER_APPROFONDI = {
|
||||||
|
model: 'groq/llama-3.3-70b-versatile',
|
||||||
|
fallbacks: [
|
||||||
|
'gemini-oai/models/gemini-2.5-flash',
|
||||||
|
'mistral/mistral-large-latest',
|
||||||
|
'cohere/command-r-plus-08-2024',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sélectionne le tier selon le param optionnel body.mode. */
|
||||||
|
export function pickBifrostTier(mode?: string) {
|
||||||
|
return mode === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BifrostChatResponse {
|
||||||
|
choices: { message: { content: string } }[]
|
||||||
|
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||||
|
extra_fields?: { provider?: string; resolved_model_used?: string }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user