From 0c41a35e77e2fd3b5147a98fb02ad7a9e2c376e2 Mon Sep 17 00:00:00 2001 From: Jeremie Zitti Date: Mon, 10 Aug 2026 11:32:12 +0100 Subject: [PATCH 1/9] chore(prep-prod): CI + SEO + observabilite + feature flag + i18n P26 complet Suite audit prod-readiness front, batch de chantiers front-only qui ne dependent pas du back en cours de livraison. ## CI debloque - observability.ts : remplace @ts-expect-error unused (post-TS-6 upgrade) par dynamic import + cast explicite pour Sentry + PostHog SDK opt-in. - npm audit fix (vulns non-breaking : cookie, dompurify, glob, brace-expansion). Reste 3 high vulns sur sharp-cli (dev-only, breaking upgrade repousse). ## SEO - static/robots.txt : disallow routes auth + api, sitemap declaration. - src/routes/sitemap.xml/+server.ts : sitemap dynamique prerendered listant les 20 routes publiques SEO-worthy (challenges, mentors, leaderboards, pricing, for-companies, for-maintainers, legal/*). ## Observabilite - src/hooks.client.ts (nouveau) : handleError qui capture Sentry + sanitize message en prod (safe pour user, evite fuite stack trace). - src/hooks.server.ts : handleError equivalent server-side avec log JSON structure vers stdout (capture Coolify -> Sentry). - src/app.d.ts : type App.Error avec errorId de correlation. - src/routes/+error.svelte : affiche reference support si errorId present. ## Feature flag - src/lib/config/features.ts (nouveau) : P26_ENABLED via env PUBLIC_P26_ENABLED. Kill-switch pret pour cacher liens nav P26 le jour ou on les ajoutera. Routes P26 restent accessibles en URL directe (soft launch). - .env.example : documente PUBLIC_P26_ENABLED + PUBLIC_API_BASE_URL + Sentry/PostHog opt-in vars. ## i18n P26 complet - ~280 cles ajoutees dans p26.* (verify, forMaintainers, maintainerDigest, slice, dashboardSlices, validation, validatorApplication, badges). - types.ts, fr.ts, en.ts etendus atomiquement. - 11 pages/composants P26 refactores : textes FR en dur remplaces par i18n.t('p26.xxx.yyy') avec interpolation pour les params dynamiques. - Termes jargon Skilluv gardes tels quels en EN (Skilluver, claim, slice, fragments, rank, attestation, PR, CI). ## Tests parcours - 6 tests parcours P26 nettoyes (retrait du guard HAS_BACK inutile car test.skip est deja inconditionnel) - fix TS process.env sans @types/node. Baseline : 0 errors sur 4495 fichiers. 2 warnings a11y baseline sur composants terminal/* dormants (non touches ici). --- .env.example | 16 +- package-lock.json | 34 +- src/app.d.ts | 5 + src/hooks.client.ts | 42 +++ src/hooks.server.ts | 40 ++- .../components/profile/BadgesSection.svelte | 39 +-- .../slice/ActiveSkilluversWidget.svelte | 13 +- src/lib/components/slice/DiaryWidget.svelte | 21 +- src/lib/config/features.ts | 27 ++ src/lib/i18n/en.ts | 321 ++++++++++++++++++ src/lib/i18n/fr.ts | 321 ++++++++++++++++++ src/lib/i18n/types.ts | 321 ++++++++++++++++++ src/lib/observability.ts | 21 +- src/routes/+error.svelte | 4 + src/routes/dashboard/slices/+page.svelte | 79 ++--- src/routes/for-maintainers/+page.svelte | 116 +++---- .../confirm/[token]/+page.svelte | 19 +- .../unsubscribe/[token]/+page.svelte | 19 +- .../my-validator-applications/+page.svelte | 67 ++-- .../validator-application/new/+page.svelte | 49 ++- .../validator-invitations/[id]/+page.svelte | 39 +-- src/routes/sitemap.xml/+server.ts | 63 ++++ src/routes/slices/[id]/+page.svelte | 96 +++--- .../[slice_id]/review/+page.svelte | 58 ++-- src/routes/validations/queue/+page.svelte | 39 +-- src/routes/verify/[hash]/+page.svelte | 55 +-- static/robots.txt | 25 ++ tests/e2e/parcours/dashboard-slices.spec.ts | 6 +- tests/e2e/parcours/for-maintainers.spec.ts | 7 +- tests/e2e/parcours/slice-detail.spec.ts | 9 +- tests/e2e/parcours/validations-queue.spec.ts | 8 +- .../parcours/validator-application.spec.ts | 6 +- tests/e2e/parcours/verify-hash.spec.ts | 9 +- 33 files changed, 1571 insertions(+), 423 deletions(-) create mode 100644 src/hooks.client.ts create mode 100644 src/lib/config/features.ts create mode 100644 src/routes/sitemap.xml/+server.ts create mode 100644 static/robots.txt diff --git a/.env.example b/.env.example index b1fae6e..db058b9 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,16 @@ -# URL interne du backend (server-side only) +# URL interne du backend (server-side only) — utilise dans hooks.server.ts API_URL=http://localhost:3001/api + +# URL exposee au client (visible dans le bundle) — utilise pour SSR fetch client + Playwright +PUBLIC_API_BASE_URL=http://localhost:3001 + +# Feature flags — voir src/lib/config/features.ts +# P26 v2 workflow challenge (slices, validations, digest maintainers). +# OFF par defaut ; passer a 'true' quand le back livre SKI-72..91, 115..123. +PUBLIC_P26_ENABLED=false + +# Observabilite (optionnelle) — voir src/lib/observability.ts +# PUBLIC_SENTRY_DSN= +# PUBLIC_APP_VERSION= +# PUBLIC_POSTHOG_KEY= +# PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com diff --git a/package-lock.json b/package-lock.json index d436f72..5e97b52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1676,9 +1676,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.70.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz", - "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==", + "version": "2.70.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz", + "integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==", "dev": true, "license": "MIT", "dependencies": { @@ -2476,15 +2476,15 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/bubble-stream-error": { @@ -3563,9 +3563,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -3712,9 +3712,9 @@ } }, "node_modules/postcss": { - "version": "8.5.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", - "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -3732,7 +3732,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4395,9 +4395,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { diff --git a/src/app.d.ts b/src/app.d.ts index e784138..b7f9d4c 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -2,6 +2,11 @@ import type { UserPrivate } from '$lib/types'; declare global { namespace App { + interface Error { + message: string; + /** UUID de correlation, permet au support de retrouver le log exact. */ + errorId?: string; + } interface Locals { user: UserPrivate | null; /** True when the user has at least one WebAuthn credential enrolled. diff --git a/src/hooks.client.ts b/src/hooks.client.ts new file mode 100644 index 0000000..bb0033d --- /dev/null +++ b/src/hooks.client.ts @@ -0,0 +1,42 @@ +import type { HandleClientError } from '@sveltejs/kit'; +import { observability } from '$lib/observability'; + +/** + * Hook client-side qui intercepte toutes les erreurs Svelte non catch (dans les + * loaders `+page.ts` ou les composants pendant l'hydratation) AVANT que le + * layout `+error.svelte` ne les render. + * + * Deux roles : + * 1. Capture Sentry (si configure) pour post-mortem. + * 2. Sanitize le message qui sera passe a `$page.error.message` — en prod on + * ne veut PAS exposer les messages TypeScript / Rust / stack traces qui + * peuvent leak la structure interne. On garde l'ID de correlation pour le + * support. + * + * NOTE : ce hook ne se declenche que pour les erreurs cote client. Les + * erreurs cote serveur sont gerees par `hooks.server.ts.handleError`. + */ +export const handleError: HandleClientError = ({ error, event, status, message }) => { + const errorId = crypto.randomUUID(); + const isProd = import.meta.env.MODE === 'production'; + + // Log complet en console dev, capture Sentry en prod. + if (isProd) { + observability.captureException(error, { + tags: { source: 'client', pathname: event.url.pathname, status: String(status) }, + extras: { errorId, message } + }); + } else { + console.error(`[client error ${errorId}]`, error); + } + + // Message safe pour l'user. En dev on garde le message brut pour debug. + const safeMessage = isProd + ? 'Une erreur inattendue est survenue. Reessaie ou reviens plus tard.' + : (error instanceof Error ? error.message : String(error)); + + return { + message: safeMessage, + errorId + }; +}; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index a32f5a0..85565dd 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,5 +1,43 @@ -import type { Handle } from '@sveltejs/kit'; +import type { Handle, HandleServerError } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; + +/** + * Interceptor cote serveur. Sanitize le message qui sera passe a + * `$page.error.message` — en prod on ne veut PAS exposer les messages Rust / + * TypeScript / stack traces qui peuvent leak la structure interne. + * Le vrai message est log cote serveur avec un errorId de correlation. + */ +export const handleError: HandleServerError = ({ error, event, status, message }) => { + const errorId = crypto.randomUUID(); + const isProd = import.meta.env.PROD; + + if (isProd) { + // En prod le log part vers stdout, capture par la stack observabilite + // (Coolify -> Sentry si configure via DSN). + console.error( + JSON.stringify({ + level: 'error', + errorId, + source: 'server', + pathname: event.url.pathname, + status, + message, + error: error instanceof Error ? { name: error.name, message: error.message } : String(error) + }) + ); + } else { + console.error(`[server error ${errorId}]`, error); + } + + const safeMessage = isProd + ? 'Une erreur inattendue est survenue. Reessaie ou reviens plus tard.' + : (error instanceof Error ? error.message : String(error)); + + return { + message: safeMessage, + errorId + }; +}; import { env } from '$env/dynamic/private'; import type { UserPrivate } from '$lib/types'; diff --git a/src/lib/components/profile/BadgesSection.svelte b/src/lib/components/profile/BadgesSection.svelte index c49599d..60fa023 100644 --- a/src/lib/components/profile/BadgesSection.svelte +++ b/src/lib/components/profile/BadgesSection.svelte @@ -6,6 +6,7 @@ // des repos qu'il maintient. import { attestationApi } from '$api/attestation'; import { toast } from '$stores/toast.svelte'; + import { i18n } from '$lib/i18n'; import Button from '$components/ui/Button.svelte'; import { Copy } from '@lucide/svelte'; @@ -67,35 +68,35 @@ async function handleCopy(text: string) { const ok = await copyToClipboard(text); - if (ok) toast.success('Copie'); - else toast.error('Impossible de copier'); + if (ok) toast.success(i18n.t('p26.badges.copyToastSuccess')); + else toast.error(i18n.t('p26.badges.copyToastError')); }
- Badges Skilluv + {i18n.t('p26.badges.sectionLabel')}
-

Badge Skilluv

+

{i18n.t('p26.badges.personalTitle')}

- Colle ce badge dans ton profil GitHub, ton CV ou LinkedIn pour montrer ta communaute Skilluv. + {i18n.t('p26.badges.personalDesc')}

{#if badgeUserFailed} - Badge pas encore genere + {i18n.t('p26.badges.notGenerated')} {:else} Skilluv badge {username} (badgeUserFailed = true)} /> @@ -105,10 +106,10 @@
- Markdown + {i18n.t('p26.badges.markdownLabel')}
{userMarkdown}
@@ -117,10 +118,10 @@
- HTML + {i18n.t('p26.badges.htmlLabel')}
{userHtml}
@@ -130,9 +131,9 @@ {#if isOwner && ownedProjects && ownedProjects.length > 0}
-

Badges Skilluv pour tes repos

+

{i18n.t('p26.badges.reposTitle')}

- Ajoute ces badges au README de tes repos pour montrer la communaute Skilluv active. + {i18n.t('p26.badges.reposDesc')}

@@ -150,17 +151,17 @@
Skilluv badge {project.github_repo_owner}/{project.github_repo_name}
- Markdown + {i18n.t('p26.badges.markdownLabel')}
{md}
@@ -168,10 +169,10 @@
- HTML + {i18n.t('p26.badges.htmlLabel')}
{html}
diff --git a/src/lib/components/slice/ActiveSkilluversWidget.svelte b/src/lib/components/slice/ActiveSkilluversWidget.svelte index 47df6ec..1e80394 100644 --- a/src/lib/components/slice/ActiveSkilluversWidget.svelte +++ b/src/lib/components/slice/ActiveSkilluversWidget.svelte @@ -1,6 +1,7 @@
-

Actifs cette semaine

+

{i18n.t('p26.slice.widgets.activeTitle')}

{#if loading} {:else if error}

{error}

{:else if count === 0} -

Aucun Skilluver actif recemment.

+

{i18n.t('p26.slice.widgets.activeEmpty')}

{:else}

- {count} Skilluver{count > 1 ? 's' : ''} actif{count > 1 ? 's' : ''} + {#if count > 1} + {i18n.t('p26.slice.widgets.activeCountPlural', { n: count })} + {:else} + {i18n.t('p26.slice.widgets.activeCountSingular', { n: count })} + {/if}

{#each users.slice(0, 5) as u (u.user_id)} diff --git a/src/lib/components/slice/DiaryWidget.svelte b/src/lib/components/slice/DiaryWidget.svelte index 63560dd..36b3ca9 100644 --- a/src/lib/components/slice/DiaryWidget.svelte +++ b/src/lib/components/slice/DiaryWidget.svelte @@ -2,6 +2,7 @@ import { slicesApi, type DiaryEntry } from '$api/slices'; import { SkilluError } from '$api/client'; import { toast } from '$stores/toast.svelte'; + import { i18n } from '$lib/i18n'; import Skeleton from '$components/ui/Skeleton.svelte'; import Button from '$components/ui/Button.svelte'; import Badge from '$components/ui/Badge.svelte'; @@ -33,7 +34,7 @@ const res = await slicesApi.diary(sliceId); entries = res.data.entries; } catch (err) { - error = err instanceof SkilluError ? err.message : 'Erreur'; + error = err instanceof SkilluError ? err.message : i18n.t('p26.validatorApplication.toastError'); } finally { loading = false; } @@ -50,9 +51,9 @@ }); entries = [res.data, ...entries]; body = ''; - toast.success('Entree publiee'); + toast.success(i18n.t('p26.slice.widgets.diaryToastPublished')); } catch (err) { - toast.error(err instanceof SkilluError ? err.message : 'Publication impossible'); + toast.error(err instanceof SkilluError ? err.message : i18n.t('p26.slice.widgets.diaryToastError')); } finally { posting = false; } @@ -77,20 +78,20 @@
-

Carnet de bord

+

{i18n.t('p26.slice.widgets.diaryTitle')}

{#if canPost}
@@ -113,7 +114,7 @@ {:else if error}

{error}

{:else if entries.length === 0} -

Aucune entree pour l'instant.

+

{i18n.t('p26.slice.widgets.diaryEmpty')}

{:else}
    {#each entries as e (e.id)} @@ -135,9 +136,9 @@ {fmtDate(e.created_at)}
{#if e.is_public} - Public + {i18n.t('p26.slice.widgets.diaryPublicBadge')} {:else} - Prive + {i18n.t('p26.slice.widgets.diaryPrivateBadge')} {/if}
diff --git a/src/lib/config/features.ts b/src/lib/config/features.ts new file mode 100644 index 0000000..31bddac --- /dev/null +++ b/src/lib/config/features.ts @@ -0,0 +1,27 @@ +/** + * Feature flags Skilluv — piloter via env vars publiques. + * + * Chaque flag est resolu au build (Vite substitue `import.meta.env.PUBLIC_*` + * a la compilation). Pour changer un flag en prod : mettre a jour la var d'env + * sur Coolify puis redeployer. Pas de flag "runtime" pour l'instant — la + * simplicite de la substitution build-time l'emporte sur l'agilite d'un + * feature-flag service tiers pour la Phase A0. + * + * Convention : `on` par defaut (opt-out), sauf mention contraire. + */ + +/** + * Route family P26 v2 (workflow challenge externe : slices, validations, + * candidatures validateur, verify attestation, digest maintainers). + * + * OFF par defaut : les endpoints backend correspondants ne sont pas encore + * tous deployes en staging (SKI-72..91, SKI-115..123 en Todo). Une fois le + * back livre, mettre `PUBLIC_P26_ENABLED=true` sur Coolify. + * + * Impact quand OFF : + * - les liens navigation vers `/dashboard/slices`, `/validations/*`, + * `/settings/validator-*`, `/for-maintainers` sont caches + * - les routes elles-memes restent accessibles en URL directe (soft-launch : + * on peut partager le lien d'un beta-tester sans exposer publiquement) + */ +export const P26_ENABLED = import.meta.env.PUBLIC_P26_ENABLED === 'true'; diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 9a8f978..086a6c2 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -761,5 +761,326 @@ export const en: Translations = { mentorZone: 'Mentor zone', juryTournament: 'Tournament jury' } + }, + p26: { + verify: { + seoTitleValid: 'Verified Skilluv attestation — {name}', + seoTitleDefault: 'Skilluv attestation', + seoDescValid: 'Skilluv attestation for {name}: a validated contribution.', + seoDescDefault: 'Verify a Skilluv attestation.', + fallbackError: 'Could not load this attestation.', + errorTitle: 'Loading error', + backHome: 'Back to skill-uv.com', + notFoundTitle: 'Attestation not found', + invalidBadge: 'invalid', + verifiedTitle: 'Verified Skilluv attestation', + verifiedBadge: 'verified', + issuedOn: 'Issued on {date}', + contributor: 'Contributor', + validatedBy: 'Validated by', + contribution: 'Contribution', + difficultyBadge: 'difficulty {n}/5', + mergedUpstream: 'Merged upstream', + repoLabel: 'Repo:', + viewPr: 'View the PR', + downloadPdf: 'Download PDF', + share: 'Share', + attestationId: 'Attestation ID', + reasonMalformed: 'This link does not match the format of a Skilluv attestation.', + reasonUnknown: 'No attestation matches this hash. It may have been revoked or never existed.', + copyToast: 'Link copied to clipboard', + copyPrompt: 'Copy this link:' + }, + forMaintainers: { + seoTitle: 'Skilluv — Weekly digest for OSS maintainers', + seoDesc: 'Get a weekly recap of Skilluv contributions to your open-source repos. Zero spam, one-click unsubscribe.', + ogDesc: 'One weekly email, Skilluv contributions across your repos. Zero spam.', + title: 'Your Skilluv contributors, recapped once a week', + subtitle: 'A weekly digest, zero spam, one-click unsubscribe.', + whatSkilluvTitle: 'What Skilluv does', + whatSkilluvBullet1: 'Our community (Afro-francophone, self-taught, career switchers) contributes to external OSS projects.', + whatSkilluvBullet2Prefix: 'On the issues you label with', + whatSkilluvBullet2Suffix: '(or public ones like', + whatSkilluvBullet3: 'We validate their work before merge — Skilluv validation is a quality pre-filter.', + whatReceiveTitle: 'What you receive', + whatReceiveBullet1: 'Weekly digest of Skilluv PRs on your repos (claims, PRs submitted, PRs validated).', + whatReceiveBullet2: 'Zero spam: one email per week, with one-click unsubscribe.', + whatReceiveBullet3: 'Privacy: your email is never shared.', + badgeTitle: 'Our Skilluv badge', + badgeNew: 'new', + badgeDesc: 'Add this badge to your README to show your project welcomes Skilluv contributions.', + badgeAlt: 'Skilluv validated badge', + copyBtn: 'Copy', + copyAria: 'Copy snippet', + copyToast: 'Snippet copied', + copyPrompt: 'Copy this snippet:', + faqTitle: 'FAQ', + faqQ1: 'How does Skilluv know which repos I work on?', + faqA1: 'You list them at signup. You can update your list any time.', + faqQ2: 'Can I subscribe without any Skilluv-labeled repo?', + faqA2: 'Yes — the digest will stay empty until you add the skilluv-challenge label to your issues.', + faqQ3: 'How do I unsubscribe?', + faqA3: 'A link in every email, or the /maintainer-digest/unsubscribe/{token} URL received at signup.', + formTitle: 'Subscribe', + successTitle: 'Confirmation requested', + successMessage: 'A confirmation email was sent to {email}. Click the link in the email to activate your subscription.', + githubLabel: 'GitHub login', + githubPh: 'e.g. torvalds', + emailLabel: 'Email', + emailPh: 'you@example.com', + reposLabel: 'Repos', + reposHint: 'Format: owner/name — comma-separated (max 50)', + reposPh: 'skilluv/skilluv-backend, skilluv/skilluv-frontend', + optInLabel: 'I agree to receive the weekly digest.', + submitBtn: 'Get the weekly digest', + errInvalidGithub: 'Invalid GitHub login.', + errInvalidEmail: 'Invalid email.', + errReposMin: 'Enter at least one repo in owner/name format.', + errReposFormat: 'Invalid format for: {list}', + errReposMax: 'Maximum 50 repos.', + errOptIn: 'You must agree to receive the digest.', + errGeneric: 'Something went wrong.' + }, + maintainerDigest: { + confirmSeoTitle: 'Confirm subscription — Skilluv', + confirmLoading: 'Confirming...', + confirmSuccessTitle: 'Subscription confirmed for {email}. Thanks!', + confirmSuccessBody: 'You will receive your first weekly digest soon.', + confirmBackHome: 'Back to home', + confirmInvalidTitle: 'This confirmation link is invalid or expired.', + confirmInvalidFallback: 'This confirmation link is invalid or expired.', + confirmFailed: 'Confirmation failed.', + confirmSubscribeAgain: 'Subscribe again', + unsubSeoTitle: 'Unsubscribe — Skilluv', + unsubLoading: 'Unsubscribing...', + unsubSuccessTitle: 'Unsubscribed. We will no longer send you the digest.', + unsubSuccessBody: 'You will no longer receive the weekly digest emails ({email}).', + unsubInvalidTitle: 'This link is invalid.', + unsubInvalidFallback: 'This link is invalid.', + unsubFailed: 'Unsubscribe failed.', + unsubBackHome: 'Back to home' + }, + slice: { + status: { + open: 'Open', + claimed: 'Claimed', + in_progress: 'In progress', + submitted: 'PR submitted', + ci_green: 'CI green', + pending_validation: 'Pending validation', + validated: 'Validated', + merged: 'Merged upstream', + closed: 'Closed', + expired: 'Expired' + }, + rankGte: 'rank ≥ {rank}', + difficultyBadge: 'difficulty {n}/5', + daysLeft: '{n}d left', + expired: 'Expired', + issueGithub: 'GitHub issue', + yourFork: 'Your fork', + viewPr: 'View the PR', + acceptanceTitle: 'Acceptance criteria', + workflowTitle: 'Workflow', + workflowInterrupted: 'Workflow interrupted ({status}).', + rejectTitle: 'PR not validated', + claimGateBlocked: 'Your rank or orientation does not match this slice yet.', + claimBtn: 'Claim this challenge', + loginToClaim: 'Log in to claim', + submitPrTitle: 'Submit your PR', + prUrlLabel: 'Pull request URL', + prUrlPh: 'https://github.com/owner/repo/pull/123', + announceLabel: 'Announce publicly on the PR that I contribute via Skilluv', + announceHint: 'A comment will be posted on your PR under your name.', + sendPrBtn: 'Send the PR', + unclaimBtn: 'Unclaim', + ciPending: 'Waiting for CI', + ciGreen: 'CI green, awaiting validation', + pendingReview: 'Under review', + pendingReviewBy: 'by a Skilluv validator.', + attestationGenerated: 'Attestation generated', + mergedUpstream: 'Merged upstream', + downloadAttestationPdf: 'Download the attestation PDF', + verifyPublic: 'Verify publicly', + attestationIdLabel: 'Attestation ID:', + confirmRelease: 'Release this challenge?', + toastReserved: 'Challenge claimed', + toastReleased: 'Challenge released', + toastPrSent: 'PR submitted', + toastReserveError: 'Could not claim', + toastReleaseError: 'Operation failed', + toastSendError: 'Could not submit', + widgets: { + activeTitle: 'Active this week', + activeEmpty: 'No Skilluver active recently.', + activeCountSingular: '{n} active Skilluver', + activeCountPlural: '{n} active Skilluvers', + diaryTitle: 'Logbook', + diaryTextareaPh: 'Where are you? Blockers, findings, next step...', + diaryPublicLabel: 'Public (visible to everyone)', + diaryPublishBtn: 'Publish', + diaryEmpty: 'No entries yet.', + diaryPublicBadge: 'Public', + diaryPrivateBadge: 'Private', + diaryToastPublished: 'Entry published', + diaryToastError: 'Could not publish' + } + }, + dashboardSlices: { + seoTitle: 'My challenges — Skilluv', + title: 'My challenges', + subtitle: 'Your contributions in progress, completed or archived.', + tabActive: 'Active', + tabDone: 'Completed', + tabArchived: 'Archived', + difficultyBadge: 'difficulty {n}/5', + rankBadge: 'rank {rank}', + viewBtn: 'View', + claimBtn: 'Claim', + detailBtn: 'Details', + emptyActiveTitle: 'No challenge in progress', + emptyActiveBody: 'Discover the challenges recommended for you below.', + emptyActiveCta: 'View recommendations', + emptyDoneTitle: 'No validated contribution yet', + emptyArchivedTitle: 'Nothing here', + recoTitle: 'Recommended for you', + recoBasedOnRank: 'Based on your rank', + recoMedian: 'and your recent challenges (median difficulty: {n}/5)', + recoEmptyTitle: 'No recommendation for now', + recoEmptyBody: 'Your rank and orientations determine which slices are proposed.', + toastReserved: 'Challenge claimed', + toastGateBlocked: 'Your rank or orientation does not match yet.', + toastReserveError: 'Could not claim' + }, + validation: { + queueSeoTitle: 'Validation queue — Skilluv', + queueTitle: 'Validation queue', + queueSubtitle: 'PRs awaiting validation in your domains.', + filterAll: 'All', + filterMine: 'Picked up by me', + notValidatorTitle: 'You are not a validator yet', + notValidatorBody: 'To access the queue you must apply as a validator on at least one domain.', + applyCta: 'Apply', + retryBtn: 'Retry', + emptyTitle: 'No PR to validate in your domains', + emptyBody: 'Come back later — the queue fills up automatically.', + difficultyBadge: 'difficulty {n}', + viewPr: 'View the PR', + reviewBtn: 'Review', + pickupBtn: 'Pick up', + toastPickedUp: 'Challenge picked up.', + toastTakenByOther: 'Another validator has already picked up this challenge.', + toastPickupError: 'Pick-up error.', + toastLoadError: 'Could not load the queue.', + reviewSeoTitle: 'Review a PR — Skilluv', + backToQueue: 'Back to the queue', + notFoundTitle: 'Not found', + notFoundBody: 'This challenge is not in your validation queue. It may have already been handled.', + reviewLoadError: 'Could not load the review.', + statusBadge: 'status {status}', + approvedTitle: 'Validation approved', + approvedFragments: '{n} fragments credited. The attestation is public.', + attestationIdLabel: 'Attestation ID: {hash}', + downloadPdf: 'Download PDF', + warningPublicApprove: 'By approving, you will generate an attestation publicly reachable at /verify/{hash} and downloadable as PDF. Fragments will be credited to the challenger and to you.', + reviewPrTitle: 'Review the PR', + reviewIframeHint: 'The iframe is blocked by GitHub — use this button to open the PR in a new tab.', + openPrOnGithub: 'Open the PR on GitHub', + verdictTitle: 'Your verdict', + feedbackLabel: 'Feedback (required to reject, 1-2000 characters)', + feedbackPh: 'Explain what works (or does not) in this PR.', + feedbackCounter: '{n}/2000', + rejectBtn: 'Reject', + approveBtn: 'Approve', + errClaimerSelf: 'Not allowed: you are the claimer of this PR.', + errApprove: 'Error while approving.', + errReject: 'Error while rejecting.', + toastRejected: 'PR rejected.' + }, + validatorApplication: { + newSeoTitle: 'Become a validator — Skilluv', + backToApplications: 'My applications', + newTitle: 'Become a Skilluv validator', + newSubtitle: 'Validators verify Skilluv PRs before they are marked as validated. Each validation credits you in fragments and boosts your reputation.', + domainLabel: 'Domain', + thresholdsTitle: 'Required thresholds', + thresholdsUnavailable: 'Stats loading unavailable for now. You can apply — the backend will verify the criteria.', + thresholdsLoading: 'Loading...', + rankLine: 'Rank Artisan minimum', + rankMiss: 'You are {rank}, Artisan required.', + prsLine: '{n} validated PRs in {domain}', + prsMiss: '{n} so far.', + reposLine: '{n} repos covered', + reposMiss: '{n} so far.', + tenureLine: '{n} days of tenure', + tenureMiss: '{n} days so far.', + motivationLabel: 'Motivation (optional)', + motivationPh: 'Why do you want to become a validator on this domain?', + motivationCounter: '{n}/500', + applyBtn: 'Apply', + toastApplySuccess: 'Application sent.', + toastApplyCriteria: 'A criterion is not met. Check the thresholds above.', + toastApplyError: 'Error while sending.', + listSeoTitle: 'My validator applications — Skilluv', + listTitle: 'My validator applications', + listNewBtn: 'New application', + listFilterAll: 'All', + listFilterPending: 'Pending', + listFilterAccepted: 'Accepted', + listFilterRejected: 'Rejected', + listFilterWithdrawn: 'Withdrawn', + statusPending: 'Pending', + statusAccepted: 'Accepted', + statusRejected: 'Rejected', + statusWithdrawn: 'Withdrawn', + originAdminInvite: 'Admin invitation', + originApplication: 'Application', + adminNote: 'Admin note', + createdOn: 'Created on {date}', + updatedOn: 'updated {date}', + acceptInvitationBtn: 'Accept the invitation', + viewDetailBtn: 'View details', + withdrawBtn: 'Withdraw', + listEmptyTitle: 'No application yet', + listEmptyBody: 'Apply on a domain you master to join the validators team.', + listApplyCta: 'Apply', + listEmptyFilterTitle: 'No application in this filter.', + listLoadError: 'Could not load.', + toastWithdrawn: 'Application withdrawn.', + toastAccepted: 'Invitation accepted.', + toastError: 'Error.', + inviteSeoTitle: 'Validator invitation — Skilluv', + inviteBackLink: 'My applications', + inviteNotFoundTitle: 'Not found', + inviteNotFoundBody: 'This invitation does not exist or is no longer available.', + inviteError: 'Error.', + inviteTitle: 'Admin invitation — Become a {domain} validator', + inviteBadge: 'Admin invitation', + invitePendingBadge: 'Pending', + inviteBody: 'A Skilluv admin is inviting you to join the {domain} validators team.', + inviteReasonTitle: 'Reason for the invitation', + inviteNoNote: 'The admin did not leave a note.', + inviteReceivedOn: 'Received on {date}', + inviteAcceptBtn: 'Accept', + inviteDeclineBtn: 'Decline', + toastInviteAccepted: 'Invitation accepted.', + toastInviteDeclined: 'Invitation declined.' + }, + badges: { + ariaLabel: 'Skilluv badges', + sectionLabel: 'Skilluv badges', + personalTitle: 'Skilluv badge', + personalDesc: 'Paste this badge on your GitHub profile, resume or LinkedIn to show your Skilluv community.', + personalAlt: 'Skilluv badge {username}', + notGenerated: 'Badge not yet generated', + markdownLabel: 'Markdown', + htmlLabel: 'HTML', + copyBtn: 'Copy', + reposTitle: 'Skilluv badges for your repos', + reposDesc: 'Add these badges to your repo READMEs to show the active Skilluv community.', + repoBadgeAlt: 'Skilluv badge {repo}', + copyToastSuccess: 'Copied', + copyToastError: 'Could not copy' + } } }; diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index 52fc8d2..bfc49f2 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -761,5 +761,326 @@ export const fr: Translations = { mentorZone: 'Espace mentor', juryTournament: 'Jury tournoi' } + }, + p26: { + verify: { + seoTitleValid: 'Attestation Skilluv verifiee — {name}', + seoTitleDefault: 'Attestation Skilluv', + seoDescValid: 'Attestation Skilluv de {name} pour une contribution validee', + seoDescDefault: 'Verifiez une attestation Skilluv.', + fallbackError: 'Impossible de charger cette attestation.', + errorTitle: 'Erreur de chargement', + backHome: 'Retour a skill-uv.com', + notFoundTitle: 'Attestation introuvable', + invalidBadge: 'invalide', + verifiedTitle: 'Attestation Skilluv verifiee', + verifiedBadge: 'verified', + issuedOn: 'Delivree le {date}', + contributor: 'Contributeur', + validatedBy: 'Validee par', + contribution: 'Contribution', + difficultyBadge: 'difficulte {n}/5', + mergedUpstream: 'Merge upstream', + repoLabel: 'Repo :', + viewPr: 'Voir la PR', + downloadPdf: 'Telecharger le PDF', + share: 'Partager', + attestationId: 'Attestation ID', + reasonMalformed: 'Ce lien ne correspond pas au format d’une attestation Skilluv.', + reasonUnknown: 'Aucune attestation ne correspond a ce hash. Elle a peut-etre ete revoquee ou n’a jamais existe.', + copyToast: 'Lien copie dans le presse-papiers', + copyPrompt: 'Copiez ce lien :' + }, + forMaintainers: { + seoTitle: 'Skilluv — Digest hebdo pour maintainers OSS', + seoDesc: 'Recevez chaque semaine un resume des contributions Skilluv sur vos repos open-source. Zero spam, unsubscribe en un clic.', + ogDesc: 'Un email hebdomadaire, les contributions Skilluv sur vos repos. Zero spam.', + title: 'Vos contributeurs Skilluv, resumes une fois par semaine', + subtitle: 'Un digest hebdo, zero spam, unsubscribe en un clic.', + whatSkilluvTitle: 'Ce que fait Skilluv', + whatSkilluvBullet1: 'Notre communaute (afro-francophone, autodidactes, reconvertis) contribue a des OSS externes.', + whatSkilluvBullet2Prefix: 'Sur les issues que vous labellisez', + whatSkilluvBullet2Suffix: '(ou celles publiques comme', + whatSkilluvBullet3: 'On valide leur travail avant merge — la validation Skilluv est un pre-filtre qualite.', + whatReceiveTitle: 'Ce que vous recevez', + whatReceiveBullet1: 'Digest hebdomadaire des PRs Skilluv sur vos repos (nb claims, PRs submit, PRs validated).', + whatReceiveBullet2: 'Zero spam : un email/semaine, avec unsubscribe en 1 clic.', + whatReceiveBullet3: 'Confidentialite : votre email n’est jamais partage.', + badgeTitle: 'Notre badge Skilluv', + badgeNew: 'nouveau', + badgeDesc: 'Ajoutez ce badge a votre README pour signaler que votre projet accueille les contributions Skilluv.', + badgeAlt: 'Badge Skilluv validated', + copyBtn: 'Copier', + copyAria: 'Copier le snippet', + copyToast: 'Snippet copie', + copyPrompt: 'Copiez ce snippet :', + faqTitle: 'FAQ', + faqQ1: 'Comment Skilluv sait sur quels repos je bosse ?', + faqA1: 'Vous les listez a l’inscription. Vous pouvez modifier votre liste a tout moment.', + faqQ2: 'Puis-je m’abonner sans avoir de repo Skilluv-labelise ?', + faqA2: 'Oui — le digest sera vide en attendant que vous ajoutiez le label skilluv-challenge sur vos issues.', + faqQ3: 'Comment se desabonner ?', + faqA3: 'Un lien dans chaque email, ou l’URL /maintainer-digest/unsubscribe/{token} recue a l’inscription.', + formTitle: 'S’abonner', + successTitle: 'Confirmation demandee', + successMessage: 'Email de confirmation envoye a {email}. Cliquez sur le lien dans l’email pour activer votre abonnement.', + githubLabel: 'Login GitHub', + githubPh: 'ex. torvalds', + emailLabel: 'Email', + emailPh: 'you@example.com', + reposLabel: 'Repos', + reposHint: 'Format : owner/name — separer par virgules (max 50)', + reposPh: 'skilluv/skilluv-backend, skilluv/skilluv-frontend', + optInLabel: 'J’accepte de recevoir le digest hebdomadaire.', + submitBtn: 'Recevoir le digest hebdomadaire', + errInvalidGithub: 'Login GitHub invalide.', + errInvalidEmail: 'Email invalide.', + errReposMin: 'Renseignez au moins un repo au format owner/name.', + errReposFormat: 'Format invalide pour : {list}', + errReposMax: 'Maximum 50 repos.', + errOptIn: 'Vous devez accepter de recevoir le digest.', + errGeneric: 'Une erreur est survenue.' + }, + maintainerDigest: { + confirmSeoTitle: 'Confirmation abonnement — Skilluv', + confirmLoading: 'Confirmation en cours...', + confirmSuccessTitle: 'Abonnement confirme pour {email}. Merci !', + confirmSuccessBody: 'Vous recevrez votre premier digest hebdomadaire prochainement.', + confirmBackHome: 'Retour a l’accueil', + confirmInvalidTitle: 'Ce lien de confirmation est invalide ou expire.', + confirmInvalidFallback: 'Ce lien de confirmation est invalide ou expire.', + confirmFailed: 'Confirmation echouee.', + confirmSubscribeAgain: 'S’abonner a nouveau', + unsubSeoTitle: 'Desabonnement — Skilluv', + unsubLoading: 'Desabonnement en cours...', + unsubSuccessTitle: 'Desabonne. Nous ne vous enverrons plus de digest.', + unsubSuccessBody: 'Vous ne recevrez plus d’emails du digest hebdomadaire ({email}).', + unsubInvalidTitle: 'Ce lien est invalide.', + unsubInvalidFallback: 'Ce lien est invalide.', + unsubFailed: 'Desabonnement echoue.', + unsubBackHome: 'Retour a l’accueil' + }, + slice: { + status: { + open: 'Ouvert', + claimed: 'Reserve', + in_progress: 'En cours', + submitted: 'PR soumise', + ci_green: 'CI verte', + pending_validation: 'En validation', + validated: 'Validee', + merged: 'Merge upstream', + closed: 'Ferme', + expired: 'Expire' + }, + rankGte: 'rank ≥ {rank}', + difficultyBadge: 'difficulte {n}/5', + daysLeft: 'Reste {n}j', + expired: 'Expire', + issueGithub: 'Issue GitHub', + yourFork: 'Ton fork', + viewPr: 'Voir la PR', + acceptanceTitle: "Criteres d'acceptation", + workflowTitle: 'Workflow', + workflowInterrupted: 'Workflow interrompu ({status}).', + rejectTitle: 'PR non validee', + claimGateBlocked: 'Ton rank ou orientation ne correspond pas encore a cette slice.', + claimBtn: 'Claim ce challenge', + loginToClaim: 'Se connecter pour claim', + submitPrTitle: 'Soumettre ta PR', + prUrlLabel: 'URL de la Pull Request', + prUrlPh: 'https://github.com/owner/repo/pull/123', + announceLabel: 'Annoncer publiquement sur la PR que je contribue via Skilluv', + announceHint: 'Un commentaire sera poste sur ta PR sous ton nom.', + sendPrBtn: 'Envoyer la PR', + unclaimBtn: 'Unclaim', + ciPending: 'En attente CI', + ciGreen: 'CI verte, en attente validation', + pendingReview: 'En cours de review', + pendingReviewBy: 'par un valideur Skilluv.', + attestationGenerated: 'Attestation generee', + mergedUpstream: 'Merge upstream', + downloadAttestationPdf: "Telecharger le PDF de l'attestation", + verifyPublic: 'Verifier publiquement', + attestationIdLabel: 'Attestation ID :', + confirmRelease: 'Liberer ce challenge ?', + toastReserved: 'Challenge reserve', + toastReleased: 'Challenge libere', + toastPrSent: 'PR soumise', + toastReserveError: 'Reservation impossible', + toastReleaseError: 'Operation impossible', + toastSendError: 'Envoi impossible', + widgets: { + activeTitle: 'Actifs cette semaine', + activeEmpty: 'Aucun Skilluver actif recemment.', + activeCountSingular: '{n} Skilluver actif', + activeCountPlural: '{n} Skilluvers actifs', + diaryTitle: 'Carnet de bord', + diaryTextareaPh: 'Ou en es-tu ? Blocages, decouvertes, prochaine etape...', + diaryPublicLabel: 'Public (visible par tous)', + diaryPublishBtn: 'Publier', + diaryEmpty: "Aucune entree pour l'instant.", + diaryPublicBadge: 'Public', + diaryPrivateBadge: 'Prive', + diaryToastPublished: 'Entree publiee', + diaryToastError: 'Publication impossible' + } + }, + dashboardSlices: { + seoTitle: 'Mes challenges — Skilluv', + title: 'Mes challenges', + subtitle: 'Tes contributions en cours, terminees ou archivees.', + tabActive: 'Actifs', + tabDone: 'Completes', + tabArchived: 'Archives', + difficultyBadge: 'difficulte {n}/5', + rankBadge: 'rank {rank}', + viewBtn: 'Voir', + claimBtn: 'Claim', + detailBtn: 'Detail', + emptyActiveTitle: 'Aucun challenge en cours', + emptyActiveBody: 'Decouvre les challenges recommandes pour toi ci-dessous.', + emptyActiveCta: 'Voir les recommandations', + emptyDoneTitle: 'Aucune contribution validee pour le moment', + emptyArchivedTitle: 'Rien ici', + recoTitle: 'Recommandes pour toi', + recoBasedOnRank: 'Base sur ton rank', + recoMedian: 'et tes derniers challenges (difficulty mediane : {n}/5)', + recoEmptyTitle: "Aucune recommandation pour l'instant", + recoEmptyBody: 'Ton rank et tes orientations determinent les slices proposees.', + toastReserved: 'Challenge reserve', + toastGateBlocked: 'Ton rank ou orientation ne correspond pas encore.', + toastReserveError: 'Reservation impossible' + }, + validation: { + queueSeoTitle: 'File de validation — Skilluv', + queueTitle: 'File de validation', + queueSubtitle: 'Les PRs en attente de validation dans tes domaines.', + filterAll: 'Toutes', + filterMine: 'Prises par moi', + notValidatorTitle: "Tu n'es pas encore validateur", + notValidatorBody: 'Pour acceder a la file, il faut candidater comme validateur sur au moins un domaine.', + applyCta: 'Candidater', + retryBtn: 'Reessayer', + emptyTitle: 'Aucune PR a valider dans tes domaines', + emptyBody: 'Reviens plus tard, la file se remplit automatiquement.', + difficultyBadge: 'difficulte {n}', + viewPr: 'Voir la PR', + reviewBtn: 'Reviewer', + pickupBtn: 'Prendre en charge', + toastPickedUp: 'Challenge pris en charge.', + toastTakenByOther: 'Ce challenge a ete pris par un autre validateur.', + toastPickupError: 'Erreur lors du pick-up.', + toastLoadError: 'Impossible de charger la file.', + reviewSeoTitle: 'Reviewer une PR — Skilluv', + backToQueue: 'Retour a la queue', + notFoundTitle: 'Introuvable', + notFoundBody: "Ce challenge n'existe pas dans ta file de validation. Il a peut-etre deja ete traite.", + reviewLoadError: 'Impossible de charger la review.', + statusBadge: 'status {status}', + approvedTitle: 'Validation approuvee', + approvedFragments: "{n} fragments credites. L'attestation est publique.", + attestationIdLabel: 'Attestation ID : {hash}', + downloadPdf: 'Telecharger le PDF', + warningPublicApprove: 'En approvant, tu genereras une attestation qui sera publique via /verify/{hash} et telechargeable en PDF. Les fragments seront credites au challenger et a toi.', + reviewPrTitle: 'Reviewer la PR', + reviewIframeHint: "L'iframe est bloque par GitHub — utilise ce bouton pour ouvrir la PR dans un nouvel onglet.", + openPrOnGithub: 'Ouvrir la PR sur GitHub', + verdictTitle: 'Ton verdict', + feedbackLabel: 'Feedback (obligatoire pour rejeter, 1-2000 caracteres)', + feedbackPh: 'Explique ce qui va (ou pas) dans cette PR.', + feedbackCounter: '{n}/2000', + rejectBtn: 'Rejeter', + approveBtn: 'Approuver', + errClaimerSelf: 'Impossible : tu es le claimer de cette PR.', + errApprove: 'Erreur lors de l approbation.', + errReject: 'Erreur lors du rejet.', + toastRejected: 'PR rejetee.' + }, + validatorApplication: { + newSeoTitle: 'Devenir validateur — Skilluv', + backToApplications: 'Mes candidatures', + newTitle: 'Devenir validateur Skilluv', + newSubtitle: "Les validateurs verifient les PRs Skilluv avant qu'elles soient marquees comme validees. Chaque validation te credite en fragments et augmente ta reputation.", + domainLabel: 'Domaine', + thresholdsTitle: 'Seuils requis', + thresholdsUnavailable: "Chargement des stats indisponible pour l'instant. Tu peux candidater — le back verifiera les criteres.", + thresholdsLoading: 'Chargement...', + rankLine: 'Rank Artisan minimum', + rankMiss: 'Tu es {rank}, il faut Artisan.', + prsLine: '{n} PRs validees sur {domain}', + prsMiss: "{n} pour l'instant.", + reposLine: '{n} repos couverts', + reposMiss: "{n} pour l'instant.", + tenureLine: "{n} jours d'anciennete", + tenureMiss: "{n} jours pour l'instant.", + motivationLabel: 'Motivation (optionnel)', + motivationPh: 'Pourquoi veux-tu devenir validateur sur ce domaine ?', + motivationCounter: '{n}/500', + applyBtn: 'Candidater', + toastApplySuccess: 'Candidature envoyee.', + toastApplyCriteria: 'Un critere n est pas rempli. Verifie les seuils ci-dessus.', + toastApplyError: 'Erreur lors de l envoi.', + listSeoTitle: 'Mes candidatures validateur — Skilluv', + listTitle: 'Mes candidatures validateur', + listNewBtn: 'Nouvelle candidature', + listFilterAll: 'Toutes', + listFilterPending: 'En attente', + listFilterAccepted: 'Acceptees', + listFilterRejected: 'Refusees', + listFilterWithdrawn: 'Retirees', + statusPending: 'En attente', + statusAccepted: 'Acceptee', + statusRejected: 'Refusee', + statusWithdrawn: 'Retiree', + originAdminInvite: 'Invitation admin', + originApplication: 'Candidature', + adminNote: 'Note admin', + createdOn: 'Cree le {date}', + updatedOn: 'maj {date}', + acceptInvitationBtn: "Accepter l'invitation", + viewDetailBtn: 'Voir le detail', + withdrawBtn: 'Retirer', + listEmptyTitle: "Aucune candidature pour l'instant", + listEmptyBody: "Candidate sur un domaine ou tu maitrises pour rejoindre l'equipe de validateurs.", + listApplyCta: 'Candidater', + listEmptyFilterTitle: 'Aucune candidature dans ce filtre.', + listLoadError: 'Impossible de charger.', + toastWithdrawn: 'Candidature retiree.', + toastAccepted: 'Invitation acceptee.', + toastError: 'Erreur.', + inviteSeoTitle: 'Invitation validateur — Skilluv', + inviteBackLink: 'Mes candidatures', + inviteNotFoundTitle: 'Introuvable', + inviteNotFoundBody: "Cette invitation n'existe pas ou n'est plus disponible.", + inviteError: 'Erreur.', + inviteTitle: 'Invitation admin — Devenir validateur {domain}', + inviteBadge: 'Invitation admin', + invitePendingBadge: 'En attente', + inviteBody: "Un admin Skilluv t'invite a rejoindre l'equipe de validateurs {domain}.", + inviteReasonTitle: "Raison de l'invitation", + inviteNoNote: "L'admin n'a pas laisse de note.", + inviteReceivedOn: 'Recu le {date}', + inviteAcceptBtn: 'Accepter', + inviteDeclineBtn: 'Refuser', + toastInviteAccepted: 'Invitation acceptee.', + toastInviteDeclined: 'Invitation refusee.' + }, + badges: { + ariaLabel: 'Badges Skilluv', + sectionLabel: 'Badges Skilluv', + personalTitle: 'Badge Skilluv', + personalDesc: 'Colle ce badge dans ton profil GitHub, ton CV ou LinkedIn pour montrer ta communaute Skilluv.', + personalAlt: 'Skilluv badge {username}', + notGenerated: 'Badge pas encore genere', + markdownLabel: 'Markdown', + htmlLabel: 'HTML', + copyBtn: 'Copier', + reposTitle: 'Badges Skilluv pour tes repos', + reposDesc: 'Ajoute ces badges au README de tes repos pour montrer la communaute Skilluv active.', + repoBadgeAlt: 'Skilluv badge {repo}', + copyToastSuccess: 'Copie', + copyToastError: 'Impossible de copier' + } } }; diff --git a/src/lib/i18n/types.ts b/src/lib/i18n/types.ts index db244c2..0ae577a 100644 --- a/src/lib/i18n/types.ts +++ b/src/lib/i18n/types.ts @@ -937,4 +937,325 @@ export interface Translations { juryTournament: string; }; }; + p26: { + verify: { + seoTitleValid: string; + seoTitleDefault: string; + seoDescValid: string; + seoDescDefault: string; + fallbackError: string; + errorTitle: string; + backHome: string; + notFoundTitle: string; + invalidBadge: string; + verifiedTitle: string; + verifiedBadge: string; + issuedOn: string; + contributor: string; + validatedBy: string; + contribution: string; + difficultyBadge: string; + mergedUpstream: string; + repoLabel: string; + viewPr: string; + downloadPdf: string; + share: string; + attestationId: string; + reasonMalformed: string; + reasonUnknown: string; + copyToast: string; + copyPrompt: string; + }; + forMaintainers: { + seoTitle: string; + seoDesc: string; + ogDesc: string; + title: string; + subtitle: string; + whatSkilluvTitle: string; + whatSkilluvBullet1: string; + whatSkilluvBullet2Prefix: string; + whatSkilluvBullet2Suffix: string; + whatSkilluvBullet3: string; + whatReceiveTitle: string; + whatReceiveBullet1: string; + whatReceiveBullet2: string; + whatReceiveBullet3: string; + badgeTitle: string; + badgeNew: string; + badgeDesc: string; + badgeAlt: string; + copyBtn: string; + copyAria: string; + copyToast: string; + copyPrompt: string; + faqTitle: string; + faqQ1: string; + faqA1: string; + faqQ2: string; + faqA2: string; + faqQ3: string; + faqA3: string; + formTitle: string; + successTitle: string; + successMessage: string; + githubLabel: string; + githubPh: string; + emailLabel: string; + emailPh: string; + reposLabel: string; + reposHint: string; + reposPh: string; + optInLabel: string; + submitBtn: string; + errInvalidGithub: string; + errInvalidEmail: string; + errReposMin: string; + errReposFormat: string; + errReposMax: string; + errOptIn: string; + errGeneric: string; + }; + maintainerDigest: { + confirmSeoTitle: string; + confirmLoading: string; + confirmSuccessTitle: string; + confirmSuccessBody: string; + confirmBackHome: string; + confirmInvalidTitle: string; + confirmInvalidFallback: string; + confirmFailed: string; + confirmSubscribeAgain: string; + unsubSeoTitle: string; + unsubLoading: string; + unsubSuccessTitle: string; + unsubSuccessBody: string; + unsubInvalidTitle: string; + unsubInvalidFallback: string; + unsubFailed: string; + unsubBackHome: string; + }; + slice: { + status: { + open: string; + claimed: string; + in_progress: string; + submitted: string; + ci_green: string; + pending_validation: string; + validated: string; + merged: string; + closed: string; + expired: string; + }; + rankGte: string; + difficultyBadge: string; + daysLeft: string; + expired: string; + issueGithub: string; + yourFork: string; + viewPr: string; + acceptanceTitle: string; + workflowTitle: string; + workflowInterrupted: string; + rejectTitle: string; + claimGateBlocked: string; + claimBtn: string; + loginToClaim: string; + submitPrTitle: string; + prUrlLabel: string; + prUrlPh: string; + announceLabel: string; + announceHint: string; + sendPrBtn: string; + unclaimBtn: string; + ciPending: string; + ciGreen: string; + pendingReview: string; + pendingReviewBy: string; + attestationGenerated: string; + mergedUpstream: string; + downloadAttestationPdf: string; + verifyPublic: string; + attestationIdLabel: string; + confirmRelease: string; + toastReserved: string; + toastReleased: string; + toastPrSent: string; + toastReserveError: string; + toastReleaseError: string; + toastSendError: string; + widgets: { + activeTitle: string; + activeEmpty: string; + activeCountSingular: string; + activeCountPlural: string; + diaryTitle: string; + diaryTextareaPh: string; + diaryPublicLabel: string; + diaryPublishBtn: string; + diaryEmpty: string; + diaryPublicBadge: string; + diaryPrivateBadge: string; + diaryToastPublished: string; + diaryToastError: string; + }; + }; + dashboardSlices: { + seoTitle: string; + title: string; + subtitle: string; + tabActive: string; + tabDone: string; + tabArchived: string; + difficultyBadge: string; + rankBadge: string; + viewBtn: string; + claimBtn: string; + detailBtn: string; + emptyActiveTitle: string; + emptyActiveBody: string; + emptyActiveCta: string; + emptyDoneTitle: string; + emptyArchivedTitle: string; + recoTitle: string; + recoBasedOnRank: string; + recoMedian: string; + recoEmptyTitle: string; + recoEmptyBody: string; + toastReserved: string; + toastGateBlocked: string; + toastReserveError: string; + }; + validation: { + queueSeoTitle: string; + queueTitle: string; + queueSubtitle: string; + filterAll: string; + filterMine: string; + notValidatorTitle: string; + notValidatorBody: string; + applyCta: string; + retryBtn: string; + emptyTitle: string; + emptyBody: string; + difficultyBadge: string; + viewPr: string; + reviewBtn: string; + pickupBtn: string; + toastPickedUp: string; + toastTakenByOther: string; + toastPickupError: string; + toastLoadError: string; + reviewSeoTitle: string; + backToQueue: string; + notFoundTitle: string; + notFoundBody: string; + reviewLoadError: string; + statusBadge: string; + approvedTitle: string; + approvedFragments: string; + attestationIdLabel: string; + downloadPdf: string; + warningPublicApprove: string; + reviewPrTitle: string; + reviewIframeHint: string; + openPrOnGithub: string; + verdictTitle: string; + feedbackLabel: string; + feedbackPh: string; + feedbackCounter: string; + rejectBtn: string; + approveBtn: string; + errClaimerSelf: string; + errApprove: string; + errReject: string; + toastRejected: string; + }; + validatorApplication: { + newSeoTitle: string; + backToApplications: string; + newTitle: string; + newSubtitle: string; + domainLabel: string; + thresholdsTitle: string; + thresholdsUnavailable: string; + thresholdsLoading: string; + rankLine: string; + rankMiss: string; + prsLine: string; + prsMiss: string; + reposLine: string; + reposMiss: string; + tenureLine: string; + tenureMiss: string; + motivationLabel: string; + motivationPh: string; + motivationCounter: string; + applyBtn: string; + toastApplySuccess: string; + toastApplyCriteria: string; + toastApplyError: string; + listSeoTitle: string; + listTitle: string; + listNewBtn: string; + listFilterAll: string; + listFilterPending: string; + listFilterAccepted: string; + listFilterRejected: string; + listFilterWithdrawn: string; + statusPending: string; + statusAccepted: string; + statusRejected: string; + statusWithdrawn: string; + originAdminInvite: string; + originApplication: string; + adminNote: string; + createdOn: string; + updatedOn: string; + acceptInvitationBtn: string; + viewDetailBtn: string; + withdrawBtn: string; + listEmptyTitle: string; + listEmptyBody: string; + listApplyCta: string; + listEmptyFilterTitle: string; + listLoadError: string; + toastWithdrawn: string; + toastAccepted: string; + toastError: string; + inviteSeoTitle: string; + inviteBackLink: string; + inviteNotFoundTitle: string; + inviteNotFoundBody: string; + inviteError: string; + inviteTitle: string; + inviteBadge: string; + invitePendingBadge: string; + inviteBody: string; + inviteReasonTitle: string; + inviteNoNote: string; + inviteReceivedOn: string; + inviteAcceptBtn: string; + inviteDeclineBtn: string; + toastInviteAccepted: string; + toastInviteDeclined: string; + }; + badges: { + ariaLabel: string; + sectionLabel: string; + personalTitle: string; + personalDesc: string; + personalAlt: string; + notGenerated: string; + markdownLabel: string; + htmlLabel: string; + copyBtn: string; + reposTitle: string; + reposDesc: string; + repoBadgeAlt: string; + copyToastSuccess: string; + copyToastError: string; + }; + }; } diff --git a/src/lib/observability.ts b/src/lib/observability.ts index 2583df4..e065bd7 100644 --- a/src/lib/observability.ts +++ b/src/lib/observability.ts @@ -37,9 +37,12 @@ class Observability { const dsn = import.meta.env.PUBLIC_SENTRY_DSN as string | undefined; if (!dsn) return; try { - // @ts-expect-error — Sentry SDK is opt-in; the import target is only - // resolved when the package is actually installed. - const Sentry = await import('@sentry/sveltekit'); + // Sentry SDK is opt-in; the import target is only resolved when the + // package is actually installed. Using dynamic import with vite-ignore + // so the resolver ne bloque pas le build quand le package est absent. + const Sentry = (await import(/* @vite-ignore */ '@sentry/sveltekit' as string)) as { + init: (opts: Record) => void; + }; Sentry.init({ dsn, tracesSampleRate: 0.1, @@ -56,8 +59,16 @@ class Observability { const key = import.meta.env.PUBLIC_POSTHOG_KEY as string | undefined; if (!key) return; try { - // @ts-expect-error — PostHog SDK is opt-in. - const posthog = (await import('posthog-js')).default; + // PostHog SDK is opt-in. Voir commentaire Sentry ci-dessus. + const mod = (await import(/* @vite-ignore */ 'posthog-js' as string)) as { + default: { + init: (key: string, opts: Record) => void; + capture: (event: string, props?: Record) => void; + identify: (id: string, props?: Record) => void; + reset: () => void; + }; + }; + const posthog = mod.default; posthog.init(key, { api_host: (import.meta.env.PUBLIC_POSTHOG_HOST as string | undefined) ?? diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index 20bed1b..e29f342 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -26,4 +26,8 @@
+ + {#if $page.error?.errorId} +

Reference support : {$page.error.errorId}

+ {/if}
diff --git a/src/routes/dashboard/slices/+page.svelte b/src/routes/dashboard/slices/+page.svelte index c21e7c4..10ad962 100644 --- a/src/routes/dashboard/slices/+page.svelte +++ b/src/routes/dashboard/slices/+page.svelte @@ -3,6 +3,7 @@ import { slicesApi, type Slice, type SliceStatus } from '$api/slices'; import { SkilluError } from '$api/client'; import { toast } from '$stores/toast.svelte'; + import { i18n } from '$lib/i18n'; import Button from '$components/ui/Button.svelte'; import Badge from '$components/ui/Badge.svelte'; import EmptyState from '$components/ui/EmptyState.svelte'; @@ -21,18 +22,9 @@ let { data }: Props = $props(); - const STATUS_LABEL: Record = { - open: 'Ouvert', - claimed: 'Reserve', - in_progress: 'En cours', - submitted: 'PR soumise', - ci_green: 'CI verte', - pending_validation: 'En validation', - validated: 'Validee', - merged: 'Merge upstream', - closed: 'Ferme', - expired: 'Expire' - }; + function statusLabel(s: SliceStatus): string { + return i18n.t(`p26.slice.status.${s}`); + } const STATUS_VARIANT: Record = { open: 'primary', @@ -79,13 +71,13 @@ claimingId = s.id; try { await slicesApi.claim(s.id); - toast.success('Challenge reserve'); + toast.success(i18n.t('p26.dashboardSlices.toastReserved')); await goto(`/slices/${s.id}`); } catch (err) { if (err instanceof SkilluError && err.status === 403) { - toast.warning('Ton rank ou orientation ne correspond pas encore.'); + toast.warning(i18n.t('p26.dashboardSlices.toastGateBlocked')); } else { - toast.error(err instanceof SkilluError ? err.message : 'Reservation impossible'); + toast.error(err instanceof SkilluError ? err.message : i18n.t('p26.dashboardSlices.toastReserveError')); } } finally { claimingId = null; @@ -95,19 +87,19 @@ function daysLeft(iso: string | null): string | null { if (!iso) return null; const diff = new Date(iso).getTime() - Date.now(); - if (diff <= 0) return 'Expire'; - return `Reste ${Math.ceil(diff / 86400000)}j`; + if (diff <= 0) return i18n.t('p26.slice.expired'); + return i18n.t('p26.slice.daysLeft', { n: Math.ceil(diff / 86400000) }); } - const tabs = [ - { value: 'active' as const, label: 'Actifs' }, - { value: 'done' as const, label: 'Completes' }, - { value: 'archived' as const, label: 'Archives' } - ]; + const tabs = $derived([ + { value: 'active' as const, label: i18n.t('p26.dashboardSlices.tabActive') }, + { value: 'done' as const, label: i18n.t('p26.dashboardSlices.tabDone') }, + { value: 'archived' as const, label: i18n.t('p26.dashboardSlices.tabArchived') } + ]); - Mes challenges — Skilluv + {i18n.t('p26.dashboardSlices.seoTitle')}
@@ -116,10 +108,10 @@

- Mes challenges + {i18n.t('p26.dashboardSlices.title')}

- Tes contributions en cours, terminees ou archivees. + {i18n.t('p26.dashboardSlices.subtitle')}

@@ -133,25 +125,25 @@ {#if tab === 'active'} {#snippet action()} - + {/snippet} {:else if tab === 'done'} - + {:else} - + {/if} {:else}
{#each pageItems as s (s.id)}
- {STATUS_LABEL[s.status]} - difficulte {s.difficulty}/5 + {statusLabel(s.status)} + {i18n.t('p26.dashboardSlices.difficultyBadge', { n: s.difficulty })} {#if s.claim_expires_at && (s.status === 'claimed' || s.status === 'in_progress')} {@const dl = daysLeft(s.claim_expires_at)} {#if dl}{dl}{/if} @@ -162,7 +154,7 @@

{s.description}

- +
{/each} @@ -176,17 +168,16 @@

- Recommandes pour toi + {i18n.t('p26.dashboardSlices.recoTitle')}

{#if data.recoMeta}

- Base sur ton rank + {i18n.t('p26.dashboardSlices.recoBasedOnRank')} {#if data.recoMeta.user_rank_ord != null} ({data.recoMeta.user_rank_ord}) {/if} {#if data.recoMeta.median_difficulty != null} - et tes derniers challenges (difficulty mediane : - {data.recoMeta.median_difficulty}/5) + {i18n.t('p26.dashboardSlices.recoMedian', { n: data.recoMeta.median_difficulty })} {/if}

{/if} @@ -199,17 +190,17 @@ {:else if data.reco.length === 0} {:else}
{#each data.reco as s (s.id)}
- {STATUS_LABEL[s.status]} - difficulte {s.difficulty}/5 - {#if s.min_rank}rank {s.min_rank}{/if} + {statusLabel(s.status)} + {i18n.t('p26.dashboardSlices.difficultyBadge', { n: s.difficulty })} + {#if s.min_rank}{i18n.t('p26.dashboardSlices.rankBadge', { rank: s.min_rank })}{/if}

{s.title} @@ -223,9 +214,9 @@ disabled={claimingId !== null} onclick={() => quickClaim(s)} > - Claim + {i18n.t('p26.dashboardSlices.claimBtn')} - +

{/each} diff --git a/src/routes/for-maintainers/+page.svelte b/src/routes/for-maintainers/+page.svelte index c5d6d3b..b3bc93d 100644 --- a/src/routes/for-maintainers/+page.svelte +++ b/src/routes/for-maintainers/+page.svelte @@ -3,6 +3,7 @@ import { attestationApi } from '$api/attestation'; import { SkilluError } from '$api/client'; import { toast } from '$stores/toast.svelte'; + import { i18n } from '$lib/i18n'; import Button from '$components/ui/Button.svelte'; import Input from '$components/ui/Input.svelte'; import Badge from '$components/ui/Badge.svelte'; @@ -33,9 +34,9 @@ async function copyBadge() { try { await navigator.clipboard.writeText(badgeMarkdown); - toast.success('Snippet copie'); + toast.success(i18n.t('p26.forMaintainers.copyToast')); } catch { - window.prompt('Copiez ce snippet :', badgeMarkdown); + window.prompt(i18n.t('p26.forMaintainers.copyPrompt'), badgeMarkdown); } } @@ -59,28 +60,28 @@ submitError = null; if (!/^[a-zA-Z0-9-]+$/.test(githubLogin.trim())) { - submitError = 'Login GitHub invalide.'; + submitError = i18n.t('p26.forMaintainers.errInvalidGithub'); return; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { - submitError = 'Email invalide.'; + submitError = i18n.t('p26.forMaintainers.errInvalidEmail'); return; } const { valid, invalid } = parseRepos(reposText); if (valid.length === 0) { - submitError = 'Renseignez au moins un repo au format owner/name.'; + submitError = i18n.t('p26.forMaintainers.errReposMin'); return; } if (invalid.length > 0) { - submitError = `Format invalide pour : ${invalid.join(', ')}`; + submitError = i18n.t('p26.forMaintainers.errReposFormat', { list: invalid.join(', ') }); return; } if (valid.length > 50) { - submitError = 'Maximum 50 repos.'; + submitError = i18n.t('p26.forMaintainers.errReposMax'); return; } if (!optIn) { - submitError = 'Vous devez accepter de recevoir le digest.'; + submitError = i18n.t('p26.forMaintainers.errOptIn'); return; } @@ -94,39 +95,24 @@ submitSuccess = { email: email.trim() }; } catch (err) { submitError = - err instanceof SkilluError ? err.message : 'Une erreur est survenue.'; + err instanceof SkilluError ? err.message : i18n.t('p26.forMaintainers.errGeneric'); } finally { submitting = false; } } - const faqs: Array<{ q: string; a: string }> = [ - { - q: 'Comment Skilluv sait sur quels repos je bosse ?', - a: 'Vous les listez a l’inscription. Vous pouvez modifier votre liste a tout moment.' - }, - { - q: 'Puis-je m’abonner sans avoir de repo Skilluv-labelise ?', - a: 'Oui — le digest sera vide en attendant que vous ajoutiez le label skilluv-challenge sur vos issues.' - }, - { - q: 'Comment se desabonner ?', - a: 'Un lien dans chaque email, ou l’URL /maintainer-digest/unsubscribe/{token} recue a l’inscription.' - } - ]; + const faqs = $derived([ + { q: i18n.t('p26.forMaintainers.faqQ1'), a: i18n.t('p26.forMaintainers.faqA1') }, + { q: i18n.t('p26.forMaintainers.faqQ2'), a: i18n.t('p26.forMaintainers.faqA2') }, + { q: i18n.t('p26.forMaintainers.faqQ3'), a: i18n.t('p26.forMaintainers.faqA3') } + ]); - Skilluv — Digest hebdo pour maintainers OSS - - - + {i18n.t('p26.forMaintainers.seoTitle')} + + + @@ -135,30 +121,29 @@

- Vos contributeurs Skilluv, resumes une fois par semaine + {i18n.t('p26.forMaintainers.title')}

- Un digest hebdo, zero spam, unsubscribe en un clic. + {i18n.t('p26.forMaintainers.subtitle')}

-

Ce que fait Skilluv

+

{i18n.t('p26.forMaintainers.whatSkilluvTitle')}

  • - Notre communaute (afro-francophone, autodidactes, reconvertis) contribue a des OSS - externes. + {i18n.t('p26.forMaintainers.whatSkilluvBullet1')}
  • - Sur les issues que vous labellisez skilluv-challenge (ou celles publiques comme {i18n.t('p26.forMaintainers.whatSkilluvBullet2Suffix')} good first issue). @@ -166,7 +151,7 @@
  • - On valide leur travail avant merge — la validation Skilluv est un pre-filtre qualite. + {i18n.t('p26.forMaintainers.whatSkilluvBullet3')}
@@ -174,25 +159,24 @@
-

Ce que vous recevez

+

{i18n.t('p26.forMaintainers.whatReceiveTitle')}

  • - Digest hebdomadaire des PRs Skilluv sur vos repos (nb claims, PRs submit, PRs - validated). + {i18n.t('p26.forMaintainers.whatReceiveBullet1')}
  • - Zero spam : un email/semaine, avec unsubscribe en 1 clic. + {i18n.t('p26.forMaintainers.whatReceiveBullet2')}
  • - Confidentialite : votre email n’est jamais partage. + {i18n.t('p26.forMaintainers.whatReceiveBullet3')}
@@ -201,16 +185,15 @@
-

Notre badge Skilluv

- nouveau +

{i18n.t('p26.forMaintainers.badgeTitle')}

+ {i18n.t('p26.forMaintainers.badgeNew')}

- Ajoutez ce badge a votre README pour signaler que votre projet accueille les contributions - Skilluv. + {i18n.t('p26.forMaintainers.badgeDesc')}

- Badge Skilluv validated + {i18n.t('p26.forMaintainers.badgeAlt')}
 					
-					Copier
+					{i18n.t('p26.forMaintainers.copyBtn')}
 				
 			
@@ -232,7 +215,7 @@
-

FAQ

+

{i18n.t('p26.forMaintainers.faqTitle')}

{#each faqs as faq, i}
@@ -255,7 +238,7 @@
-

S’abonner

+

{i18n.t('p26.forMaintainers.formTitle')}

{#if submitSuccess}
- Confirmation demandee + {i18n.t('p26.forMaintainers.successTitle')}

- Email de confirmation envoye a {submitSuccess.email}. - Cliquez sur le lien dans l’email pour activer votre abonnement. + {i18n.t('p26.forMaintainers.successMessage', { email: submitSuccess.email })}

{:else} @@ -277,25 +259,25 @@ novalidate > {#if submitError} {/if} {/if} diff --git a/src/routes/maintainer-digest/confirm/[token]/+page.svelte b/src/routes/maintainer-digest/confirm/[token]/+page.svelte index 27927b1..cd236c4 100644 --- a/src/routes/maintainer-digest/confirm/[token]/+page.svelte +++ b/src/routes/maintainer-digest/confirm/[token]/+page.svelte @@ -3,6 +3,7 @@ import { page } from '$app/stores'; import { maintainerDigestApi } from '$api/maintainerDigest'; import { SkilluError } from '$api/client'; + import { i18n } from '$lib/i18n'; import Button from '$components/ui/Button.svelte'; import { CheckCircle2, XCircle, Loader2 } from '@lucide/svelte'; @@ -19,7 +20,7 @@ if (res.data.confirmed) { state = { status: 'success', email: res.data.email }; } else { - state = { status: 'error', message: 'Confirmation echouee.' }; + state = { status: 'error', message: i18n.t('p26.maintainerDigest.confirmFailed') }; } } catch (err) { state = { @@ -27,14 +28,14 @@ message: err instanceof SkilluError ? err.message - : 'Ce lien de confirmation est invalide ou expire.' + : i18n.t('p26.maintainerDigest.confirmInvalidFallback') }; } }); - Confirmation abonnement — Skilluv + {i18n.t('p26.maintainerDigest.confirmSeoTitle')} @@ -42,23 +43,23 @@
{#if state.status === 'loading'} -

Confirmation en cours...

+

{i18n.t('p26.maintainerDigest.confirmLoading')}

{:else if state.status === 'success'}

- Abonnement confirme pour {state.email}. Merci ! + {i18n.t('p26.maintainerDigest.confirmSuccessTitle', { email: state.email })}

- Vous recevrez votre premier digest hebdomadaire prochainement. + {i18n.t('p26.maintainerDigest.confirmSuccessBody')}

- + {:else}

- Ce lien de confirmation est invalide ou expire. + {i18n.t('p26.maintainerDigest.confirmInvalidTitle')}

{state.message}

- + {/if}
diff --git a/src/routes/maintainer-digest/unsubscribe/[token]/+page.svelte b/src/routes/maintainer-digest/unsubscribe/[token]/+page.svelte index 33cb172..cf502b5 100644 --- a/src/routes/maintainer-digest/unsubscribe/[token]/+page.svelte +++ b/src/routes/maintainer-digest/unsubscribe/[token]/+page.svelte @@ -3,6 +3,7 @@ import { page } from '$app/stores'; import { maintainerDigestApi } from '$api/maintainerDigest'; import { SkilluError } from '$api/client'; + import { i18n } from '$lib/i18n'; import Button from '$components/ui/Button.svelte'; import { CheckCircle2, XCircle, Loader2 } from '@lucide/svelte'; @@ -19,19 +20,19 @@ if (res.data.unsubscribed) { state = { status: 'success', email: res.data.email }; } else { - state = { status: 'error', message: 'Desabonnement echoue.' }; + state = { status: 'error', message: i18n.t('p26.maintainerDigest.unsubFailed') }; } } catch (err) { state = { status: 'error', - message: err instanceof SkilluError ? err.message : 'Ce lien est invalide.' + message: err instanceof SkilluError ? err.message : i18n.t('p26.maintainerDigest.unsubInvalidFallback') }; } }); - Desabonnement — Skilluv + {i18n.t('p26.maintainerDigest.unsubSeoTitle')} @@ -39,23 +40,23 @@
{#if state.status === 'loading'} -

Desabonnement en cours...

+

{i18n.t('p26.maintainerDigest.unsubLoading')}

{:else if state.status === 'success'}

- Desabonne. Nous ne vous enverrons plus de digest. + {i18n.t('p26.maintainerDigest.unsubSuccessTitle')}

- Vous ne recevrez plus d’emails du digest hebdomadaire ({state.email}). + {i18n.t('p26.maintainerDigest.unsubSuccessBody', { email: state.email })}

- + {:else}

- Ce lien est invalide. + {i18n.t('p26.maintainerDigest.unsubInvalidTitle')}

{state.message}

- + {/if}
diff --git a/src/routes/settings/my-validator-applications/+page.svelte b/src/routes/settings/my-validator-applications/+page.svelte index 0e79f90..9debf85 100644 --- a/src/routes/settings/my-validator-applications/+page.svelte +++ b/src/routes/settings/my-validator-applications/+page.svelte @@ -10,6 +10,7 @@ import { SkilluError } from '$api/client'; import { auth } from '$stores/auth.svelte'; import { toast } from '$stores/toast.svelte'; + import { i18n } from '$lib/i18n'; import Button from '$components/ui/Button.svelte'; import Badge from '$components/ui/Badge.svelte'; import EmptyState from '$components/ui/EmptyState.svelte'; @@ -38,12 +39,14 @@ withdrawn: 'default' }; - const STATUS_LABEL: Record = { - pending: 'En attente', - accepted: 'Acceptee', - rejected: 'Refusee', - withdrawn: 'Retiree' - }; + function statusLabel(s: ValidatorApplicationStatus): string { + switch (s) { + case 'pending': return i18n.t('p26.validatorApplication.statusPending'); + case 'accepted': return i18n.t('p26.validatorApplication.statusAccepted'); + case 'rejected': return i18n.t('p26.validatorApplication.statusRejected'); + case 'withdrawn': return i18n.t('p26.validatorApplication.statusWithdrawn'); + } + } const DOMAIN_BADGE: Record = { code: 'code', @@ -79,7 +82,7 @@ } catch (err) { view = { status: 'error', - message: err instanceof SkilluError ? err.message : 'Impossible de charger.' + message: err instanceof SkilluError ? err.message : i18n.t('p26.validatorApplication.listLoadError') }; } } @@ -96,10 +99,10 @@ actingId = app.id; try { await validatorApplicationsApi.withdraw(app.id); - toast.success('Candidature retiree.'); + toast.success(i18n.t('p26.validatorApplication.toastWithdrawn')); await load(); } catch (err) { - toast.error(err instanceof SkilluError ? err.message : 'Erreur.'); + toast.error(err instanceof SkilluError ? err.message : i18n.t('p26.validatorApplication.toastError')); } finally { actingId = null; } @@ -109,10 +112,10 @@ actingId = app.id; try { await validatorApplicationsApi.accept(app.id); - toast.success('Invitation acceptee.'); + toast.success(i18n.t('p26.validatorApplication.toastAccepted')); await load(); } catch (err) { - toast.error(err instanceof SkilluError ? err.message : 'Erreur.'); + toast.error(err instanceof SkilluError ? err.message : i18n.t('p26.validatorApplication.toastError')); } finally { actingId = null; } @@ -120,7 +123,7 @@ - Mes candidatures validateur — Skilluv + {i18n.t('p26.validatorApplication.listSeoTitle')}
@@ -129,10 +132,10 @@ class="font-heading text-3xl font-bold" style:font-family="'Fraunces Variable', Georgia, serif" > - Mes candidatures validateur + {i18n.t('p26.validatorApplication.listTitle')} @@ -140,11 +143,11 @@
{view.message}
- +
{:else if view.apps.length === 0} {#snippet action()} - + {/snippet} {:else if visibleApps.length === 0} - + {:else}
{#each visibleApps as app (app.id)} @@ -184,9 +187,9 @@
{app.domain} - {app.origin === 'admin_invite' ? 'Invitation admin' : 'Candidature'} + {app.origin === 'admin_invite' ? i18n.t('p26.validatorApplication.originAdminInvite') : i18n.t('p26.validatorApplication.originApplication')} - {STATUS_LABEL[app.status]} + {statusLabel(app.status)}
{#if app.motivation} @@ -195,15 +198,15 @@ {#if app.admin_notes}
-

Note admin

+

{i18n.t('p26.validatorApplication.adminNote')}

{app.admin_notes}

{/if}
- Cree le {formatDate(app.created_at)} + {i18n.t('p26.validatorApplication.createdOn', { date: formatDate(app.created_at) })} {#if app.updated_at && app.updated_at !== app.created_at} - — maj {formatDate(app.updated_at)} + — {i18n.t('p26.validatorApplication.updatedOn', { date: formatDate(app.updated_at) })} {/if}
@@ -216,14 +219,14 @@ loading={actingId === app.id} onclick={() => accept(app)} > - Accepter l'invitation + {i18n.t('p26.validatorApplication.acceptInvitationBtn')} {/if}
{/if} diff --git a/src/routes/settings/validator-application/new/+page.svelte b/src/routes/settings/validator-application/new/+page.svelte index b292389..b9cf8ec 100644 --- a/src/routes/settings/validator-application/new/+page.svelte +++ b/src/routes/settings/validator-application/new/+page.svelte @@ -13,6 +13,7 @@ } from '$api/validatorApplications'; import { auth } from '$stores/auth.svelte'; import { toast } from '$stores/toast.svelte'; + import { i18n } from '$lib/i18n'; import Button from '$components/ui/Button.svelte'; import Select from '$components/ui/Select.svelte'; import { Check, X, ArrowLeft } from '@lucide/svelte'; @@ -96,13 +97,13 @@ domain, motivation: motivation.trim() || undefined }); - toast.success('Candidature envoyee.'); + toast.success(i18n.t('p26.validatorApplication.toastApplySuccess')); await goto('/settings/my-validator-applications'); } catch (err) { if (err instanceof SkilluError && err.status === 403) { - toast.error('Un critere n est pas rempli. Verifie les seuils ci-dessus.'); + toast.error(i18n.t('p26.validatorApplication.toastApplyCriteria')); } else { - toast.error(err instanceof SkilluError ? err.message : 'Erreur lors de l envoi.'); + toast.error(err instanceof SkilluError ? err.message : i18n.t('p26.validatorApplication.toastApplyError')); } } finally { submitting = false; @@ -111,7 +112,7 @@ - Devenir validateur — Skilluv + {i18n.t('p26.validatorApplication.newSeoTitle')}
@@ -121,7 +122,7 @@ class="inline-flex items-center gap-1 text-sm text-text-muted hover:text-text-primary" > - Mes candidatures + {i18n.t('p26.validatorApplication.backToApplications')}
@@ -129,15 +130,14 @@ class="mb-3 font-heading text-3xl font-bold" style:font-family="'Fraunces Variable', Georgia, serif" > - Devenir validateur Skilluv + {i18n.t('p26.validatorApplication.newTitle')}

- Les validateurs verifient les PRs Skilluv avant qu'elles soient marquees comme validees. Chaque - validation te credite en fragments et augmente ta reputation. + {i18n.t('p26.validatorApplication.newSubtitle')}

- + -

{motivation.length}/500

+

{i18n.t('p26.validatorApplication.motivationCounter', { n: motivation.length })}

diff --git a/src/routes/settings/validator-invitations/[id]/+page.svelte b/src/routes/settings/validator-invitations/[id]/+page.svelte index 67ebeb5..f204056 100644 --- a/src/routes/settings/validator-invitations/[id]/+page.svelte +++ b/src/routes/settings/validator-invitations/[id]/+page.svelte @@ -8,6 +8,7 @@ import { SkilluError } from '$api/client'; import { auth } from '$stores/auth.svelte'; import { toast } from '$stores/toast.svelte'; + import { i18n } from '$lib/i18n'; import Button from '$components/ui/Button.svelte'; import Badge from '$components/ui/Badge.svelte'; import Skeleton from '$components/ui/Skeleton.svelte'; @@ -53,7 +54,7 @@ } catch (err) { view = { status: 'error', - message: err instanceof SkilluError ? err.message : 'Erreur.' + message: err instanceof SkilluError ? err.message : i18n.t('p26.validatorApplication.inviteError') }; } } @@ -65,10 +66,10 @@ acting = 'accept'; try { await validatorApplicationsApi.accept(view.app.id); - toast.success('Invitation acceptee.'); + toast.success(i18n.t('p26.validatorApplication.toastInviteAccepted')); await goto('/settings/my-validator-applications'); } catch (err) { - toast.error(err instanceof SkilluError ? err.message : 'Erreur.'); + toast.error(err instanceof SkilluError ? err.message : i18n.t('p26.validatorApplication.inviteError')); } finally { acting = null; } @@ -79,10 +80,10 @@ acting = 'decline'; try { await validatorApplicationsApi.withdraw(view.app.id); - toast.success('Invitation refusee.'); + toast.success(i18n.t('p26.validatorApplication.toastInviteDeclined')); await goto('/settings/my-validator-applications'); } catch (err) { - toast.error(err instanceof SkilluError ? err.message : 'Erreur.'); + toast.error(err instanceof SkilluError ? err.message : i18n.t('p26.validatorApplication.inviteError')); } finally { acting = null; } @@ -90,7 +91,7 @@ - Invitation validateur — Skilluv + {i18n.t('p26.validatorApplication.inviteSeoTitle')}
@@ -100,7 +101,7 @@ class="inline-flex items-center gap-1 text-sm text-text-muted hover:text-text-primary" > - Mes candidatures + {i18n.t('p26.validatorApplication.inviteBackLink')}
@@ -109,16 +110,16 @@ {:else if view.status === 'not-found'}
-

Introuvable

+

{i18n.t('p26.validatorApplication.inviteNotFoundTitle')}

- Cette invitation n'existe pas ou n'est plus disponible. + {i18n.t('p26.validatorApplication.inviteNotFoundBody')}

{:else if view.status === 'error'}
{view.message}
- +
{:else} @@ -127,29 +128,29 @@ class="mb-2 font-heading text-2xl font-bold" style:font-family="'Fraunces Variable', Georgia, serif" > - Invitation admin — Devenir validateur {app.domain} + {i18n.t('p26.validatorApplication.inviteTitle', { domain: app.domain })}
- Invitation admin - En attente + {i18n.t('p26.validatorApplication.inviteBadge')} + {i18n.t('p26.validatorApplication.invitePendingBadge')}

- Un admin Skilluv t'invite a rejoindre l'equipe de validateurs {app.domain}. + {i18n.t('p26.validatorApplication.inviteBody', { domain: app.domain })}

-

Raison de l'invitation

+

{i18n.t('p26.validatorApplication.inviteReasonTitle')}

{#if app.admin_notes}
{app.admin_notes}
{:else} -

L'admin n'a pas laisse de note.

+

{i18n.t('p26.validatorApplication.inviteNoNote')}

{/if}
-

Recu le {formatDate(app.created_at)}

+

{i18n.t('p26.validatorApplication.inviteReceivedOn', { date: formatDate(app.created_at) })}

@@ -159,7 +160,7 @@ loading={acting === 'accept'} onclick={accept} > - Accepter + {i18n.t('p26.validatorApplication.inviteAcceptBtn')}
{/if} diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts new file mode 100644 index 0000000..24cc728 --- /dev/null +++ b/src/routes/sitemap.xml/+server.ts @@ -0,0 +1,63 @@ +import type { RequestHandler } from './$types'; + +/** + * Sitemap XML dynamique — n'inclut que les pages publiques SEO-worthy. + * Les pages dynamiques (/profile/[username], /verify/[hash], /guilds/[slug]) + * ne sont pas listees ici : elles seraient trop nombreuses ou volatiles. Elles + * sont decouvertes via les backlinks + le crawl a partir des pages listing + * (/mentors, /challenges, /guilds, /leaderboards). + * + * A regenerer via un job cron quand on aura des listings statiques (blog, + * etudes de cas, etc.). Pour l'instant, hardcode = simple, suffisant. + */ + +const BASE_URL = 'https://skill-uv.com'; + +const STATIC_ROUTES: { path: string; changefreq: string; priority: number }[] = [ + { path: '/', changefreq: 'weekly', priority: 1.0 }, + { path: '/challenges', changefreq: 'daily', priority: 0.9 }, + { path: '/leaderboards', changefreq: 'daily', priority: 0.8 }, + { path: '/mentors', changefreq: 'weekly', priority: 0.8 }, + { path: '/guilds', changefreq: 'weekly', priority: 0.7 }, + { path: '/tournaments', changefreq: 'weekly', priority: 0.7 }, + { path: '/bounties', changefreq: 'weekly', priority: 0.7 }, + { path: '/certifications', changefreq: 'weekly', priority: 0.7 }, + { path: '/events', changefreq: 'weekly', priority: 0.6 }, + { path: '/diplomas/verify', changefreq: 'monthly', priority: 0.6 }, + { path: '/pricing', changefreq: 'monthly', priority: 0.8 }, + { path: '/for-companies', changefreq: 'monthly', priority: 0.7 }, + { path: '/for-companies/bounties', changefreq: 'monthly', priority: 0.6 }, + { path: '/for-maintainers', changefreq: 'monthly', priority: 0.7 }, + { path: '/auth/login', changefreq: 'yearly', priority: 0.4 }, + { path: '/auth/register', changefreq: 'yearly', priority: 0.5 }, + { path: '/legal/privacy', changefreq: 'yearly', priority: 0.3 }, + { path: '/legal/terms', changefreq: 'yearly', priority: 0.3 }, + { path: '/legal/gdpr', changefreq: 'yearly', priority: 0.3 }, + { path: '/legal/mentions', changefreq: 'yearly', priority: 0.3 } +]; + +export const GET: RequestHandler = async () => { + const now = new Date().toISOString().slice(0, 10); + const urls = STATIC_ROUTES.map( + (r) => ` + ${BASE_URL}${r.path} + ${now} + ${r.changefreq} + ${r.priority.toFixed(1)} + ` + ).join('\n'); + + const xml = ` + +${urls} +`; + + return new Response(xml, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Cache-Control': 'public, max-age=3600' + } + }); +}; + +export const prerender = true; diff --git a/src/routes/slices/[id]/+page.svelte b/src/routes/slices/[id]/+page.svelte index 5a38b43..578e47a 100644 --- a/src/routes/slices/[id]/+page.svelte +++ b/src/routes/slices/[id]/+page.svelte @@ -5,6 +5,7 @@ import { attestationApi } from '$api/attestation'; import { SkilluError } from '$api/client'; import { toast } from '$stores/toast.svelte'; + import { i18n } from '$lib/i18n'; import Button from '$components/ui/Button.svelte'; import Badge from '$components/ui/Badge.svelte'; import ActiveSkilluversWidget from '$components/slice/ActiveSkilluversWidget.svelte'; @@ -35,18 +36,9 @@ 'merged' ]; - const STATUS_LABEL: Record = { - open: 'Ouvert', - claimed: 'Reserve', - in_progress: 'En cours', - submitted: 'PR soumise', - ci_green: 'CI verte', - pending_validation: 'En validation', - validated: 'Validee', - merged: 'Merge upstream', - closed: 'Ferme', - expired: 'Expire' - }; + function statusLabel(s: SliceStatus): string { + return i18n.t(`p26.slice.status.${s}`); + } const STATUS_VARIANT: Record = { open: 'primary', @@ -80,13 +72,13 @@ try { const res = await slicesApi.claim(slice.id); slice = { ...slice, status: 'claimed', claimed_by_user_id: auth.user?.id ?? null, fork_repo_url: res.data.fork_repo_url ?? slice.fork_repo_url }; - toast.success('Challenge reserve'); + toast.success(i18n.t('p26.slice.toastReserved')); await invalidateAll(); } catch (err) { if (err instanceof SkilluError && err.status === 403) { claimGateBlocked = true; } else { - toast.error(err instanceof SkilluError ? err.message : 'Reservation impossible'); + toast.error(err instanceof SkilluError ? err.message : i18n.t('p26.slice.toastReserveError')); } } finally { claiming = false; @@ -94,15 +86,15 @@ } async function doUnclaim() { - if (!confirm('Liberer ce challenge ?')) return; + if (!confirm(i18n.t('p26.slice.confirmRelease'))) return; unclaiming = true; try { await slicesApi.unclaim(slice.id); slice = { ...slice, status: 'open', claimed_by_user_id: null }; - toast.success('Challenge libere'); + toast.success(i18n.t('p26.slice.toastReleased')); await invalidateAll(); } catch (err) { - toast.error(err instanceof SkilluError ? err.message : 'Operation impossible'); + toast.error(err instanceof SkilluError ? err.message : i18n.t('p26.slice.toastReleaseError')); } finally { unclaiming = false; } @@ -117,10 +109,10 @@ slice = { ...slice, status: 'submitted', submitted_pr_url: prUrl }; prUrl = ''; announce = false; - toast.success('PR soumise'); + toast.success(i18n.t('p26.slice.toastPrSent')); await invalidateAll(); } catch (err) { - toast.error(err instanceof SkilluError ? err.message : 'Envoi impossible'); + toast.error(err instanceof SkilluError ? err.message : i18n.t('p26.slice.toastSendError')); } finally { submittingPr = false; } @@ -129,9 +121,9 @@ function fmtDaysLeft(iso: string | null): string | null { if (!iso) return null; const diff = new Date(iso).getTime() - Date.now(); - if (diff <= 0) return 'Expire'; + if (diff <= 0) return i18n.t('p26.slice.expired'); const days = Math.ceil(diff / 86400000); - return `Reste ${days}j`; + return i18n.t('p26.slice.daysLeft', { n: days }); } function truncHash(h: string): string { @@ -150,19 +142,19 @@
- {STATUS_LABEL[slice.status]} + {statusLabel(slice.status)} {#if slice.labels[0]} {slice.labels[0]} {/if} {#if slice.min_rank} - rank ≥ {slice.min_rank} + {i18n.t('p26.slice.rankGte', { rank: slice.min_rank })} {/if} {#if slice.required_orientation_slugs} {#each slice.required_orientation_slugs as o (o)} {o} {/each} {/if} - difficulte {slice.difficulty}/5 + {i18n.t('p26.slice.difficultyBadge', { n: slice.difficulty })} {#if slice.claim_expires_at && (slice.status === 'claimed' || slice.status === 'in_progress')} {@const dl = fmtDaysLeft(slice.claim_expires_at)} {#if dl} @@ -182,7 +174,7 @@ class="inline-flex items-center gap-1.5 text-text-muted hover:text-accent transition-colors" > - Issue GitHub + {i18n.t('p26.slice.issueGithub')} {/if} {#if slice.fork_repo_url} @@ -193,7 +185,7 @@ class="inline-flex items-center gap-1.5 text-text-muted hover:text-accent transition-colors" > - Ton fork + {i18n.t('p26.slice.yourFork')} {/if} {#if slice.submitted_pr_url} @@ -204,7 +196,7 @@ class="inline-flex items-center gap-1.5 text-text-muted hover:text-accent transition-colors" > - Voir la PR + {i18n.t('p26.slice.viewPr')} {/if}
@@ -221,7 +213,7 @@ {#if slice.acceptance_criteria?.length}

- Criteres d'acceptation + {i18n.t('p26.slice.acceptanceTitle')}

    {#each slice.acceptance_criteria as c, i (i)} @@ -237,7 +229,7 @@

    - Workflow + {i18n.t('p26.slice.workflowTitle')}

      {#each STATUS_ORDER as st, i (st)} @@ -256,20 +248,22 @@ - {STATUS_LABEL[st]} + {statusLabel(st)} {/each}
    {#if slice.status === 'closed' || slice.status === 'expired'} -

    Workflow interrompu ({STATUS_LABEL[slice.status]}).

    +

    + {i18n.t('p26.slice.workflowInterrupted', { status: statusLabel(slice.status) })} +

    {/if}
    {#if slice.validation_reject_reason}
    -

    PR non validee

    +

    {i18n.t('p26.slice.rejectTitle')}

    {slice.validation_reject_reason}

    {/if} @@ -279,41 +273,41 @@ {#if canClaim} {#if claimGateBlocked}

    - Ton rank ou orientation ne correspond pas encore a cette slice. + {i18n.t('p26.slice.claimGateBlocked')}

    {/if} {:else if !auth.isAuthenticated && slice.status === 'open'} {/if} {#if canSubmitPr}
    -

    Soumettre ta PR

    +

    {i18n.t('p26.slice.submitPrTitle')}

    @@ -324,7 +318,7 @@ disabled={!prUrl.trim() || submittingPr} loading={submittingPr} > - Envoyer la PR + {i18n.t('p26.slice.sendPrBtn')}
    @@ -343,37 +337,37 @@ {#if isMine && (slice.status === 'submitted' || slice.status === 'ci_green')}
    {#if slice.submitted_pr_url} - + {/if} - {slice.status === 'ci_green' ? 'CI verte, en attente validation' : 'En attente CI'} + {slice.status === 'ci_green' ? i18n.t('p26.slice.ciGreen') : i18n.t('p26.slice.ciPending')}
    {/if} {#if slice.status === 'pending_validation'}

    - En cours de review + {i18n.t('p26.slice.pendingReview')} - {#if slice.validator_user_id}par un valideur Skilluv.{/if} + {#if slice.validator_user_id}{i18n.t('p26.slice.pendingReviewBy')}{/if}

    {/if} {#if (slice.status === 'validated' || slice.status === 'merged') && slice.attestation_hash}
    - Attestation generee + {i18n.t('p26.slice.attestationGenerated')} {#if slice.status === 'merged'} - Merge upstream + {i18n.t('p26.slice.mergedUpstream')} {/if}
    @@ -383,7 +377,7 @@ {#if slice.attestation_hash}

    - Attestation ID : + {i18n.t('p26.slice.attestationIdLabel')} {truncHash(slice.attestation_hash)}

    {/if} diff --git a/src/routes/validations/[slice_id]/review/+page.svelte b/src/routes/validations/[slice_id]/review/+page.svelte index 76a08fa..b132aeb 100644 --- a/src/routes/validations/[slice_id]/review/+page.svelte +++ b/src/routes/validations/[slice_id]/review/+page.svelte @@ -10,6 +10,7 @@ import { SkilluError } from '$api/client'; import { auth } from '$stores/auth.svelte'; import { toast } from '$stores/toast.svelte'; + import { i18n } from '$lib/i18n'; import Button from '$components/ui/Button.svelte'; import Badge from '$components/ui/Badge.svelte'; import Skeleton from '$components/ui/Skeleton.svelte'; @@ -64,7 +65,7 @@ } catch (err) { view = { status: 'error', - message: err instanceof SkilluError ? err.message : 'Impossible de charger la review.' + message: err instanceof SkilluError ? err.message : i18n.t('p26.validation.reviewLoadError') }; } } @@ -83,9 +84,9 @@ approveResult = res.data; } catch (err) { if (err instanceof SkilluError && err.status === 400) { - submitError = 'Impossible : tu es le claimer de cette PR.'; + submitError = i18n.t('p26.validation.errClaimerSelf'); } else { - submitError = err instanceof SkilluError ? err.message : 'Erreur lors de l approbation.'; + submitError = err instanceof SkilluError ? err.message : i18n.t('p26.validation.errApprove'); } } finally { approving = false; @@ -98,13 +99,13 @@ rejecting = true; try { await validationApi.reject(view.item.slice.id, { reason: feedback }); - toast.success('PR rejetee.'); + toast.success(i18n.t('p26.validation.toastRejected')); await goto('/validations/queue'); } catch (err) { if (err instanceof SkilluError && err.status === 400) { - submitError = 'Impossible : tu es le claimer de cette PR.'; + submitError = i18n.t('p26.validation.errClaimerSelf'); } else { - submitError = err instanceof SkilluError ? err.message : 'Erreur lors du rejet.'; + submitError = err instanceof SkilluError ? err.message : i18n.t('p26.validation.errReject'); } } finally { rejecting = false; @@ -113,7 +114,7 @@ - Reviewer une PR — Skilluv + {i18n.t('p26.validation.reviewSeoTitle')}
    @@ -123,7 +124,7 @@ class="inline-flex items-center gap-1 text-sm text-text-muted hover:text-text-primary" > - Retour a la queue + {i18n.t('p26.validation.backToQueue')}
    @@ -134,16 +135,16 @@ {:else if view.status === 'not-found'}
    -

    Introuvable

    +

    {i18n.t('p26.validation.notFoundTitle')}

    - Ce challenge n'existe pas dans ta file de validation. Il a peut-etre deja ete traite. + {i18n.t('p26.validation.notFoundBody')}

    {:else if view.status === 'error'}
    {view.message}
    - +
    {:else} @@ -159,19 +160,19 @@
    {domain} - difficulte {item.slice.difficulty} - status {item.slice.status} + {i18n.t('p26.validation.difficultyBadge', { n: item.slice.difficulty })} + {i18n.t('p26.validation.statusBadge', { status: item.slice.status })}
{#if approveResult}
-

Validation approuvee

+

{i18n.t('p26.validation.approvedTitle')}

- {approveResult.fragments_credited} fragments credites. L'attestation est publique. + {i18n.t('p26.validation.approvedFragments', { n: approveResult.fragments_credited })}

- Attestation ID : {approveResult.attestation_hash} + {i18n.t('p26.validation.attestationIdLabel', { hash: approveResult.attestation_hash })}

@@ -193,18 +194,17 @@
- En approvant, tu genereras une attestation qui sera publique via /verify/{'{hash}'} et - telechargeable en PDF. Les fragments seront credites au challenger et a toi. + {i18n.t('p26.validation.warningPublicApprove')}
-

Reviewer la PR

+

{i18n.t('p26.validation.reviewPrTitle')}

- L'iframe est bloque par GitHub — utilise ce bouton pour ouvrir la PR dans un nouvel onglet. + {i18n.t('p26.validation.reviewIframeHint')}

@@ -226,10 +226,10 @@
-

Ton verdict

+

{i18n.t('p26.validation.verdictTitle')}

-

{feedback.length}/2000

+

{i18n.t('p26.validation.feedbackCounter', { n: feedback.length })}

{#if submitError} @@ -253,10 +253,10 @@ loading={rejecting} onclick={reject} > - Rejeter + {i18n.t('p26.validation.rejectBtn')}
diff --git a/src/routes/validations/queue/+page.svelte b/src/routes/validations/queue/+page.svelte index 2b4cdb5..9eb55f2 100644 --- a/src/routes/validations/queue/+page.svelte +++ b/src/routes/validations/queue/+page.svelte @@ -5,6 +5,7 @@ import { SkilluError } from '$api/client'; import { auth } from '$stores/auth.svelte'; import { toast } from '$stores/toast.svelte'; + import { i18n } from '$lib/i18n'; import Button from '$components/ui/Button.svelte'; import Badge from '$components/ui/Badge.svelte'; import EmptyState from '$components/ui/EmptyState.svelte'; @@ -65,7 +66,7 @@ } view = { status: 'error', - message: err instanceof SkilluError ? err.message : 'Impossible de charger la file.' + message: err instanceof SkilluError ? err.message : i18n.t('p26.validation.toastLoadError') }; } } @@ -86,12 +87,12 @@ ) }; } - toast.success('Challenge pris en charge.'); + toast.success(i18n.t('p26.validation.toastPickedUp')); } catch (err) { if (err instanceof SkilluError && err.status === 400) { - toast.error('Ce challenge a ete pris par un autre validateur.'); + toast.error(i18n.t('p26.validation.toastTakenByOther')); } else { - toast.error(err instanceof SkilluError ? err.message : 'Erreur lors du pick-up.'); + toast.error(err instanceof SkilluError ? err.message : i18n.t('p26.validation.toastPickupError')); } // Refetch pour resynchroniser la file (le back est source de verite). await load(); @@ -107,25 +108,25 @@ - File de validation — Skilluv + {i18n.t('p26.validation.queueSeoTitle')}

- File de validation + {i18n.t('p26.validation.queueTitle')}

- Les PRs en attente de validation dans tes domaines. + {i18n.t('p26.validation.queueSubtitle')}

{#if view.status === 'ready' && view.items.length > 0} {#snippet action()} - + {/snippet} {:else if view.status === 'error'}
{view.message}
- +
{:else if visibleItems.length === 0} {:else}
@@ -180,7 +181,7 @@
{shortRepo(item.repo_url)} {domain} - difficulte {item.slice.difficulty} + {i18n.t('p26.validation.difficultyBadge', { n: item.slice.difficulty })}
@@ -202,12 +203,12 @@
{#if item.picked_up_by_me} {:else} {/if}
diff --git a/src/routes/verify/[hash]/+page.svelte b/src/routes/verify/[hash]/+page.svelte index 1b8813d..aac513a 100644 --- a/src/routes/verify/[hash]/+page.svelte +++ b/src/routes/verify/[hash]/+page.svelte @@ -7,6 +7,7 @@ } from '$api/attestation'; import { SkilluError } from '$api/client'; import { toast } from '$stores/toast.svelte'; + import { i18n } from '$lib/i18n'; import Button from '$components/ui/Button.svelte'; import Badge from '$components/ui/Badge.svelte'; import Skeleton from '$components/ui/Skeleton.svelte'; @@ -26,7 +27,7 @@ state = { status: 'ready', result }; } catch (err) { const message = - err instanceof SkilluError ? err.message : 'Impossible de charger cette attestation.'; + err instanceof SkilluError ? err.message : i18n.t('p26.verify.fallbackError'); state = { status: 'error', message }; } }); @@ -57,10 +58,10 @@ const url = typeof window !== 'undefined' ? window.location.href : ''; try { await navigator.clipboard.writeText(url); - toast.success('Lien copie dans le presse-papiers'); + toast.success(i18n.t('p26.verify.copyToast')); } catch { // Fallback : selection manuelle via prompt - window.prompt('Copiez ce lien :', url); + window.prompt(i18n.t('p26.verify.copyPrompt'), url); } } @@ -79,13 +80,13 @@ let seoTitle = $derived( valid - ? `Attestation Skilluv verifiee — ${valid.challenger.display_name}` - : 'Attestation Skilluv' + ? i18n.t('p26.verify.seoTitleValid', { name: valid.challenger.display_name }) + : i18n.t('p26.verify.seoTitleDefault') ); let seoDescription = $derived( valid - ? `Attestation Skilluv de ${valid.challenger.display_name} pour une contribution validee` - : 'Verifiez une attestation Skilluv.' + ? i18n.t('p26.verify.seoDescValid', { name: valid.challenger.display_name }) + : i18n.t('p26.verify.seoDescDefault') ); @@ -119,52 +120,52 @@

- Erreur de chargement + {i18n.t('p26.verify.errorTitle')}

{state.message}

- +
{:else if invalid}

- Attestation introuvable + {i18n.t('p26.verify.notFoundTitle')}

- invalide + {i18n.t('p26.verify.invalidBadge')}

{invalid.reason === 'malformed attestation hash' - ? 'Ce lien ne correspond pas au format d’une attestation Skilluv.' + ? i18n.t('p26.verify.reasonMalformed') : invalid.reason === 'unknown attestation hash' - ? 'Aucune attestation ne correspond a ce hash. Elle a peut-etre ete revoquee ou n’a jamais existe.' + ? i18n.t('p26.verify.reasonUnknown') : invalid.reason}

- +
{:else if valid}

- Attestation Skilluv verifiee + {i18n.t('p26.verify.verifiedTitle')}

- verified + {i18n.t('p26.verify.verifiedBadge')}

- Delivree le {formatDate(valid.validated_at)} + {i18n.t('p26.verify.issuedOn', { date: formatDate(valid.validated_at) })}

- Contributeur + {i18n.t('p26.verify.contributor')}

- Validee par + {i18n.t('p26.verify.validatedBy')}

- Contribution + {i18n.t('p26.verify.contribution')}

{valid.domain} - difficulte {valid.difficulty}/5 + {i18n.t('p26.verify.difficultyBadge', { n: valid.difficulty })} {#if valid.merged_upstream} - Merge upstream + {i18n.t('p26.verify.mergedUpstream')} {/if}

- Repo : {valid.repo} + {i18n.t('p26.verify.repoLabel')} {valid.repo}

@@ -247,17 +248,17 @@ download > - Telecharger le PDF + {i18n.t('p26.verify.downloadPdf')}