Développeur Full‑Stack

Je conçois des plateformes web multilingues, sécurisées et soignées, du CMS à l'interface.

Collaborons
Portrait de Castole Agossou Yao

Technologies : Next.js, React, TypeScript, Tailwind CSS, Strapi, Supabase, Sanity, PostgreSQL, Node.js, Docker, Linux, Framer Motion, Three.js, GitHub Actions, Vercel.

Aperçu du projet Zielscheibe Nachhilfe
En ligne

Zielscheibe Nachhilfe

Plateforme de soutien scolaire et de coaching d'apprentissage à Essen, dans la Ruhr et en ligne : demande d'aides (BuT), contrat avec signature numérique et PDF, rendez-vous en ligne et espace membres, en 7 langues dont l'arabe.

Next.js 16Strapi 5TypeScriptTailwind CSSPostgreSQL

zielscheibe-nachhilfe.de

Étude de cas

Zielscheibe Nachhilfe

Plateforme de soutien scolaire et de coaching d'apprentissage à Essen, dans la Ruhr et en ligne : demande d'aides (BuT), contrat avec signature numérique et PDF, rendez-vous en ligne et espace membres, en 7 langues dont l'arabe.

Mon rôle. Conception et développement complets : front, CMS, espace membres, contrats et déploiement.

Next.js 16Strapi 5TypeScriptTailwind CSSPostgreSQL

Défis & solutions

  1. 01 · Problème

    Les familles parlent arabe, turc, ukrainien, russe… et les contrats de certaines sortaient vides.

    Solution

    7 langues avec prise en charge complète de l'écriture de droite à gauche, noms en alphabet latin acceptés pour toutes les familles, et un générateur de PDF qui embarque les bonnes polices pour que chaque contrat soit complet.

    frontend/lib/pdf-contract-generator.ts

    Avant

    try {
      const form = mainPdfDoc.getForm();
      // …
      const tf1_6 = form.getTextField('Textfeld 1_6');
      if (tf1_6 && studentName) tf1_6.setText(studentName);
    
      const tf1_7 = form.getTextField('Textfeld 1_7');
      if (tf1_7 && data.dateOfBirth) tf1_7.setText(data.dateOfBirth);
      // …

    Après

    /**
     * pdf-lib lève une erreur quand un nom de champ manque, au lieu de renvoyer undefined. Donc
     * `getTextField('A') || getTextField('B')` n'atteignait jamais B, et un champ renommé interrompait
     * le reste de son bloc, en silence, dans un try/catch, laissant une partie d'un contrat ou d'un
     * formulaire de consentement vide.
     */
    function lenientForm(form: PDFForm, label: string) {
      const lookup = <T>(kind: string, name: string, get: () => T): T | undefined => {
        try {
          return get();
        } catch {
          console.warn(`[PDF-GENERATOR] ${label}: ${kind} "${name}" fehlt in der Vorlage`);
          return undefined;
        }
      };
      return {
        // Le texte passe par toWinAnsi : l'Helvetica du formulaire ne sait pas dessiner « ş » ni « ı »,
        // et une seule erreur de ce genre laissait tout le contrat vide.
        getTextField: (name: string) => {
          const field = lookup('Textfeld', name, () => form.getTextField(name));
          return field ? { setText: (value?: string) => field.setText(value === undefined ? value : toWinAnsi(value)) } : undefined;
        },
        // …
      };
    }
    
    const form = lenientForm(mainPdfDoc.getForm(), 'Vertrag');
  2. 02 · Problème

    Données personnelles d'enfants et de parents : la loi impose des durées de conservation et une suppression propre.

    Solution

    Une purge complète validée par un avocat, qui ne désabonne plus les lecteurs de la newsletter dont le consentement est toujours en vigueur.

    backend/src/services/gdpr-retention.js

    Avant

    const stale = await strapi.documents(uid).findMany({
      filters: { createdAt: { $lt: cutoff } },
      limit: BATCH_SIZE,
      // …
    });

    Après

    /**
     * Un abonné confirmé à la newsletter est un consentement toujours en vigueur, pas une trace
     * d'échanges passés : purger selon createdAt désabonnait chaque lecteur trois ans après son
     * inscription, sans qu'il l'ait demandé.
     */
    const EXCLUDED_FROM_PURGE = {
      'api::newsletter-subscriber.newsletter-subscriber': { status: { $ne: 'Bestätigt' } },
    };
    
    const stale = await strapi.documents(uid).findMany({
      filters: { createdAt: { $lt: cutoff }, ...(EXCLUDED_FROM_PURGE[uid] || {}) },
      limit: BATCH_SIZE,
      // …
    });
  3. 03 · Problème

    Un site public avec des comptes est une cible, et une faille du framework a été publiée pendant le projet.

    Solution

    Content-Security-Policy stricte par nonce, d'abord en mode rapport, jetons CSRF, et mise à jour immédiate de Next.js dès l'annonce des failles de gravité élevée.

    frontend/proxy.ts

    Avant

    const csp = `
      default-src 'self';
      script-src 'self' 'unsafe-inline' ${evalDirective} https://challenges.cloudflare.com https://www.googletagmanager.com …;
      style-src 'self' 'unsafe-inline' https://cdn-cookieyes.com;
      // …`

    Après

    const STRICT_CSP = process.env.CSP_STRICT === 'true';
    
    function generateNonce(): string {
      const bytes = new Uint8Array(16);
      crypto.getRandomValues(bytes);
      let binary = '';
      for (const b of bytes) binary += String.fromCharCode(b);
      return btoa(binary);
    }
    
    // Faire confiance aux scripts par nonce et les laisser charger leurs propres dépendances
    // (strict-dynamic).
    const scriptSrcPrefix =
      STRICT_CSP && nonce
        ? `'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-inline' ${evalDirective}`
        : `'self' 'unsafe-inline' ${evalDirective}`;
    
    const csp = `
      default-src 'self';
      script-src ${scriptSrcPrefix} https://challenges.cloudflare.com …;
      // …`
  4. 04 · Problème

    Quand le CMS avait un souci, des pages devenaient des 404 et le serveur atteignait ses propres limites de débit.

    Solution

    Des pages d'accueil qui restent en ligne si Strapi tombe, un jeton serveur sur chaque lecture, et un dossier en échec qui n'est plus régénéré à l'infini.

    frontend/app/[locale]/angebote/[...slug]/page.tsx

    Avant

    let strapiOffer = await getAngebotBySlugServer(fullSlug, locale).catch(() => null);
    if (!strapiOffer && slugArray.length > 1) {
      strapiOffer = await getAngebotBySlugServer(normalizedSlug, locale).catch(() => null);
    }

    Après

    // Un Strapi injoignable est mémorisé, sans être transformé en « introuvable » : les contenus
    // statiques de secours ci-dessous sont quand même essayés, et seul un slug qu'aucun ne connaît lève
    // une erreur (non mise en cache) au lieu d'un 404 (mis en cache).
    let strapiUnavailable = false;
    const lookup = (slugToFind: string) =>
      getAngebotBySlugServer(slugToFind, locale, { throwWhenUnavailable: true }).catch(() => {
        strapiUnavailable = true;
        return null;
      });
    let strapiOffer = await lookup(fullSlug);
    if (!strapiOffer && slugArray.length > 1) {
      strapiOffer = await lookup(normalizedSlug);
    }
    // …
    if (strapiUnavailable) {
      throw new Error(`Strapi unavailable while resolving /angebote/${fullSlug}`);
    }
    return null;
  5. 05 · Problème

    Strapi limite les lectures publiques à 120 par minute et par IP, et chaque rendu de page part des mêmes quelques adresses Vercel : les rendus du site lui-même atteignaient la limite et recevaient des 429 (équipe absente de la page À propos, sitemap vide, horaires en repli statique).

    Solution

    Chaque appel serveur porte désormais un jeton partagé que Strapi ne compte pas, dans une variable sans préfixe NEXT_PUBLIC_ pour qu'elle n'atteigne jamais un bundle navigateur. Un appel direct sans jeton reste limité.

    frontend/lib/strapi-server.ts

    Code

    // Strapi limite les GET publics par IP (120/min, middlewares/rate-limit.js), et chaque rendu
    // serveur part des mêmes quelques adresses Vercel : les rendus des pages du site remplissaient
    // cette fenêtre et recevaient des 429 (contenu de secours, plan du site vide). Le jeton partagé du
    // serveur indique que l'appel vient de ce serveur, que Strapi ne compte pas. La variable n'a pas de
    // préfixe NEXT_PUBLIC_, donc elle n'atteint jamais le code envoyé au navigateur.
    if (FORM_API_TOKEN && !headers.has('X-Api-Token')) headers.set('X-Api-Token', FORM_API_TOKEN);
  6. 06 · Problème

    Un dossier de documents qui échouait sans cesse (pièce illisible, fournisseur en panne pendant des jours) était régénéré et renvoyé toutes les cinq minutes, sans fin.

    Solution

    Un compteur de tentatives stocké sur l'enregistrement : après 5 essais, le dossier reste « failed » pour que l'équipe le regarde, et la tâche de reprise l'ignore.

    backend/src/services/document-jobs.js

    Avant

    await strapi.db.query(uid).updateMany({ where: { documentId, documentLease: lease }, data: { documentStatus: 'failed', documentLease: null, documentLeaseExpiresAt: null } });
    strapi.log.error(`[DOCUMENTS] ${kind} dossier retained for retry: ${error.message}`);
    // …
    filters: { $or: [{ documentStatus: { $in: ['pending', 'failed'] } }, { documentStatus: 'processing', documentLeaseExpiresAt: { $lt: new Date().toISOString() } }] }, limit: 25, sort: ['updatedAt:asc'],

    Après

    /**
     * Un dossier qui échoue en boucle (pièce jointe illisible, prestataire en panne pendant des jours)
     * était régénéré et renvoyé toutes les cinq minutes sans fin. Après ce nombre d'essais, il reste «
     * en échec » pour que l'équipe s'en occupe.
     */
    const MAX_DOCUMENT_ATTEMPTS = 5;
    
    // …
    const attempts = (record.documentAttempts || 0) + 1;
    await strapi.db.query(uid).updateMany({ where: { documentId, documentLease: lease }, data: { documentStatus: 'failed', documentLease: null, documentLeaseExpiresAt: null, documentAttempts: attempts } });
    // …
    filters: { $or: [
      { documentStatus: 'pending' },
      { documentStatus: 'failed', $or: [{ documentAttempts: { $null: true } }, { documentAttempts: { $lt: MAX_DOCUMENT_ATTEMPTS } }] },
      { documentStatus: 'processing', documentLeaseExpiresAt: { $lt: new Date().toISOString() } },
    ] }, limit: 25, sort: ['updatedAt:asc'],
  7. 07 · Problème

    Les sessions des membres sont des jetons sans état valables 7 jours : après un changement de mot de passe, les anciens jetons (un jeton volé, un appareil oublié) continuaient de fonctionner.

    Solution

    L'instant du changement est stocké sur l'utilisateur et tout jeton émis avant est refusé, arrondi à la seconde pour que le jeton émis juste après le changement fonctionne. Le nouveau jeton remplace le cookie : le membre reste connecté. Les jetons de confirmation sont stockés hachés.

    backend/src/lib/session-revocation.js

    Code

    /**
     * Les sessions se terminent quand le mot de passe change. Les JWT des membres sont sans état et
     * valables jusqu'à 7 jours. Changer ou réinitialiser le mot de passe enregistre l'instant dans
     * user.passwordChangedAt ; tout jeton émis avant (son « iat », en secondes entières) est alors
     * refusé. L'instant est arrondi à la seconde pour que le jeton émis juste après le changement, dans
     * la même seconde, fonctionne encore.
     */
    function passwordChangeInstant(now = Date.now()) {
      return new Date(Math.floor(now / 1000) * 1000);
    }
    
    function issuedBeforePasswordChange(tokenPayload, passwordChangedAt) {
      if (!passwordChangedAt) return false;
      const changedAt = new Date(passwordChangedAt).getTime();
      if (Number.isNaN(changedAt)) return false;
      // Un jeton sans « iat » ne peut pas prouver qu'il est plus récent que le changement.
      if (typeof tokenPayload?.iat !== 'number') return true;
      return tokenPayload.iat * 1000 < changedAt;
    }

En chiffres

  • 7

    langues, dont l'arabe de droite à gauche

  • 2 026

    tests automatisés (987 front, 1 039 back)

  • 975

    commits, avec intégration continue

Visiter le site
Aperçu du projet École Notre Dame du Mont Carmel
En ligne

École Notre Dame du Mont Carmel

Site de l'école de Nikki (Bénin), liée à la fondation FUNDEBE : présentation, infos pratiques, actualités, étapes de scolarité et inscription en ligne, en 4 langues et installable comme une application (PWA).

Next.js 14Tailwind CSSFramer MotionPWA

ndmc-nikki.netlify.app

Étude de cas

École Notre Dame du Mont Carmel

Site de l'école de Nikki (Bénin), liée à la fondation FUNDEBE : présentation, infos pratiques, actualités, étapes de scolarité et inscription en ligne, en 4 langues et installable comme une application (PWA).

Mon rôle. Refonte et développement complets du site de l'école, du design system au déploiement.

Next.js 14Tailwind CSSFramer MotionPWA

Défis & solutions

  1. 01 · Problème

    Les textes étaient écrits en dur et la bibliothèque de traduction ne fonctionnait pas avec un export statique : le premier rendu n'était pas traduit ou échouait à l'hydratation.

    Solution

    Un schéma hybride : un composant serveur fixe la langue avant le rendu et un provider client ajoute react-i18next, si bien que les traductions marchent côté serveur et dans le navigateur.

    frontend/src/i18n/I18nProvider.tsx

    Avant

    export default function I18nProvider({ children, locale }: { children: ReactNode; locale: string }) {
      const [Provider, setProvider] = useState<ComponentType<{ i18n: unknown; children: ReactNode }> | null>(null);
    
      useEffect(() => {
        if (i18n.language !== locale) {
          i18n.changeLanguage(locale);
        }
        let cancelled = false;
        import("react-i18next").then((mod) => {
          if (!cancelled) {
            setProvider(() => mod.I18nextProvider as ComponentType<{ i18n: unknown; children: ReactNode }>);
          }
        });
        return () => { cancelled = true; };
      }, [locale]);
    
      if (!Provider) {
        return <>{children}</>;
      }
    
      return <Provider i18n={i18n}>{children}</Provider>;
    }

    Après

    // I18nServerSync.tsx : pas de « use client », s'exécute uniquement sur le serveur
    export default function I18nServerSync({ locale, children }: { locale: Locale; children: React.ReactNode }) {
      // Forcer i18next sur la bonne langue pendant le rendu serveur
      if (i18n.language !== locale) {
        i18n.changeLanguage(locale);
      }
      return <>{children}</>;
    }
    
    // I18nProvider.tsx : côté client
    if (!i18next.isInitialized) {
      i18next.use(initReactI18next);
    }
    
    export default function I18nProvider({ children }: { children: ReactNode }) {
      useEffect(() => {
        // S'assurer que react-i18next est initialisé
        if (!i18next.hasResourceBundle(i18next.language, "translation")) {
          i18next.use(initReactI18next);
        }
      }, []);
    
      return <I18nextProvider i18n={i18next}>{children}</I18nextProvider>;
    }
    
    // layout.tsx
    <I18nServerSync locale={locale}>
      <I18nProvider>
        <Navbar />
        {/* … */}
      </I18nProvider>
    </I18nServerSync>
  2. 02 · Problème

    Les erreurs de formulaire restaient en français : avec Zod 4, les messages personnalisés écrits dans les schémas contournaient la table d'erreurs et n'étaient jamais traduits.

    Solution

    Messages retirés des schémas et une seule table d'erreurs qui transforme champ et type d'erreur en clé de traduction, dans les 4 langues.

    frontend/src/lib/validations.ts

    Avant

    enfantNom: z
      .string()
      .min(2, "zodErrors.nameMin")
      .max(100, "zodErrors.nameMax")
      .regex(/^[a-zA-ZÀ-ÿ\s'-]+$/, "zodErrors.nameRegex"),
    
    dateNaissance: z
      .string()
      .min(1, "zodErrors.dateNaissanceRequired")
      .refine((val) => {
        // …
      }, "zodErrors.dateNaissanceAge"),

    Après

    enfantNom: z
      .string()
      .min(2)
      .max(100)
      .regex(/^[a-zA-ZÀ-ÿ\s'-]+$/),
    
    dateNaissance: z
      .string()
      .min(1)
      .refine((val) => {
        // …
      }),
  3. 03 · Problème

    Les photos de l'école pesaient trop lourd pour les connexions au Bénin.

    Solution

    Conversion de toutes les images en WebP, de 6,2 Mo à 2,6 Mo (−57 %), et une PWA installable avec service worker.

  4. 04 · Problème

    Un site d'école doit être trouvé, lisible par tous et compris des moteurs de recherche.

    Solution

    Sitemap, robots.txt, balises canonical et hreflang, données Schema.org, travail d'accessibilité (mouvement réduit, ARIA) et un design system aux couleurs de l'école.

En chiffres

  • −57 %

    de poids d'images (de 6,2 Mo à 2,6 Mo)

  • 4

    langues

  • PWA

    installable et utilisable hors ligne

Visiter le site
Aperçu du projet QuestPocket
En ligne

QuestPocket

Landing page de Pocket Quest, une application mobile qui transforme le temps d'écran en récompenses : on gagne du temps sur les réseaux sociaux en accomplissant des tâches saines. Fond 3D en pixel art animé, défilement fluide, liste d'attente et compte à rebours, en 3 langues.

Next.js 14Tailwind CSSThree.jsGSAPLenis

questpocket.netlify.app

Étude de cas

QuestPocket

Landing page de Pocket Quest, une application mobile qui transforme le temps d'écran en récompenses : on gagne du temps sur les réseaux sociaux en accomplissant des tâches saines. Fond 3D en pixel art animé, défilement fluide, liste d'attente et compte à rebours, en 3 langues.

Mon rôle. Conception et développement de la landing page. L'application mobile elle-même (Flutter, NestJS) est encore en phase de spécification.

Next.js 14Tailwind CSSThree.jsGSAPLenis

Défis & solutions

  1. 01 · Problème

    Le fond 3D en pixel art construit avec Three.js cassait le rendu serveur et provoquait des erreurs d'hydratation, car Three.js ne fonctionne que dans le navigateur.

    Solution

    Les composants WebGL sont chargés côté client seulement, avec un import dynamique et le rendu serveur désactivé, tandis que le reste de la page reste un export statique.

    landing-react/src/components/PixelBackground.tsx

    Avant

    export default function PixelBackground() {
      return (
        <div className="fixed inset-0 z-0" style={{ filter: 'blur(60px)', opacity: 0.35, pointerEvents: 'none' }}>
          <Canvas
            // …
          </Canvas>
        </div>
      )
    }

    Après

    import dynamic from 'next/dynamic'
    
    function PixelBackgroundInner() {
      return (
        <div className="fixed inset-0 z-0" style={{ filter: 'blur(60px)', opacity: 0.35, pointerEvents: 'none' }}>
          <Canvas
            // …
          </Canvas>
        </div>
      )
    }
    
    export default dynamic(() => Promise.resolve(PixelBackgroundInner), { ssr: false })
  2. 02 · Problème

    Les apparitions au scroll dépendaient d'un hook JavaScript qui basculait une classe : le contenu restait invisible jusqu'à l'hydratation, et le défilement fluide ne fonctionnait pas dans l'export statique.

    Solution

    Apparitions réécrites en pures animations CSS avec des délais échelonnés, donc sans JavaScript, et Lenis chargé dans un effet une fois la page prête.

    landing-react/src/app/globals.css

    Avant

    .reveal {
      opacity: 0;
      transform: translateY(

    Après

    /* Animations d'apparition liées au défilement : CSS seul, aucun JS nécessaire */
    .reveal {
      animation: revealUp 0.

En chiffres

  • 3

    langues (anglais, français, allemand)

  • 6

    itérations de design en 3 jours

  • 3D

    fond pixel art en WebGL

Visiter le site
Aperçu du projet Laserbeni
En cours

Laserbeni

Plateforme et application installable (PWA) d'un institut d'épilation laser durable en Allemagne : zones et tarifs, offres, FAQ, avant/après, réservation intégrée via Beautinda, contenus gérés dans Sanity et ambiance 3D en verre.

Next.jsTailwind CSSSanityThree.jsPWA

Livraison fin octobre 2026

Étude de cas

Laserbeni

Plateforme et application installable (PWA) d'un institut d'épilation laser durable en Allemagne : zones et tarifs, offres, FAQ, avant/après, réservation intégrée via Beautinda, contenus gérés dans Sanity et ambiance 3D en verre.

Mon rôle. Conception et développement de la plateforme et de l'application installable.

Next.jsTailwind CSSSanityThree.jsPWA

Défis & solutions

  1. 01 · Problème

    Les clientes doivent réserver sans quitter l'application, mais le studio utilise déjà le système de réservation Beautinda.

    Solution

    Beautinda s'ouvre dans une fenêtre modale à l'intérieur de la PWA, compte client compris pour consulter ou déplacer ses rendez-vous : une seule expérience.

  2. 02 · Problème

    Le public est sur téléphone et attend une application, sans passer par un magasin d'applications.

    Solution

    PWA pensée mobile d'abord : manifeste, service worker, mode hors ligne et bandeaux d'installation iOS et Android, prête pour un emballage natif plus tard.

  3. 03 · Problème

    Les prix, offres et photos changent souvent, et la gérante n'est pas développeuse.

    Solution

    Contenu géré dans Sanity : prix, forfaits, photos avant/après et FAQ se modifient sans toucher au code.

En chiffres

  • PWA

    installable sur iOS et Android

  • 1

    parcours de réservation, sans quitter l'application

Livraison fin octobre 2026

Sites & plateformes web

Sites et applications sur mesure avec Next.js, React, TypeScript et Tailwind CSS : rapides, bien référencés, installables en PWA quand c'est utile.

CMS & back-end

Contenus gérables sans développeur avec Strapi, Sanity ou Supabase, API REST, espaces membres avec authentification, e-mails automatiques et documents PDF.

Identité & UI/UX

Identité visuelle, maquettes UI/UX et guides graphiques, puis supports marketing : flyers, brochures et visuels pour les réseaux sociaux.

Multilingue & accessible

Sites jusqu'à 7 langues, dont l'arabe avec lecture de droite à gauche, pensés pour tous les publics et tous les écrans.

Infrastructure & mise en ligne

Conteneurs Docker, serveurs Linux, intégration continue, protection anti-spam et en-têtes de sécurité, déploiement sur Vercel, Netlify ou Railway.

Les e-mails de confirmation envoyés par mon formulaire de contact, conçus et codés à la main : tableaux, styles en ligne, animations CSS et version texte. Voyez l'aperçu, puis lisez le vrai code.

Une carte claire : un logo pixel dont le >_ se tape tout seul, une apparition progressive, le récapitulatif de la demande et une chronologie numérotée.

C'est le vrai e-mail envoyé par le formulaire, avec des données d'exemple, dans la langue de la page. Les animations jouent dans Apple Mail et iOS Mail ; Gmail, Yahoo et Outlook affichent le même e-mail sans mouvement. Les vrais fichiers source du dépôt, découpés par rôle (gabarit, sections, styles, thème) : le même code construit l'aperçu ci-dessus et l'e-mail envoyé aux visiteurs. Le style se choisit avec une seule variable, CONFIRMATION_STYLE.

Développeur full-stack avec une solide base en réseaux et télécommunications. Je conçois des plateformes web modernes (Next.js, React, TypeScript), je mets en place l'infrastructure qui les fait tourner et j'applique les technologies actuelles sur de vrais projets, du CMS à l'interface.

Front-end

  • Next.js
  • React
  • TypeScript
  • Tailwind CSS
  • Design responsive
  • UX / UI design

Back-end & CMS

  • Strapi
  • Supabase
  • Sanity
  • PostgreSQL
  • API REST
  • SEO (bases)

Infrastructure & réseaux

  • Docker
  • Portainer
  • Linux (Kali)
  • SSH · Tailscale
  • CI/CD
  • Réseaux & sécurité IT
  • Fibre optique

Développement natif IA

  • Codage agentique
  • Développement assisté par IA
  • Workflows natifs IA
  • Intégration de LLM (Groq, OpenAI)

Transmission & langues

  • Pédagogie
  • Création de supports
  • Français (natif)
  • Allemand (C1)
  • Anglais (B1)

Homelab

Cluster personnel (ZimaBoard, Raspberry Pi) pour héberger mes propres services, géré avec Docker et Portainer et des pipelines CI/CD automatisés.

Ensemble, on peut créer quelque chose de clair et d'impactant. Réserve un créneau ou écris-moi : on en parle autour d'un café (virtuel).

❯ write --to castoleBerlin --:--

Salut, c'est Castole ! Dis-moi ce que tu as en tête : je lis chaque message moi-même.

Vos informations servent uniquement à vous répondre.

Castole Agossou Yao© 2026 Castole Agossou YaoRetour en haut