diff --git a/.gitignore b/.gitignore index acef788..394abb2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ .pnpm-store/ # Nuxt +.nuxt/ apps/app/.nuxt/ apps/app/.output/ apps/app/.data/ diff --git a/apps/app/.env.example b/apps/app/.env.example index df25a2f..f232577 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -14,6 +14,10 @@ APP_URL=http://localhost:3000 # (THI-124 / flux candidature). Sans cette clé, /api/candidature/* répond 502. ANTHROPIC_API_KEY=sk-ant-changeme +# Dev uniquement : si "true", désactive le quota de générations gratuites +# (le modèle freemium reste codé ; à ne PAS activer en prod). +# DISABLE_GENERATION_LIMIT=true + # SMTP (optionnel en dev — si absent, le magic-link est loggé dans la console). # SMTP_HOST=smtp.example.fr # SMTP_PORT=587 diff --git a/apps/app/components/cv/CvDesignPanel.vue b/apps/app/components/cv/CvDesignPanel.vue new file mode 100644 index 0000000..2d37b9e --- /dev/null +++ b/apps/app/components/cv/CvDesignPanel.vue @@ -0,0 +1,104 @@ + + + diff --git a/apps/app/components/cv/CvPhotoInput.vue b/apps/app/components/cv/CvPhotoInput.vue new file mode 100644 index 0000000..327217d --- /dev/null +++ b/apps/app/components/cv/CvPhotoInput.vue @@ -0,0 +1,249 @@ + + + diff --git a/apps/app/components/cv/CvTemplate.vue b/apps/app/components/cv/CvTemplate.vue index 0624804..50f7be9 100644 --- a/apps/app/components/cv/CvTemplate.vue +++ b/apps/app/components/cv/CvTemplate.vue @@ -18,6 +18,12 @@ function isEducation(s: CvSection): s is Extract { return s.kind === 'summary' } +function isKeySkills(s: CvSection): s is Extract { + return s.kind === 'keyskills' +} +function isLanguages(s: CvSection): s is Extract { + return s.kind === 'languages' +} - - - Design system - diff --git a/apps/app/pages/candidature.vue b/apps/app/pages/candidature.vue index 96d44bb..39e95ef 100644 --- a/apps/app/pages/candidature.vue +++ b/apps/app/pages/candidature.vue @@ -3,8 +3,8 @@ import type { AnalyzeCandidatureRequest, AnalyzeCandidatureResponse, GenerateCandidatureRequest, + GenerateCandidatureResponse, ProfileDTO, - RenderableCv, UsageSummary, } from '@cvo/shared' import { @@ -15,7 +15,7 @@ import { matchVerdict, } from '@cvo/shared' import type { MatchVerdict } from '@cvo/shared' -import { ChevronRight, Download, RotateCcw, ShieldCheck, TriangleAlert } from '@lucide/vue' +import { ChevronRight, TriangleAlert } from '@lucide/vue' definePageMeta({ middleware: 'auth' }) @@ -23,13 +23,14 @@ const toast = useToast() // ─── Machine à états du parcours ───────────────────────────────────────────── -type Step = 'input' | 'analyzing' | 'scored' | 'generating' | 'done' +// Le parcours s'arrête à `generating` : au succès on redirige vers l'éditeur +// (/candidatures/:id) — le CV est persisté, donc plus de perte au changement d'onglet. +type Step = 'input' | 'analyzing' | 'scored' | 'generating' const step = ref('input') const offerText = ref('') const offerError = ref('') const analysis = ref(null) -const generatedCv = ref(null) // ─── Quota de générations (badge + gating des boutons) ─────────────────────── @@ -149,15 +150,18 @@ async function generate() { if (!analysis.value || quotaExhausted.value || step.value === 'generating') return step.value = 'generating' try { - const body: GenerateCandidatureRequest = { offer: analysis.value.offer } - const res = await $fetch<{ cv: RenderableCv }>(CANDIDATURE_GENERATE_PATH, { + const body: GenerateCandidatureRequest = { + offer: analysis.value.offer, + match: analysis.value.match, + } + const res = await $fetch(CANDIDATURE_GENERATE_PATH, { method: 'POST', body, }) - generatedCv.value = res.cv - step.value = 'done' - toast.success('CV généré', "Ton CV adapté à l'offre est prêt.") await refreshUsage() + toast.success('CV généré', 'Ton CV est prêt — tu peux le personnaliser.') + // Persisté en base : on ouvre l'éditeur (le CV survit aux changements d'onglet). + await navigateTo(`/candidatures/${res.candidatureId}`) } catch (err) { step.value = 'scored' const status = errorStatus(err) @@ -167,66 +171,29 @@ async function generate() { toast.error('Quota atteint', 'Tu as épuisé tes générations offertes.') } else if (status === 422) { toast.error('Génération impossible', errorMessage(err, 'Réessaie dans quelques instants.')) + } else if (status === 409) { + // Profil incomplet (vide ou nom manquant) : on relaie le message serveur, + // qui guide vers la page profil. Aucun crédit consommé. + toast.error('Profil à compléter', errorMessage(err, 'Complète ton profil puis réessaie.')) } else { toast.error('La génération a échoué', "Ton crédit n'a pas été consommé.") } } } -// ─── Étape 3 : export PDF + nouvelle candidature ───────────────────────────── - -const exporting = ref(false) - -async function exportPdf() { - if (!generatedCv.value) return - exporting.value = true - try { - const res = await $fetch('/api/cv/export-pdf', { - method: 'POST', - body: generatedCv.value, - responseType: 'arrayBuffer', - }) - const blob = new Blob([res], { type: 'application/pdf' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'cv-teven.pdf' - a.click() - URL.revokeObjectURL(url) - } catch { - toast.error( - 'Export impossible', - "Le PDF n'a pas pu être généré. Réessaie dans quelques instants.", - ) - } finally { - exporting.value = false - } -} - -async function resetFlow() { - offerText.value = '' - offerError.value = '' - analysis.value = null - generatedCv.value = null - step.value = 'input' - await refreshUsage() -} - -// ─── A11y : focus déplacé sur le titre du résultat à chaque étape clé ──────── +// ─── A11y : focus déplacé sur le titre du résultat à l'étape « scored » ────── const scoredHeadingRef = ref(null) -const doneHeadingRef = ref(null) watch(step, async (s) => { - if (s !== 'scored' && s !== 'done') return + if (s !== 'scored') return await nextTick() - const target = s === 'scored' ? scoredHeadingRef.value : doneHeadingRef.value - target?.focus() + scoredHeadingRef.value?.focus() })