What the people I build for say about working together.
”
“Mr. Agossou did not merely build an ordinary website for us from a purchased standard template. Instead, he programmed and designed a fully custom, technically demanding web solution tailored exactly to our business requirements.”
/SKILLS
Websites & platforms
I build websites and applications with Next.js, React, TypeScript and Tailwind CSS: fast, search-friendly and installable as a PWA when it helps.
CMS & back-end
I manage content with Strapi, Sanity or Supabase, and build REST APIs, member areas with authentication, automatic e-mails and PDF documents.
GET /api/projects
200 OK · 42 ms
{
"title": "Zielscheibe",
"status": "live",
"cms": "Strapi 5"
}
Identity & UI/UX
I design UI/UX mockups and visual identities, down to the style guide.
AaStyle guide
Multilingual & accessible
I build sites in up to 7 languages, including right-to-left Arabic, designed for every audience and every screen.
ENHello
FRBonjour
DEHallo
ARمرحبا
Infrastructure & deployment
I work with Docker, Linux and continuous integration, protect forms (anti-spam, security headers) and deploy on Vercel, Netlify or Railway.
❯ docker compose up -d
✔ web · running
✔ db · healthy
✔ proxy · running
❯ git push (deploy)
Workshop
/WORKSHOP
Code and design I built from start to finish. Each piece shows the real code, and what it produces right beside it.
Laserbeni
The 3D zone selector
On the price page, the client touches a zone of a 3D body and sees its price. The starting body comes from a free library: everything that makes it usable, I designed and wrote.
Three.js
GLSL
Raycasting
24 zones
Code
features/atelier/zone-demo/stage.ts
1import{2AmbientLight,3BufferAttribute,4Color,5DirectionalLight,6Group,7Mesh,8PerspectiveCamera,9Scene,10ShaderMaterial,11WebGLRenderer,12}from"three";13import{GLTFLoader}from"three/examples/jsm/loaders/GLTFLoader.js";14import{ neighboursOf }from"./mesh";15import{ paint,typePickContext}from"./pick";16import{ attachPointer }from"./pointer";17import{ buildPodium }from"./podium";18import{ createSpin }from"./spin";19import{FRAGMENT,VERTEX}from"./shaders";20import{ headZone,typeZone}from"./zones";2122exporttypeStage={ show:(selection:ReadonlySet<Zone>)=>void; dispose:()=>void};23typeHandlers={ model: string; onPick:(zone:Zone)=>void; onFail:()=>void};2425/**26 * The 3D scene: loads the body (a mesh whose every point carries its zone), puts it on a podium and27 * handles the mouse and the finger (see `pointer.ts`): dragging turns the body, touching picks a28 * zone, hovering lights it up.29 */30exportfunctioncreateStage(container:HTMLElement,{ model, onPick, onFail }:Handlers):Stage{31const renderer =newWebGLRenderer({ antialias:true, alpha:true});32 renderer.setPixelRatio(Math.min(devicePixelRatio,2));33const canvas = renderer.domElement;34 canvas.style.cssText ="display:block;width:100%;height:100%;touch-action:pan-y;cursor:grab";35 container.appendChild(canvas);3637const scene =newScene();38const camera =newPerspectiveCamera(28,1,0.1,50);39const figure =newGroup();40const sun =newDirectionalLight(0xffe2c4,2);41 sun.position.set(1,2,2);42 scene.add(figure,newAmbientLight(0xffffff,1.4), sun);4344const material =newShaderMaterial({45 vertexShader:VERTEX,46 fragmentShader:FRAGMENT,47 uniforms:{48 uBase:{ value:newColor("#e8ddd0")},49 uGold:{ value:newColor("#c9a064")},50 uGoldLight:{ value:newColor("#e6c48f")},51 uGoldDeep:{ value:newColor("#8a6a38")},52 uTime:{ value:0},53},54});5556let ctx:PickContext|null=null;57let around:Uint32Array[]=[];58let selected:BufferAttribute;59let hovered:BufferAttribute;60let current:ReadonlySet<Zone>=newSet();61let disposed =false;6263newGLTFLoader().load(64 model,65(gltf)=>{66if(disposed)return;67const mesh = gltf.scene.children[0]asMesh;68const geometry = mesh.geometry;69 geometry.computeBoundingBox();70const box = geometry.boundingBox!;71const height = box.max.y - box.min.y;7273const zone = geometry.getAttribute("_zone");74const where = geometry.getAttribute("position");75// points without a zone (the head) get theirs from their position76const zoneOf =Float32Array.from({ length: zone.count },(_, i)=> zone.getX(i)||headZone(where.getX(i), where.getY(i), where.getZ(i)));77 around =neighboursOf(geometry.getIndex()!.array, zone.count);78 selected =newBufferAttribute(newFloat32Array(zone.count),1);79 hovered =newBufferAttribute(newFloat32Array(zone.count),1);80 geometry.setAttribute("aSel", selected);81 geometry.setAttribute("aHover", hovered);8283const body =newMesh(geometry, material);84 body.position.z =-(box.min.z + box.max.z)/2;85 figure.add(body);8687 scene.add(...buildPodium(box.min.y, height));8889const tan =Math.tan((camera.fov *Math.PI)/360);90 camera.userData ={ distance:(0.66* height)/ tan, lookY: box.min.y +0.5* height };91 ctx ={ mesh: body, camera, canvas, zoneOf };92show(current);93resize();94},95undefined,96 onFail,97);9899functionshow(selection:ReadonlySet<Zone>){100 current = selection;101if(ctx)paint(selected, ctx.zoneOf, around, selection,4);102}103104functionresize(){105const{ clientWidth: w, clientHeight: h }= container;106if(!w ||!h)return;107 renderer.setSize(w, h,false);108 camera.aspect = w / h;109const{ distance, lookY }= camera.userData as{ distance?: number; lookY?: number };110// the whole body must fit in the available height AND width111if(distance && lookY !==undefined){112 camera.position.set(0, lookY, distance /Math.min(1, camera.aspect *1.25));113 camera.lookAt(0, lookY,0);114}115 camera.updateProjectionMatrix();116}117const observer =newResizeObserver(resize);118 observer.observe(container);119120const spin =createSpin(figure);121attachPointer(canvas, figure,()=> ctx,{122 onPick,123 onTouch: spin.touch,124 onHover:(zone)=> ctx &&paint(hovered, ctx.zoneOf, around,newSet(zone ?[zone]:[]),3),125});126127let frame =0;128let previous =0;129const loop =(time: number)=>{130 spin.tick(previous ?(time - previous)/1000:0, time);131 previous = time;132 material.uniforms.uTime.value = time /1000;133 renderer.render(scene, camera);134 frame =requestAnimationFrame(loop);135};136 frame =requestAnimationFrame(loop);137138return{139 show,140dispose(){141 disposed =true;142cancelAnimationFrame(frame);143 observer.disconnect();144 renderer.dispose();145 canvas.remove();146},147};148}149
Preview
The code shown is the one behind this demo. On the Laserbeni site the selector goes further: back, face, packages and booking.
Profile
/PROFILE
Full-stack developer with a solid background in networking and telecommunications. I design and ship multilingual web platforms (Next.js, React, TypeScript, Node.js), from the database to the interface, with tests, automated deployment and security built in from the start.
Front-end
Next.js
React
TypeScript
Tailwind CSS
Three.js
Framer Motion
Responsive
UX / UI design
Back-end & data
Node.js
REST API
Strapi
Supabase
Sanity
PostgreSQL
Authentication & member areas
Multilingual, up to 7 languages
Quality & delivery
Automated tests
GitHub Actions (CI/CD)
Docker
Cloudflare Pages
Vercel
Netlify
PWA
Security & reliability
Server-side validation
Anti-spam (Turnstile)
Security headers
Linux
SSH · Tailscale
Networks and telecom
AI-assisted development
Reviewed and tested code
Agentic coding
AI-native workflows
LLM integration (Groq, OpenAI)
Languages & teaching
German (C1)
French (native)
English (B1)
Pedagogy
Teaching materials
Homelab
A personal cluster (ZimaBoard, Raspberry Pi) where I host my own services with Docker, to test for real what I put online.
A role, a project? Let's talk.
Write to me or plan a chat: let's talk over a (virtual) coffee.
❯ write --to castoleBerlin --:--
Hi, I'm Castole! Tell me what you have in mind: I read every message myself.