Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions api/job-share.js
Original file line number Diff line number Diff line change
@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

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 `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}" />
<meta name="robots" content="noindex,nofollow" />
<meta property="og:title" content="${escapeHtml(title)}" />
<meta property="og:description" content="${escapeHtml(description)}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="${escapeHtml(url)}" />
<meta property="og:image" content="${escapeHtml(FALLBACK_OG_IMAGE)}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${escapeHtml(title)}" />
<meta name="twitter:description" content="${escapeHtml(description)}" />
<meta name="twitter:image" content="${escapeHtml(FALLBACK_OG_IMAGE)}" />
<link rel="canonical" href="${escapeHtml(url)}" />
</head>
<body style="font-family:Inter,system-ui,sans-serif;margin:0;padding:2rem;background:#f8fafc;color:#0f172a;">
<main style="max-width:760px;margin:0 auto;background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:1.25rem 1.25rem 1rem;">
<h1 style="margin:0 0 .5rem;font-size:1.5rem;">Offre introuvable</h1>
<p style="margin:.25rem 0 1rem;color:#475569;">Cette annonce n’existe plus ou a été supprimée.</p>
<a href="${JOBS_PAGE_URL}" style="display:inline-block;background:#0a2540;color:#fff;text-decoration:none;padding:.65rem 1rem;border-radius:999px;font-weight:600;">
Voir les annonces récentes
</a>
</main>
</body>
</html>`;
}

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 `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(pageTitle)}</title>
<meta name="description" content="${escapeHtml(description)}" />
<meta name="robots" content="index,follow,max-image-preview:large" />
<link rel="canonical" href="${escapeHtml(pageUrl)}" />
<meta property="og:title" content="${escapeHtml(title)}" />
<meta property="og:description" content="${escapeHtml(description)}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="${escapeHtml(pageUrl)}" />
<meta property="og:image" content="${escapeHtml(image)}" />
<meta property="og:site_name" content="StudyAlready" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${escapeHtml(title)}" />
<meta name="twitter:description" content="${escapeHtml(description)}" />
<meta name="twitter:image" content="${escapeHtml(image)}" />
</head>
<body style="font-family:Inter,system-ui,sans-serif;margin:0;padding:2rem;background:#f8fafc;color:#0f172a;">
<main style="max-width:760px;margin:0 auto;background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:1.25rem 1.25rem 1rem;">
<p style="margin:0 0 .5rem;font-size:.75rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#1e3a8a;">${escapeHtml(cat)}</p>
<h1 style="margin:0 0 .6rem;font-size:1.5rem;line-height:1.25;">${escapeHtml(title)}</h1>
<p style="margin:0 0 1rem;color:#475569;">${escapeHtml(description)}</p>
<p style="margin:0 0 1rem;font-size:.85rem;color:#64748b;">
${createdAt ? `Publié le ${escapeHtml(createdAt)} · ` : ''}${escapeHtml(author)}
</p>
<a href="${escapeHtml(boardUrl)}" style="display:inline-block;background:#0a2540;color:#fff;text-decoration:none;padding:.65rem 1rem;border-radius:999px;font-weight:600;">
Voir l’annonce complète sur StudyAlready
</a>
<p style="margin:.9rem 0 0;font-size:.75rem;color:#64748b;">
StudyAlready est une plateforme communautaire. Les annonces sont publiées par les membres.
</p>
</main>
</body>
</html>`;
}

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));
}
}
48 changes: 48 additions & 0 deletions assets/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 18 additions & 2 deletions assets/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
}
Expand Down Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion assets/js/student-jobs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 3 additions & 7 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
<meta name="keywords" content="équivalence diplôme FWB, études Belgique, étudiants internationaux, Cameroun Belgique, Sénégal Belgique, reconnaissance diplôme, originaux équivalence, StudyAlready" />
<meta name="author" content="StudyAlready" />
<meta name="theme-color" content="#0a2540" />
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
<link rel="canonical" href="https://www.studyalready.com" />

<!-- Open Graph (Facebook, LinkedIn, WhatsApp) -->
<meta property="og:title" content="StudyAlready — Équivalence FWB &amp; confiance jusqu'aux originaux" />
Expand Down Expand Up @@ -154,7 +156,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
</a>

<button id="mobileMenuBtn" class="lg:hidden text-brand-dark" aria-label="Menu">
<button id="mobileMenuBtn" class="lg:hidden text-brand-dark" aria-label="Menu principal" aria-controls="mobileMenu" aria-expanded="false">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
</nav>
Expand Down Expand Up @@ -1013,12 +1015,6 @@ <h4 class="text-white font-semibold mb-3">Légal</h4>
</div>
</footer>

<!-- Bouton WhatsApp flottant -->
<a href="https://wa.me/32465339448?text=Bonjour%20StudyAlready%2C%20je%20souhaite%20des%20informations%20sur%20l%27%C3%A9quivalence%20FWB%20%28dipl%C3%B4me%20Cameroun%29%20et%20la%20r%C3%A9cup%C3%A9ration%20des%20originaux." target="_blank" rel="noopener" aria-label="Contacter sur WhatsApp"
class="fixed bottom-6 right-6 z-40 w-14 h-14 bg-green-500 hover:bg-green-600 rounded-full shadow-2xl flex items-center justify-center transition hover:scale-110">
<svg class="w-7 h-7 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M.057 24l1.687-6.163c-1.041-1.804-1.588-3.849-1.587-5.946.003-6.556 5.338-11.891 11.893-11.891 3.181.001 6.167 1.24 8.413 3.488 2.245 2.248 3.481 5.236 3.48 8.414-.003 6.557-5.338 11.892-11.893 11.892-1.99-.001-3.951-.5-5.688-1.448l-6.305 1.654zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.651 3.891 1.746 5.634l-.999 3.648 3.742-.981zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414z"/></svg>
</a>

<script src="assets/js/config.js"></script>
<script src="/assets/js/vendor/supabase-umd-2.49.4.min.js"></script>
<script src="assets/js/supabase-init.js"></script>
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading