diff --git a/apps/app/app.vue b/apps/app/app.vue index f8eacfa..e034073 100644 --- a/apps/app/app.vue +++ b/apps/app/app.vue @@ -2,4 +2,5 @@ + diff --git a/apps/app/assets/css/main.css b/apps/app/assets/css/main.css index 6fa76d0..b8f06e3 100644 --- a/apps/app/assets/css/main.css +++ b/apps/app/assets/css/main.css @@ -4,41 +4,119 @@ * Design tokens — source unique de vérité (Tailwind v4 @theme). * Règle d'équipe : AUCUNE valeur en dur dans les composants. * On consomme ces tokens via les classes générées : bg-brand-600, text-ink-700, - * rounded-card, font-sans, etc. Le design system détaillé viendra avec l'UX/UI. + * rounded-card, font-sans, shadow-card, etc. + * + * Direction "Chaleur professionnelle" (réfs Resume.io/Teal/Notion) : + * neutres chauds (stone), accent indigo profond, radius généreux, ombres douces. + * + * Exceptions admises à la règle tokens-only (décision revue 2026-06) : + * - `white`/`black` Tailwind : constantes universelles, autorisées sur fonds + * sombres ou brand (text-white sur bg-brand-600, etc.). + * - Modificateurs d'opacité `/N` sur tokens (bg-brand-100/60…) : mécanisme + * standard Tailwind v4 (color-mix), pas une valeur en dur. + * - Valeurs arbitraires ([…]) uniquement quand intrinsèques au composant + * (ex. tailles texte du mini-mockup CvMockup) — jamais pour contourner + * un token existant. */ @theme { - /* Couleur de marque (placeholder neutre tant que le branding n'est pas tranché — - le naming/branding final viendra du CMO via THI-121 → ajuster ici uniquement). */ - --color-brand-50: #eff6ff; - --color-brand-100: #dbeafe; - --color-brand-400: #60a5fa; - --color-brand-500: #3b82f6; - --color-brand-600: #2563eb; - --color-brand-700: #1d4ed8; - - /* Texte & neutres */ - --color-ink-100: #f3f4f6; - --color-ink-300: #d1d5db; - --color-ink-500: #6b7280; - --color-ink-700: #374151; - --color-ink-900: #111827; - - /* États sémantiques */ - --color-success-500: #16a34a; - --color-warning-500: #d97706; - --color-danger-500: #dc2626; + /* Marque — indigo profond (crédibilité) */ + --color-brand-50: #eef2ff; + --color-brand-100: #e0e7ff; + --color-brand-200: #c7d2fe; + --color-brand-300: #a5b4fc; + --color-brand-400: #818cf8; + --color-brand-500: #6366f1; + --color-brand-600: #4f46e5; + --color-brand-700: #4338ca; + --color-brand-800: #3730a3; + --color-brand-900: #312e81; + --color-brand-950: #1e1b4b; + + /* Texte & neutres — stone (chaleur) */ + --color-ink-50: #fafaf9; + --color-ink-100: #f5f5f4; + --color-ink-200: #e7e5e4; + --color-ink-300: #d6d3d1; + --color-ink-400: #a8a29e; + --color-ink-500: #78716c; + --color-ink-600: #57534e; + --color-ink-700: #44403c; + --color-ink-800: #292524; + --color-ink-900: #1c1917; + --color-ink-950: #0c0a09; + + /* États sémantiques — échelles fond (50/100), bordure (200), texte/action (600/700) */ + --color-success-50: #f0fdf4; + --color-success-100: #dcfce7; + --color-success-200: #bbf7d0; + --color-success-500: #22c55e; + --color-success-600: #16a34a; + --color-success-700: #15803d; + + --color-warning-50: #fffbeb; + --color-warning-100: #fef3c7; + --color-warning-200: #fde68a; + --color-warning-500: #f59e0b; + --color-warning-600: #d97706; + --color-warning-700: #b45309; + + --color-danger-50: #fef2f2; + --color-danger-100: #fee2e2; + --color-danger-200: #fecaca; + --color-danger-500: #ef4444; + --color-danger-600: #dc2626; + --color-danger-700: #b91c1c; /* Surfaces & bordures */ --color-surface: #ffffff; - --color-surface-muted: #f9fafb; - --color-border: #e5e7eb; + --color-surface-muted: #fafaf9; + --color-surface-warm: #f5f5f4; + --color-border: #e7e5e4; + --color-border-strong: #d6d3d1; - /* Typographie */ - --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; + /* Typographie — chargée réellement via @nuxt/fonts (nuxt.config.ts) */ + --font-sans: + 'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; - /* Rayons nommés */ - --radius-card: 0.75rem; - --radius-control: 0.5rem; + /* Rayons nommés — généreux (direction chaleur pro) */ + --radius-card: 1rem; + --radius-control: 0.625rem; + + /* Ombres multi-couches très douces (style Stripe), teinte stone */ + --shadow-xs: 0 1px 2px 0 rgb(28 25 23 / 0.04); + --shadow-sm: 0 1px 2px 0 rgb(28 25 23 / 0.04), 0 1px 3px 0 rgb(28 25 23 / 0.06); + --shadow-card: 0 1px 2px 0 rgb(28 25 23 / 0.04), 0 2px 8px -2px rgb(28 25 23 / 0.08); + --shadow-card-hover: + 0 2px 4px -1px rgb(28 25 23 / 0.06), 0 8px 24px -4px rgb(28 25 23 / 0.12); + --shadow-pop: + 0 4px 8px -2px rgb(28 25 23 / 0.08), 0 16px 40px -8px rgb(28 25 23 / 0.16); + --shadow-brand: 0 1px 2px 0 rgb(67 56 202 / 0.12), 0 4px 12px -2px rgb(79 70 229 / 0.25); + + /* Animations utilitaires (toasts, entrées de sections) */ + --animate-toast-in: toast-in 0.3s cubic-bezier(0.16, 1, 0.3, 1); + --animate-fade-up: fade-up 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; + + @keyframes toast-in { + from { + opacity: 0; + transform: translateY(0.75rem) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes fade-up { + from { + opacity: 0; + transform: translateY(0.5rem); + } + to { + opacity: 1; + transform: translateY(0); + } + } } html, @@ -47,6 +125,20 @@ body, height: 100%; } +body { + @apply bg-surface text-ink-800 antialiased; +} + +/* Respecte la préférence utilisateur de mouvement réduit. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} + /* ─── Print / PDF (cible : Chromium headless, PR 3) ───────────────────────── * Règles appliquées aussi bien au Ctrl+P du navigateur qu'au rendu Playwright. * Objectif : margins A4, pas d'éléments d'interface, couleurs préservées. diff --git a/apps/app/components/landing/CvMockup.vue b/apps/app/components/landing/CvMockup.vue new file mode 100644 index 0000000..083368b --- /dev/null +++ b/apps/app/components/landing/CvMockup.vue @@ -0,0 +1,149 @@ + + + diff --git a/apps/app/components/landing/MagicLinkForm.vue b/apps/app/components/landing/MagicLinkForm.vue index 60a5526..12358b0 100644 --- a/apps/app/components/landing/MagicLinkForm.vue +++ b/apps/app/components/landing/MagicLinkForm.vue @@ -1,30 +1,35 @@ + + diff --git a/apps/app/components/ui/Card.vue b/apps/app/components/ui/Card.vue index 8a66c24..cd2eba5 100644 --- a/apps/app/components/ui/Card.vue +++ b/apps/app/components/ui/Card.vue @@ -4,7 +4,7 @@ defineProps<{ title?: string }>() diff --git a/apps/app/components/ui/Input.vue b/apps/app/components/ui/Input.vue index 5011dfd..7c385d3 100644 --- a/apps/app/components/ui/Input.vue +++ b/apps/app/components/ui/Input.vue @@ -1,4 +1,8 @@ + + diff --git a/apps/app/components/ui/State.vue b/apps/app/components/ui/State.vue index 4a9a95c..a1fa89e 100644 --- a/apps/app/components/ui/State.vue +++ b/apps/app/components/ui/State.vue @@ -1,6 +1,8 @@ + + diff --git a/apps/app/components/ui/button-classes.ts b/apps/app/components/ui/button-classes.ts index 6a90d36..a4907b0 100644 --- a/apps/app/components/ui/button-classes.ts +++ b/apps/app/components/ui/button-classes.ts @@ -7,15 +7,16 @@ export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' export type ButtonSize = 'sm' | 'md' | 'lg' const BASE = - 'inline-flex items-center justify-center gap-2 rounded-control font-medium transition-colors ' + + 'inline-flex items-center justify-center gap-2 rounded-control font-semibold transition-all duration-200 ' + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 ' + - 'disabled:cursor-not-allowed disabled:opacity-50' + 'active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100' const VARIANTS: Record = { - primary: 'bg-brand-600 text-brand-50 hover:bg-brand-700', - secondary: 'bg-surface text-ink-900 ring-1 ring-border hover:bg-surface-muted', - ghost: 'bg-transparent text-ink-700 hover:bg-surface-muted', - danger: 'bg-danger-500 text-surface hover:bg-danger-500/90', + primary: 'bg-brand-600 text-white shadow-brand hover:bg-brand-700 hover:shadow-card-hover', + secondary: + 'bg-surface text-ink-800 shadow-xs ring-1 ring-border-strong hover:bg-surface-muted hover:ring-ink-400', + ghost: 'bg-transparent text-ink-600 hover:bg-surface-warm hover:text-ink-800', + danger: 'bg-danger-600 text-white shadow-xs hover:bg-danger-700', } const SIZES: Record = { diff --git a/apps/app/composables/useAuth.ts b/apps/app/composables/useAuth.ts index 41f4e76..594d2f4 100644 --- a/apps/app/composables/useAuth.ts +++ b/apps/app/composables/useAuth.ts @@ -10,13 +10,20 @@ import { createAuthClient } from 'better-auth/client' import { magicLinkClient } from 'better-auth/client/plugins' -// Singleton par page (SSR-safe : le composable est appelé côté client uniquement). +// Singleton par contexte. Le client vanilla better-auth exige une URL ABSOLUE +// quand on en fournit une (une relative type '/api/auth' est rejetée partout) : +// - navigateur : on omet baseURL → il prend l'origine courante + '/api/auth' +// (chemin par défaut de Better Auth) ; +// - SSR (le layout lit la session au rendu) : on la construit depuis +// runtimeConfig.public.appUrl. let _client: ReturnType | null = null function getClient() { if (!_client) { _client = createAuthClient({ - baseURL: '/api/auth', + ...(import.meta.server + ? { baseURL: `${useRuntimeConfig().public.appUrl}/api/auth` } + : {}), plugins: [magicLinkClient()], }) } diff --git a/apps/app/composables/useLanding.ts b/apps/app/composables/useLanding.ts index c594224..7ae06bc 100644 --- a/apps/app/composables/useLanding.ts +++ b/apps/app/composables/useLanding.ts @@ -1,15 +1,17 @@ import { fr, type LandingMessages } from '~/i18n/fr' import { BRAND } from '~/config/brand' -import { PLANS } from '~/config/pricing' +import { CREDIT_PACKS, FREE_GENERATIONS } from '~/config/pricing' /** - * Remplace récursivement le placeholder `{brand}` par la constante BRAND dans - * toute la copy. Tient lieu d'interpolation i18n minimale en attendant le + * Remplace récursivement les placeholders `{brand}` et `{freeGenerations}` + * dans toute la copy. Tient lieu d'interpolation i18n minimale en attendant le * module dédié (le contrat de clés reste identique). */ function interpolate(value: T): T { if (typeof value === 'string') { - return value.replace(/\{brand\}/g, BRAND) as T + return value + .replace(/\{brand\}/g, BRAND) + .replace(/\{freeGenerations\}/g, String(FREE_GENERATIONS)) as T } if (Array.isArray(value)) { return value.map((item) => interpolate(item)) as T @@ -38,5 +40,11 @@ export function useLanding() { })), }) - return { m, brand: BRAND, plans: PLANS, faqJsonLd } + return { + m, + brand: BRAND, + packs: CREDIT_PACKS, + freeGenerations: FREE_GENERATIONS, + faqJsonLd, + } } diff --git a/apps/app/composables/useToast.ts b/apps/app/composables/useToast.ts new file mode 100644 index 0000000..7fa875f --- /dev/null +++ b/apps/app/composables/useToast.ts @@ -0,0 +1,47 @@ +/** + * Toasts globaux — feedback non bloquant (succès/erreur/info) après une action. + * État partagé via useState (SSR-safe) ; le rendu est assuré par + * monté une seule fois dans app.vue. + */ +export interface ToastItem { + id: number + variant: 'success' | 'error' | 'info' + title: string + description?: string +} + +export interface ToastOptions { + /** Durée d'affichage en ms ; 0 = persistant (fermeture manuelle). */ + duration?: number +} + +let nextId = 1 + +export function useToast() { + const toasts = useState('ui-toasts', () => []) + + function dismiss(id: number) { + toasts.value = toasts.value.filter((t) => t.id !== id) + } + + function push(toast: Omit, options?: ToastOptions): number { + const id = nextId++ + toasts.value = [...toasts.value, { id, ...toast }] + const duration = options?.duration ?? (toast.variant === 'error' ? 6000 : 4000) + if (import.meta.client && duration > 0) { + setTimeout(() => dismiss(id), duration) + } + return id + } + + return { + toasts, + dismiss, + success: (title: string, description?: string, options?: ToastOptions) => + push({ variant: 'success', title, description }, options), + error: (title: string, description?: string, options?: ToastOptions) => + push({ variant: 'error', title, description }, options), + info: (title: string, description?: string, options?: ToastOptions) => + push({ variant: 'info', title, description }, options), + } +} diff --git a/apps/app/config/pricing.ts b/apps/app/config/pricing.ts index 53e0668..76a8a62 100644 --- a/apps/app/config/pricing.ts +++ b/apps/app/config/pricing.ts @@ -1,22 +1,25 @@ /** * Pricing — CONFIG, jamais figé en dur dans les composants. * - * ⚠️ Arbitrage RÉSERVÉ AU RÉFÉRENT (pricing THI-119) : le montant exact et la - * liste précise des features par plan sont à valider. Fourchette validée - * 9–15 €/mois ; `PREMIUM_PRICE_EUR` est un PLACEHOLDER au milieu de la - * fourchette, à confirmer avant tout go-live. + * Modèle acté (2026-06-10, fondateur) : 2 générations de CV OFFERTES pour tester, + * puis achat de PACKS DE CRÉDITS one-shot (Stripe Checkout, pas d'abonnement). + * 1 crédit = 1 génération de CV optimisé. Pas d'offre illimitée : c'est le seul + * scénario déficitaire (coût API ~0,30–0,40 € par génération, marge packs ~60 %). */ -export const PREMIUM_PRICE_EUR = 12 as const // placeholder 9–15 € — à arbitrer (THI-119) -export interface PlanConfig { - /** clé i18n du plan dans i18n/fr.ts > pricing.plans */ - key: 'free' | 'premium' - /** prix mensuel en € ; null = gratuit */ - priceEur: number | null +/** Générations offertes à l'inscription (aligné sur FREE_TIER_QUOTAS.generation). */ +export const FREE_GENERATIONS = 2 as const + +export interface CreditPackConfig { + /** clé i18n du pack dans i18n/fr.ts > pricing.packs */ + key: 'starter' | 'standard' + priceEur: number + credits: number + /** Pack mis en avant visuellement (badge « Le plus populaire »). */ featured: boolean } -export const PLANS: readonly PlanConfig[] = [ - { key: 'free', priceEur: null, featured: false }, - { key: 'premium', priceEur: PREMIUM_PRICE_EUR, featured: true }, +export const CREDIT_PACKS: readonly CreditPackConfig[] = [ + { key: 'starter', priceEur: 5, credits: 5, featured: false }, + { key: 'standard', priceEur: 12, credits: 15, featured: true }, ] diff --git a/apps/app/i18n/fr.ts b/apps/app/i18n/fr.ts index 390860c..33d6789 100644 --- a/apps/app/i18n/fr.ts +++ b/apps/app/i18n/fr.ts @@ -3,7 +3,10 @@ * validé CMO « landing-copy », THI-128). AUCUN texte en dur dans les templates : * les composants lisent ces clés via useLanding(). * - * `{brand}` est un placeholder interpolé par useLanding() (config/brand.ts). + * Placeholders interpolés par useLanding() : + * - `{brand}` → constante BRAND (config/brand.ts) + * - `{freeGenerations}` → constante FREE_GENERATIONS (config/pricing.ts) + * * Structure pensée i18n : ce fichier devient le locale `fr` quand le module * i18n dédié sera branché (clés inchangées, swap trivial). */ @@ -13,30 +16,39 @@ export const fr = { meta: { title: '{brand} — CV adapté à chaque offre d’emploi, sans mentir', description: - 'Reformulez et adaptez votre CV à chaque offre en quelques minutes. {brand} met en valeur votre expérience réelle — jamais de fausses compétences. Gratuit, sans mot de passe.', + 'Adaptez votre CV à chaque offre en quelques minutes. {brand} met en valeur votre expérience réelle — jamais de fausses compétences. {freeGenerations} CV offerts, sans abonnement.', + }, + + nav: { + signIn: 'Se connecter', }, hero: { + eyebrow: 'Votre prochain CV, taillé sur mesure', h1Lead: 'Un CV taillé pour chaque offre.', h1Accent: 'Sans jamais mentir.', subtitle: '{brand} reformule et hiérarchise votre expérience réelle pour coller à l’offre que vous visez. Jamais de fausses compétences — juste votre parcours, mis en valeur.', cta: 'Créer mon CV adapté — gratuit', trust: - 'Sans carte bancaire · Lien de connexion par e-mail · Vos données restent privées (RGPD)', - visualBefore: 'CV générique', - visualAfter: 'CV réaligné sur l’offre', - visualNote: 'Aucune ligne inventée — uniquement réordonnée et reformulée.', + 'Sans carte bancaire · {freeGenerations} CV offerts · Vos données restent privées (RGPD)', + mockup: { + badge: 'Match 87 %', + beforeLabel: 'CV générique', + afterLabel: 'CV adapté à l’offre', + }, }, problem: { + eyebrow: 'Le constat', h2: 'Le même CV pour 30 offres ? Les recruteurs le voient. Les ATS aussi.', body: 'Un CV générique se noie. Le réécrire à la main pour chaque offre prend une heure que vous n’avez pas. Et les générateurs « IA » règlent le problème en inventant des compétences que vous devrez assumer en entretien.', transition: 'Il y a une troisième voie.', }, how: { - h2: 'Comment ça marche', + eyebrow: 'Comment ça marche', + h2: 'Trois étapes, quelques minutes.', steps: [ { title: 'Importez votre parcours', @@ -54,6 +66,7 @@ export const fr = { }, guardrail: { + eyebrow: 'Notre engagement', h2: 'Optimisé, jamais inventé.', promise: '{brand} ne fabrique jamais de compétence, de diplôme ou d’expérience que vous n’avez pas. Notre IA hiérarchise, reformule et met en valeur ce qui existe déjà dans votre parcours — rien d’autre.', @@ -70,6 +83,7 @@ export const fr = { }, audience: { + eyebrow: 'Pour qui ?', h2: 'Pensé pour celles et ceux qui postulent vraiment.', cards: [ { title: 'Jeunes diplômés', body: 'Faire ressortir l’essentiel d’un parcours court.' }, @@ -79,38 +93,63 @@ export const fr = { }, pricing: { - h2: 'Commencez gratuitement. Passez Premium quand ça compte.', - perMonth: '/mois', - free: '0 €', - legal: 'Sans engagement · résiliable à tout moment.', - plans: { - free: { - name: 'Gratuit', - tagline: 'Idéal pour tester sur votre prochaine candidature.', - features: ['Adaptation à l’offre', 'Export du CV', 'Découverte du garde-fou'], - cta: 'Créer mon CV gratuit', + eyebrow: 'Tarifs simples', + h2: 'Vos {freeGenerations} premiers CV sont offerts.', + intro: + 'Ensuite, des packs de crédits sans abonnement : 1 crédit = 1 CV adapté à une offre. Vous payez quand vous postulez, pas tous les mois.', + featuredBadge: 'Le plus populaire', + freePrice: '0 €', + creditsSuffix: 'crédits', + perCv: 'soit {price} par CV', + legal: 'Paiement sécurisé · Sans abonnement, sans engagement.', + free: { + name: 'Découverte', + creditsNote: '{freeGenerations} CV adaptés offerts', + tagline: 'Pour tester {brand} sur vos prochaines candidatures.', + features: [ + '{freeGenerations} CV adaptés à une offre, offerts à l’inscription', + 'Garde-fou anti-invention inclus', + 'Export PDF prêt à envoyer', + 'Sans carte bancaire', + ], + cta: 'Commencer gratuitement', + }, + packs: { + starter: { + name: 'Pack Starter', + tagline: 'Pour une recherche ciblée, au rythme de vos candidatures.', + features: [ + '1 crédit = 1 CV adapté à une offre', + 'Toutes les fonctionnalités incluses', + 'Paiement unique, sans abonnement', + ], + cta: 'Choisir Starter', }, - premium: { - name: 'Premium', - tagline: 'Pour celles et ceux qui postulent en série.', + standard: { + name: 'Pack Standard', + tagline: 'Pour postuler en série, au meilleur prix par CV.', features: [ - 'Adaptations illimitées', - 'Variantes par offre', - 'Historique de candidatures', - 'Exports avancés', + '1 crédit = 1 CV adapté à une offre', + 'Le meilleur prix par CV', + 'Paiement unique, sans abonnement', ], - cta: 'Passer Premium', + cta: 'Choisir Standard', }, }, }, faq: { + eyebrow: 'On vous répond', h2: 'Questions fréquentes', items: [ { q: 'Est-ce que {brand} invente des compétences ?', a: 'Non, jamais. C’est notre règle non négociable : on réordonne et reformule votre expérience réelle, on n’ajoute rien.', }, + { + q: 'Combien ça coûte ?', + a: 'Vos {freeGenerations} premiers CV sont offerts à l’inscription. Ensuite, des packs de crédits sans abonnement : à partir de 1 € le CV adapté à une offre. Vous ne payez que ce que vous utilisez.', + }, { q: 'Faut-il créer un mot de passe ?', a: 'Non — connexion par lien magique envoyé sur votre e-mail.', @@ -133,14 +172,14 @@ export const fr = { finalCta: { h2: 'Votre prochaine candidature mérite mieux qu’un copier-coller.', cta: 'Créer mon CV adapté — gratuit', - trust: 'Sans carte bancaire · RGPD · Honnête par conception.', + trust: 'Sans carte bancaire · {freeGenerations} CV offerts · Honnête par conception.', }, signup: { emailLabel: 'Votre e-mail', emailPlaceholder: 'vous@email.com', submit: 'Recevoir mon lien de connexion', - sentTitle: 'C’est envoyé ✉️', + sentTitle: 'C’est envoyé !', sentBody: 'Cliquez sur le lien dans votre e-mail pour continuer. Pensez aux indésirables.', errorInvalid: 'Cet e-mail ne semble pas valide — vérifiez et réessayez.', errorNetwork: 'Une erreur est survenue — réessayez dans un instant.', diff --git a/apps/app/layouts/default.vue b/apps/app/layouts/default.vue index 806fe47..a87d312 100644 --- a/apps/app/layouts/default.vue +++ b/apps/app/layouts/default.vue @@ -1,11 +1,190 @@ + +