From 199c0234b400cc88013b04cace1a19b6aaabf006 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 19 May 2026 06:58:19 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Am=C3=A9liore=20SEO=20technique=20et=20usab?= =?UTF-8?q?ilit=C3=A9=20mobile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ERIC74SONG96 --- assets/css/style.css | 48 +++++++++++++++++++++++++++++++++++ assets/js/main.js | 20 +++++++++++++-- index.html | 10 +++----- package-lock.json | 4 +-- src/lib/static-html-pages.mjs | 35 +++++++++++++++++++++++-- 5 files changed, 104 insertions(+), 13 deletions(-) diff --git a/assets/css/style.css b/assets/css/style.css index d63052e..e6a93b8 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -10,6 +10,53 @@ body { -moz-osx-font-smoothing: grayscale; } +/* Empêche les débordements horizontaux imprévus sur mobile */ +html, +body { + max-width: 100%; + overflow-x: clip; +} + +/* Garde les médias et SVG dans le viewport */ +img, +svg, +video, +iframe { + max-width: 100%; +} + +/* Lisibilité mobile : évite les textes trop petits */ +@media (max-width: 640px) { + .text-xs { + font-size: 0.8125rem; + line-height: 1.2rem; + } + + input, + select, + textarea { + font-size: 16px; + } +} + +/* Confort tactile (44px mini recommandé) */ +@media (pointer: coarse) { + a.inline-flex, + button, + summary, + input[type="button"], + input[type="submit"] { + min-height: 44px; + } + + #mobileMenu a, + #mobileMenu summary { + min-height: 44px; + display: flex; + align-items: center; + } +} + /* Socle local pour les pages clés : évite un rendu brut si le CDN Tailwind arrive lentement ou est bloqué sur mobile. */ .font-sans { font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } @@ -181,6 +228,7 @@ textarea:focus-visible { right: 14px; bottom: 88px; z-index: 10000; + min-height: 44px; display: flex; align-items: center; justify-content: center; diff --git a/assets/js/main.js b/assets/js/main.js index 6e0ff47..76caf8e 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -192,9 +192,24 @@ document.addEventListener('DOMContentLoaded', function () { var menuBtn = document.getElementById('mobileMenuBtn'); var mobileMenu = document.getElementById('mobileMenu'); if (menuBtn && mobileMenu) { - menuBtn.addEventListener('click', function () { mobileMenu.classList.toggle('hidden'); }); + var setMenuOpen = function (isOpen) { + mobileMenu.classList.toggle('hidden', !isOpen); + menuBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + }; + setMenuOpen(!mobileMenu.classList.contains('hidden')); + menuBtn.addEventListener('click', function () { + setMenuOpen(mobileMenu.classList.contains('hidden')); + }); mobileMenu.addEventListener('click', function (ev) { - if (ev.target.closest('a')) mobileMenu.classList.add('hidden'); + if (ev.target.closest('a')) setMenuOpen(false); + }); + document.addEventListener('keydown', function (ev) { + if (ev.key === 'Escape') setMenuOpen(false); + }); + document.addEventListener('click', function (ev) { + if (mobileMenu.classList.contains('hidden')) return; + if (mobileMenu.contains(ev.target) || menuBtn.contains(ev.target)) return; + setMenuOpen(false); }); } } @@ -307,6 +322,7 @@ document.addEventListener('DOMContentLoaded', function () { var p = (window.location.pathname || '').toLowerCase(); if (p.indexOf('admin') !== -1) return; if (document.getElementById('saFloatWa')) return; + if (document.querySelector('a[href^="https://wa.me/32465339448"][class*="fixed"]')) return; var a = document.createElement('a'); a.id = 'saFloatWa'; a.href = 'https://wa.me/32465339448'; diff --git a/index.html b/index.html index 543e7e0..a0e15b5 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,8 @@ + + @@ -154,7 +156,7 @@ - @@ -1013,12 +1015,6 @@

Légal

- - - - - diff --git a/package-lock.json b/package-lock.json index c7a3baf..312e112 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "workspace", + "name": "studyalready-site", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "workspace", + "name": "studyalready-site", "version": "1.0.0", "license": "ISC", "devDependencies": { diff --git a/src/lib/static-html-pages.mjs b/src/lib/static-html-pages.mjs index 4ef631f..333dd40 100644 --- a/src/lib/static-html-pages.mjs +++ b/src/lib/static-html-pages.mjs @@ -97,6 +97,37 @@ function routeFromSourcePath(sourcePath) { return '/' + sourcePath.replace(/\/index\.html$/, '').replace(/\.html$/, ''); } +function ensureCanonicalLink(html, sourcePath) { + const route = routeFromSourcePath(sourcePath); + const canonical = `https://www.studyalready.com${route === '/' ? '' : route}`; + + if (/]*rel=["']canonical["']/i.test(html)) { + return html + .replace( + /(]*rel=["']canonical["'][^>]*href=["'])([^"']+)(["'][^>]*>)/i, + `$1${canonical}$3` + ) + .replace( + /(]*href=["'])([^"']+)(["'][^>]*rel=["']canonical["'][^>]*>)/i, + `$1${canonical}$3` + ); + } + + return html.replace('', ` \n`); +} + +function ensureRobotsMeta(html) { + if (/]*name=["']robots["']/i.test(html)) return html; + return html.replace( + '', + ' \n' + ); +} + +function ensureSeoBaseline(html, sourcePath) { + return ensureRobotsMeta(ensureCanonicalLink(html, sourcePath)); +} + function ogSlugFromSourcePath(sourcePath) { const route = routeFromSourcePath(sourcePath); return route === '/' ? 'home' : route.replace(/^\//, '').replace(/\//g, '--'); @@ -203,7 +234,7 @@ function staticHeaderHtml() { - @@ -378,7 +409,7 @@ export function readStaticHtmlPage(sourcePath) { exposeBlogArticleContent( injectStaticHeader( replaceSocialImage( - cleanSiteUrl(normalizeInternalLinks(html, sourcePath)), + ensureSeoBaseline(cleanSiteUrl(normalizeInternalLinks(html, sourcePath)), sourcePath), sourcePath ) ), From 8ad825bb43af4c7ec1275e968f778cdb23173063 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 14:47:44 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Ajoute=20des=20liens=20de=20partage=20job?= =?UTF-8?q?=20avec=20aper=C3=A7u=20d=C3=A9di=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ERIC74SONG96 --- api/job-share.js | 225 +++++++++++++++++++++++++++++++++++++ assets/js/student-jobs.mjs | 2 +- vercel.json | 6 + 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 api/job-share.js diff --git a/api/job-share.js b/api/job-share.js new file mode 100644 index 0000000..19eb261 --- /dev/null +++ b/api/job-share.js @@ -0,0 +1,225 @@ +const SUPABASE_URL = 'https://nevdhyekybmtvejhwhxz.supabase.co'; +const SUPABASE_ANON_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5ldmRoeWVreWJtdHZlamh3aHh6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzg1MTA4MzUsImV4cCI6MjA5NDA4NjgzNX0.drps-e29P2HfISCRsqglnbsi3YjYqw3_jIj2F4WYBOc'; +const JOBS_PAGE_URL = 'https://www.studyalready.com/offres-etudiants'; +const FALLBACK_OG_IMAGE = 'https://www.studyalready.com/assets/img/og/offres-etudiants.svg'; +const BUCKET = 'job-offers'; + +const OFFER_CATEGORY_LABELS = { + soutien_scolaire: 'Soutien scolaire', + emploi_universitaire: 'Emploi étudiant', + stage: 'Stage', + autre_communaute: 'Autre partage', +}; + +function escapeHtml(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function clipText(value, max = 200) { + const text = String(value || '').replace(/\s+/g, ' ').trim(); + if (text.length <= max) return text; + return text.slice(0, Math.max(0, max - 1)).trimEnd() + '…'; +} + +function stripLinksAndNoise(description) { + return String(description || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => !/^https?:\/\//i.test(line)) + .filter((line) => !/^lien\s*:/i.test(line)) + .filter((line) => !/^lien vers/i.test(line)) + .filter((line) => !/^image\s*:/i.test(line)) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeJobId(raw) { + const id = String(raw || '').trim(); + if (!/^[a-z0-9-]{8,64}$/i.test(id)) return ''; + return id; +} + +function categoryLabel(value) { + const key = String(value || '').trim(); + return OFFER_CATEGORY_LABELS[key] || 'Annonce étudiante'; +} + +function pickImageUrl(job) { + const ext = String(job?.external_image_url || '').trim(); + if (/^https:\/\//i.test(ext)) return ext; + const imagePath = String(job?.image_path || '').trim(); + if (!imagePath) return FALLBACK_OG_IMAGE; + return `${SUPABASE_URL}/storage/v1/object/public/${BUCKET}/${encodeURI(imagePath)}`; +} + +async function fetchJob(id) { + const headers = { + apikey: SUPABASE_ANON_KEY, + Authorization: `Bearer ${SUPABASE_ANON_KEY}`, + Accept: 'application/json', + }; + + const selects = [ + 'id,title,description,author_label,created_at,offer_category,external_image_url,image_path', + 'id,title,description,author_label,created_at,image_path', + ]; + + let lastError = null; + for (let i = 0; i < selects.length; i++) { + const endpoint = + `${SUPABASE_URL}/rest/v1/student_job_posts` + + `?id=eq.${encodeURIComponent(id)}` + + `&select=${encodeURIComponent(selects[i])}` + + `&limit=1`; + + const res = await fetch(endpoint, { headers }); + const data = await res.json().catch(() => null); + + if (!res.ok) { + const message = data && data.message ? String(data.message) : `Supabase ${res.status}`; + lastError = new Error(message); + const missingCol = /column .* does not exist/i.test(message) || String(data?.code || '') === '42703'; + if (missingCol && i < selects.length - 1) continue; + throw lastError; + } + + if (Array.isArray(data) && data.length) return data[0]; + if (Array.isArray(data) && data.length === 0) return null; + lastError = new Error('Réponse Supabase invalide'); + } + + if (lastError) throw lastError; + return null; +} + +function renderNotFound(url) { + const title = 'Offre introuvable | StudyAlready'; + const description = 'Cette annonce n’existe plus ou a été supprimée.'; + return ` + + + + + ${escapeHtml(title)} + + + + + + + + + + + + + + +
+

Offre introuvable

+

Cette annonce n’existe plus ou a été supprimée.

+ + Voir les annonces récentes + +
+ +`; +} + +function renderJobPage(job, pageUrl) { + const cat = categoryLabel(job.offer_category); + const title = clipText(job.title || `${cat} — StudyAlready`, 120); + const bodyText = stripLinksAndNoise(job.description); + const description = clipText( + bodyText || `${cat}. Annonce publiée par un membre de la communauté StudyAlready.`, + 220 + ); + const image = pickImageUrl(job); + const createdAt = job.created_at + ? new Date(job.created_at).toLocaleString('fr-BE', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : ''; + const author = clipText(job.author_label || 'Membre StudyAlready', 60); + const boardUrl = `${JOBS_PAGE_URL}#job-${job.id}`; + const pageTitle = `${title} | StudyAlready`; + + return ` + + + + + ${escapeHtml(pageTitle)} + + + + + + + + + + + + + + + +
+

${escapeHtml(cat)}

+

${escapeHtml(title)}

+

${escapeHtml(description)}

+

+ ${createdAt ? `Publié le ${escapeHtml(createdAt)} · ` : ''}${escapeHtml(author)} +

+ + Voir l’annonce complète sur StudyAlready + +

+ StudyAlready est une plateforme communautaire. Les annonces sont publiées par les membres. +

+
+ +`; +} + +export default async function handler(req, res) { + const id = normalizeJobId(req?.query?.id); + const pageUrl = `https://www.studyalready.com/offres-etudiants/job/${encodeURIComponent(id || '')}`; + + if (!id) { + res.statusCode = 400; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(renderNotFound(pageUrl)); + return; + } + + try { + const job = await fetchJob(id); + if (!job) { + res.statusCode = 404; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(renderNotFound(pageUrl)); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=300, stale-while-revalidate=3600'); + res.end(renderJobPage(job, pageUrl)); + } catch (_error) { + res.statusCode = 500; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(renderNotFound(pageUrl)); + } +} diff --git a/assets/js/student-jobs.mjs b/assets/js/student-jobs.mjs index e3bcb04..3c9aa84 100644 --- a/assets/js/student-jobs.mjs +++ b/assets/js/student-jobs.mjs @@ -302,7 +302,7 @@ function setImportMsg(text, isError) { } function shareLinksForPost(postId, title) { - const pageUrl = `${CANONICAL_JOBS_PAGE}#job-${postId}`; + const pageUrl = `${CANONICAL_JOBS_PAGE}/job/${postId}`; const shortTitle = String(title || 'Offre') .replace(/\s+/g, ' ') .trim() diff --git a/vercel.json b/vercel.json index 6020f47..9fcd54e 100644 --- a/vercel.json +++ b/vercel.json @@ -161,6 +161,12 @@ ] } ], + "rewrites": [ + { + "source": "/offres-etudiants/job/:id", + "destination": "/api/job-share?id=:id" + } + ], "redirects": [ { "source": "/index.html", "destination": "/", "permanent": true }, { "source": "/accelerateur-job.html", "destination": "/accelerateur-job", "permanent": true },