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/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/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/index.html b/index.html
index 543e7e0..a0e15b5 100644
--- a/index.html
+++ b/index.html
@@ -10,6 +10,8 @@
+
+
@@ -154,7 +156,7 @@
-