Implémentation complète du multilingue FR/EN

- Installation vue-i18n v11 et création des fichiers de traduction (fr.json, en.json)
- Création store locale avec détection hiérarchique (URL > localStorage > navigator)
- Modification des routes avec préfixe /:locale? optionnel
- Toggle FR/EN dans Menu.vue avec synchronisation immédiate
- Traduction de ~200 textes dans 27 composants Vue
- Suppression des labels hardcodés en français côté backend
- Ajout route Kirby catch-all en/(:all?) pour /en/ URLs
- Helper addLocalePrefix() pour préserver locale dans liens dialogs
- Traduction pseudo-élément CSS via data attribute

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-02-02 18:21:11 +01:00
parent 3af95b1d20
commit 82eb8d88cc
49 changed files with 1079 additions and 295 deletions

View file

@ -29,7 +29,22 @@
<div v-if="isExpanded" id="menu" class="flex | rounded-xl">
<header class="w-full | flex">
<!-- TODO: à dynamiser en récupérant le $site->title() -->
<p lang="en">Design to Pack</p>
<p :lang="currentLocale">Design to Pack</p>
<div class="lang-toggle">
<button
@click="switchLocale('fr')"
:class="{ active: currentLocale === 'fr' }"
>
FR
</button>
<span class="slash">/</span>
<button
@click="switchLocale('en')"
:class="{ active: currentLocale === 'en' }"
>
EN
</button>
</div>
</header>
<nav class="w-full | flow">
<ul class="flex">
@ -51,25 +66,23 @@
>{{ mainItem.title }}</router-link
>
<span
v-if="mainItem.title === 'Inspirations' && page?.newInspirations"
v-if="
mainItem.title === t('menu.inspirations') && page?.newInspirations
"
class="pill pill--secondary"
>{{ 'Nouveautés' }}</span
>{{ t('menu.news') }}</span
>
</li>
</ul>
<details :class="{ skeleton: !currentProjects }" open>
<summary>Projets en cours</summary>
<summary>{{ t('menu.currentProjects') }}</summary>
<ul v-if="currentProjects.length > 0">
<li
v-for="project in currentProjects"
:class="{ active: isCurrent(project) }"
>
<router-link
:to="
isEmptyBrief(project)
? project.uri + '/client-brief'
: project.uri
"
:to="getProjectPath(project)"
:class="hasUnreadNotification(project) ? 'new' : undefined"
:data-dtl="project.isDTLEnabled ? 'true' : undefined"
@click="collapse()"
@ -79,13 +92,13 @@
</ul>
</details>
<details v-if="archivedProjects.length">
<summary>Projets archivés</summary>
<summary>{{ t('menu.archivedProjects') }}</summary>
<ul>
<li
v-for="project in archivedProjects"
:class="{ active: isCurrent(project) }"
>
<router-link :to="project.uri" @click="collapse()">{{
<router-link :to="getProjectPath(project)" @click="collapse()">{{
project.title
}}</router-link>
</li>
@ -96,13 +109,19 @@
<ul class="flex">
<li data-icon="user">
<a
:href="user.role === 'admin' ? '/panel/account' : '/account'"
:href="
user.role === 'admin'
? '/panel/account'
: currentLocale === 'en'
? '/en/account'
: '/account'
"
@click="collapse()"
>Profil</a
>{{ t('menu.profile') }}</a
>
</li>
<li data-icon="logout">
<a href="/logout" @click="collapse()">Déconnexion</a>
<a href="/logout" @click="collapse()">{{ t('menu.logout') }}</a>
</li>
</ul>
</footer>
@ -113,17 +132,23 @@
import { storeToRefs } from 'pinia';
import { computed, ref } from 'vue';
import { useProjectsStore } from '../stores/projects';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useUserStore } from '../stores/user';
import { usePageStore } from '../stores/page';
import { useProjectStore } from '../stores/project';
import { useLocaleStore } from '../stores/locale';
import { useI18n } from 'vue-i18n';
const route = useRoute();
const router = useRouter();
const isExpanded = ref(true);
const { user, notifications } = storeToRefs(useUserStore());
const { currentProjects, archivedProjects } = storeToRefs(useProjectsStore());
const { isEmptyBrief } = useProjectStore();
const { page } = storeToRefs(usePageStore());
const localeStore = useLocaleStore();
const { currentLocale } = storeToRefs(localeStore);
const { t } = useI18n();
const unreadNotificationsCount = computed(() => {
if (!user.value) return 0;
@ -135,34 +160,37 @@ const unreadNotificationsCount = computed(() => {
return count === 0 ? 0 : count;
});
const mainItems = [
{
title: 'Home',
path: '/',
icon: 'home',
},
{
title: 'Notifications',
path: '/notifications',
icon: 'megaphone',
},
{
title: 'Réunions',
path: '/reunions',
icon: 'calendar',
disabled: true,
},
{
title: 'Design to Light',
path: '/design-to-light',
icon: 'leaf',
},
{
title: 'Inspirations',
path: '/inspirations',
icon: 'inspiration',
},
];
const mainItems = computed(() => {
const prefix = currentLocale.value === 'en' ? '/en' : '';
return [
{
title: t('menu.home'),
path: prefix + '/',
icon: 'home',
},
{
title: t('menu.notifications'),
path: prefix + '/notifications',
icon: 'megaphone',
},
{
title: t('menu.meetings'),
path: prefix + '/reunions',
icon: 'calendar',
disabled: true,
},
{
title: t('menu.designToLight'),
path: prefix + '/design-to-light',
icon: 'leaf',
},
{
title: t('menu.inspirations'),
path: prefix + '/inspirations',
icon: 'inspiration',
},
];
});
function toggleExpand() {
isExpanded.value = !isExpanded.value;
@ -192,6 +220,33 @@ function collapse() {
isExpanded.value = false;
}
}
function switchLocale(newLocale) {
if (newLocale === currentLocale.value) return;
let newPath = route.path;
if (newLocale === 'en') {
if (!newPath.startsWith('/en')) {
newPath = '/en' + newPath;
}
} else {
if (newPath.startsWith('/en')) {
newPath = newPath.replace(/^\/en/, '') || '/';
}
}
localeStore.setLocale(newLocale);
router.push(newPath);
}
function getProjectPath(project) {
const prefix = currentLocale.value === 'en' ? '/en' : '';
const path = isEmptyBrief(project)
? project.uri + '/client-brief'
: project.uri;
return prefix + path;
}
</script>
<style>
@ -243,6 +298,24 @@ button[aria-controls='menu'][aria-expanded='false']
overflow-y: auto;
}
/* Lang toggle */
.lang-toggle {
font-family: var(--font-sans);
}
.lang-toggle button {
padding: 0;
}
.lang-toggle button.active {
background-color: transparent !important;
color: var(--color-primary);
}
.lang-toggle span.slash {
padding: 0 0.2rem;
}
@media (max-width: 1023px) {
button[aria-controls='menu'][aria-expanded='true'] {
left: 0;

View file

@ -5,12 +5,12 @@
modal
:draggable="false"
:dismissableMask="true"
header="Demander la création dun projet"
:header="t('dialogs.requestProject')"
class="dialog"
:closeOnEscape="true"
>
<template #header>
<h2 class="font-serif text-lg">Demander la création dun projet</h2>
<h2 class="font-serif text-lg">{{ t('dialogs.requestProject') }}</h2>
</template>
<form
@ -18,24 +18,28 @@
class="w-full h-full p-16 flex flex-col"
style="--row-gap: 1rem"
>
<label for="project-title" class="sr-only">Nom du projet</label>
<label for="project-title" class="sr-only">{{
t('forms.projectName')
}}</label>
<input
type="text"
v-model="title"
id="project-title"
placeholder="Nom du projet"
:placeholder="t('forms.projectName')"
class="w-full rounded-md border border-grey-200 px-16 py-12"
required
/>
<label for="project-details" class="sr-only">Détails du projet</label>
<label for="project-details" class="sr-only">{{
t('forms.projectDetails')
}}</label>
<textarea
id="project-details"
name="details"
v-model="details"
cols="30"
rows="10"
placeholder="Détails du projet…"
:placeholder="t('forms.projectDetailsPlaceholder')"
class="w-full flex-1 rounded-md border border-grey-200 px-16 py-12"
required
></textarea>
@ -52,26 +56,24 @@
class="flex font-medium mt-4"
style="--column-gap: var(--space-4)"
>
Créer avec
<span class="flex justify-center text-sm" data-icon="leaf"
>Design to Light</span
>
{{ t('dialogs.createWithDTL') }}
<span class="flex justify-center text-sm" data-icon="leaf">{{
t('dtl.title')
}}</span>
</label>
<p class="text-sm mt-8 mb-4">
Découvrez la note environnementale de votre projet et allégez limpact
de votre projet grâce à nos expertises doptimisation du poids de
flacon.
{{ t('dialogs.dtlDescription') }}
</p>
<router-link to="/design-to-light" class="text-sm font-medium"
>En savoir plus</router-link
>
<router-link to="/design-to-light" class="text-sm font-medium">{{
t('dialogs.learnMore')
}}</router-link>
</div>
<footer class="flex-columns w-full mt-16" style="column-gap: 0.5rem">
<button class="btn btn--black-10" @click="emits('close')">
Annuler
{{ t('buttons.cancel') }}
</button>
<button class="btn" type="submit">Soumettre</button>
<button class="btn" type="submit">{{ t('buttons.submit') }}</button>
</footer>
</form>
</Dialog>
@ -81,6 +83,9 @@
import Dialog from 'primevue/dialog';
import { ref, watch } from 'vue';
import { useApiStore } from '../stores/api';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const title = ref('');
const details = ref('');

View file

@ -1,6 +1,6 @@
<template>
<header class="flex">
<h2 id="tabslist" class="sr-only">Projets</h2>
<h2 id="tabslist" class="sr-only">{{ t('brief.projects') }}</h2>
<Tabs :tabs="tabs" @update:currentTab="changeTab" />
</header>
<section
@ -11,6 +11,7 @@
:aria-label="tabs[0].label"
class="flow"
:class="{ skeleton: isProjectsLoading }"
:data-empty-text="t('projects.none')"
>
<Project
v-for="project in currentProjects"
@ -25,6 +26,7 @@
tabindex="0"
:aria-label="tabs[1].label"
class="flow"
:data-empty-text="t('projects.none')"
>
<Project
v-for="project in archivedProjects"
@ -39,7 +41,9 @@ import Project from './project/Project.vue';
import { useProjectsStore } from '../stores/projects';
import { ref, computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { projects, currentProjects, archivedProjects, isProjectsLoading } =
storeToRefs(useProjectsStore());
@ -47,13 +51,13 @@ const currentTab = ref('currentProjects');
const tabs = computed(() => {
return [
{
label: 'Projets en cours',
label: t('projects.current'),
id: 'currentProjects',
count: currentProjects.value.length,
isActive: currentTab.value === 'currentProjects',
},
{
label: 'Projets archivés',
label: t('projects.archived'),
id: 'archivedProjects',
count: archivedProjects.value.length,
isActive: currentTab.value === 'archivedProjects',
@ -72,7 +76,7 @@ section {
min-height: calc(100vh - 8.5rem);
}
section:not(.skeleton):empty::after {
content: 'Aucun projet pour le moment';
content: attr(data-empty-text);
position: absolute;
inset: 0;
display: grid;

View file

@ -12,7 +12,7 @@
v-model="currentValue"
:options="items"
optionLabel="title"
:placeholder="'Sélectionnez une déclinaison'"
:placeholder="t('forms.selectVariation')"
:maxSelectedLabels="3"
class="font-serif"
:class="{ active: currentValue }"
@ -45,7 +45,7 @@
<p v-if="currentValue">
{{ currentValue.title }}
</p>
<p v-else>Sélectionnez une déclinaison</p>
<p v-else>{{ t('forms.selectVariation') }}</p>
</template>
<template #option="slotProps">
@ -65,6 +65,9 @@
import { onBeforeMount, ref, watch, nextTick } from 'vue';
import { storeToRefs } from 'pinia';
import { useDialogStore } from '../stores/dialog';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
// Props
const { items, label, isCompareModeEnabled, index } = defineProps({

View file

@ -39,9 +39,7 @@
<footer v-if="!comment.isEditMode" class="comment__replies">
<p v-if="comment.replies?.length > 0">
{{ comment.replies.length }} réponse{{
comment.replies.length > 1 ? 's' : ''
}}
{{ comment.replies.length }} {{ comment.replies.length > 1 ? t('comments.replies') : t('comments.reply') }}
</p>
<div
v-if="userStore.canEditComment(comment)"
@ -52,14 +50,14 @@
data-icon="edit"
@click="editComment($event)"
>
<span class="sr-only">Éditer</span>
<span class="sr-only">{{ t('comments.edit') }}</span>
</button>
<button
class="btn btn--transparent btn--icon btn--sm"
data-icon="delete"
@click="deleteComment($event)"
>
<span class="sr-only">Supprimer</span>
<span class="sr-only">{{ t('buttons.delete') }}</span>
</button>
</div>
</footer>
@ -68,11 +66,11 @@
<input
type="submit"
class="btn btn--tranparent"
value="Sauvegarder"
:value="t('buttons.save')"
@click="saveEditedComment($event)"
/>
<button class="btn btn--white-10" @click="cancelEditComment($event)">
Annuler
{{ t('buttons.cancel') }}
</button>
</footer>
</article>
@ -88,9 +86,11 @@ import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { storeToRefs } from 'pinia';
import { usePageStore } from '../../stores/page';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
dayjs.locale('fr');
const { t } = useI18n();
const { comment, commentIndex } = defineProps({
comment: Object,
commentIndex: Number,
@ -125,11 +125,11 @@ function formatDate() {
const dateNumber = parseInt(dayjs(comment.date).format('YYMMD'));
if (dateNumber === todayNumber) {
return "Aujourd'hui";
return t('dates.today');
}
if (dateNumber === todayNumber - 1) {
return 'hier';
return t('dates.yesterday');
}
return dayjs(comment.date).format('D MMM YY');
@ -153,7 +153,7 @@ async function read() {
page.value.uri
);
} catch (error) {
console.log('Erreur lors de la lecture de la notification : ', error);
console.log(t('errors.readNotificationFailed'), error);
}
}

View file

@ -1,9 +1,10 @@
<template>
<aside id="comments-container" aria-labelledby="comments-label">
<h2 id="comments-label" class="sr-only">Commentaires</h2>
<h2 id="comments-label" class="sr-only">{{ t('comments.title') }}</h2>
<div
class="comments | flow"
:class="{ empty: !comments || comments.length === 0 }"
:data-empty-message="t('comments.emptyMessage')"
>
<template v-if="comments">
<template v-if="!openedComment">
@ -26,7 +27,7 @@
isAddOpen = false;
"
>
<span>Retour à la liste</span>
<span>{{ t('buttons.backToList') }}</span>
</button>
<Comment
:comment="openedComment"
@ -53,7 +54,7 @@
class="btn btn--white-20 | w-full"
@click="toggleCommentPositionMode(true)"
>
Ajouter un commentaire
{{ t('buttons.addComment') }}
</button>
<button
v-else-if="openedComment && !isAddOpen"
@ -61,7 +62,7 @@
class="btn btn--white-20 | justify-start w-full | text-white-50"
@click="isAddOpen = true"
>
Répondre
{{ t('buttons.reply') }}
</button>
<!-- TODO: afficher #new-comment une fois le bouton Ajouter un commentaire cliqué -->
<div
@ -70,11 +71,10 @@
class="bg-primary | text-sm text-white | rounded-lg | p-12"
>
<p class="flex justify-start | mb-12" data-icon="comment">
<strong>Nouveau commentaire</strong>
<strong>{{ t('comments.new') }}</strong>
</p>
<p>
Dans la zone du contenu, cliquez vous souhaitez positionner le
commentaire
{{ t('comments.newInstruction') }}
</p>
</div>
<form
@ -84,13 +84,13 @@
class="flow | p-12 | rounded-xl"
@submit="handleSubmit"
>
<label class="sr-only" for="comment">Votre commentaire</label>
<label class="sr-only" for="comment">{{ t('comments.your') }}</label>
<textarea
v-model="draftComment.text"
:disabled="isSubmitting ? true : undefined"
name="comment"
id="comment"
placeholder="Ajouter un commentaire…"
:placeholder="t('forms.commentPlaceholder')"
rows="5"
class="text-sm | rounded-lg bg-black p-12"
></textarea>
@ -99,11 +99,11 @@
type="submit"
class="btn"
:class="{ submitting: isSubmitting }"
:value="isSubmitting ? 'En cours' : undefined"
:value="isSubmitting ? t('comments.inProgress') : undefined"
:disabled="isSubmitting ? true : undefined"
/>
<button class="btn btn--white-10" @click="isAddOpen = false">
Annuler
{{ t('buttons.cancel') }}
</button>
</footer>
</form>
@ -122,9 +122,11 @@ import { useDialogStore } from '../../stores/dialog';
import Comment from './Comment.vue';
import { storeToRefs } from 'pinia';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
dayjs.locale('fr');
const { t } = useI18n();
const { user } = useUserStore();
const { page } = usePageStore();
const dialog = useDialogStore();

View file

@ -1,13 +1,16 @@
<template>
<button id="dtl-btn" class="bg-black rounded-md p-4" :title="'Design to Light: ' + (hasAlternatives ? 'New' : grade)" data-icon="leaf" :data-grade="grade" :data-new="hasAlternatives ? true : undefined">
<span lang="en" class="sr-only">Design to Light</span>
<span v-if="hasAlternatives" lang="en" class="new">New</span>
<button id="dtl-btn" class="bg-black rounded-md p-4" :title="t('dtl.grade', { grade: hasAlternatives ? t('menu.news') : grade })" data-icon="leaf" :data-grade="grade" :data-new="hasAlternatives ? true : undefined">
<span lang="en" class="sr-only">{{ t('dtl.title') }}</span>
<span v-if="hasAlternatives" lang="en" class="new">{{ t('menu.news') }}</span>
</button>
</template>
<script setup>
import { storeToRefs } from "pinia";
import { usePageStore } from "../../stores/page";
import { computed } from "vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { page } = storeToRefs(usePageStore());

View file

@ -17,8 +17,8 @@
proposals.length === 1 && isDialogOpen
? activeProposal.title
? activeProposal.title
: 'Design to light'
: 'Design to light'
: t('dtl.title')
: t('dtl.title')
}}
</h2>
<button
@ -27,7 +27,7 @@
class="btn btn--icon btn--transparent | ml-auto"
data-icon="close"
>
<span class="sr-only">Fermer</span>
<span class="sr-only">{{ t('buttons.close') }}</span>
</button>
</header>
<nav v-if="!isDialogOpen" class="tabs" role="tablist" tabindex="-1">
@ -42,8 +42,8 @@
proposal.title
? proposal.title
: index === 0
? 'Proposition initiale'
: 'Alternative ' + index
? t('dtl.initialProposal')
: t('dtl.alternative', { index })
}}
</button>
</nav>
@ -67,12 +67,12 @@
/>
</router-link>
<p>
Données basées sur la proposition <br />du {{ activeProposal.date }}
<br />{{ activeProposal.stepLabel }}
{{ t('dtl.proposalBasedOn') }} <br />du {{ activeProposal.date }}
<br />{{ stepLabel }}
</p>
</div>
<div id="note-globale" class="px-32 py-16 border-b flow">
<h3>Note globale</h3>
<h3>{{ t('dtl.globalScore') }}</h3>
<div class="flex" style="--column-gap: 1rem">
<p :data-grade="activeProposal.grades.global.letter">
<strong class="sr-only">{{
@ -100,15 +100,15 @@
</div>
</div>
<div id="positionnement" class="px-32 py-16 border-b flow">
<h3>Positionnement</h3>
<h3>{{ t('dtl.positioning') }}</h3>
<dl>
<dt id="design">Design</dt>
<dt id="design">{{ t('dtl.design') }}</dt>
<dd>
<span class="sr-only">{{
activeProposal.grades.position.complexity
}}</span>
</dd>
<dt id="poids">Poids</dt>
<dt id="poids">{{ t('dtl.weight') }}</dt>
<dd>
<span class="sr-only">{{
activeProposal.grades.position.weight
@ -132,7 +132,7 @@
</button>
</div>
<div id="indicateur" class="px-32 py-16 border-b flow">
<h3>Indicateur des composants impliqués</h3>
<h3>{{ t('dtl.indicators') }}</h3>
<div class="grid">
<template
v-for="indicator in activeProposal.grades.indicators"
@ -170,8 +170,8 @@
>
{{
page.hasOptimizationRequest
? "Demande d'expertise en cours de traitement…"
: 'Demander une expertise doptimisation'
? t('dtl.requestPending')
: t('dtl.requestOptimization')
}}
</button>
</footer>
@ -190,6 +190,7 @@ import { storeToRefs } from 'pinia';
import { ref, onBeforeUnmount, computed } from 'vue';
import { useDialogStore } from '../../stores/dialog';
import { usePageStore } from '../../stores/page';
import { useI18n } from 'vue-i18n';
const { proposals } = defineProps({
proposals: Array,
@ -197,6 +198,7 @@ const { proposals } = defineProps({
const { page } = storeToRefs(usePageStore());
const { openedFile, activeTracks } = storeToRefs(useDialogStore());
const { t } = useI18n();
const isDialogOpen = computed(() => {
if (openedFile.value) {
@ -215,6 +217,17 @@ const emits = defineEmits(['close']);
const activeProposal =
proposals.length === 1 ? computed(() => proposals[0]) : ref(proposals[0]);
const stepLabel = computed(() => {
const proposal = activeProposal.value || activeProposal;
const location = proposal.location;
if (location.step === 'virtualSample') {
return location.type === 'dynamic' ? t('dtl.dynamicTrack') : t('dtl.staticTrack');
}
return t('steps.' + location.step);
});
window.addEventListener('keyup', closeOnEscape);
window.addEventListener('click', close);

View file

@ -5,15 +5,15 @@
modal
:draggable="false"
:dismissableMask="true"
header="Demander un rendez-vous"
:header="t('dialogs.requestMeeting')"
class="dialog"
:closeOnEscape="true"
@click="preventClose($event)"
>
<template #header>
<h2 class="font-serif text-lg">Demander un rendez-vous</h2>
<h2 class="font-serif text-lg">{{ t('dialogs.requestMeeting') }}</h2>
<p class="flex justify-center text-sm" data-icon="leaf">
Design to Light
{{ t('dtl.title') }}
</p>
</template>
@ -22,7 +22,7 @@
class="w-full h-full p-16 flex flex-col"
style="--row-gap: 1rem"
>
<label for="projects" class="sr-only">Projet</label>
<label for="projects" class="sr-only">{{ t('brief.projects') }}</label>
<select
name="projects"
id="projects"
@ -30,7 +30,7 @@
class="w-full rounded-md border border-grey-200 px-16 py-12"
required
>
<option value="">Sélectionnez le projet</option>
<option value="">{{ t('forms.selectVariation') }}</option>
<option
v-for="project in currentProjects"
:key="project.uri"
@ -41,35 +41,37 @@
</option>
</select>
<label for="appointment-subject" class="sr-only"
>Objet du rendez-vous</label
>
<label for="appointment-subject" class="sr-only">{{
t('forms.meetingSubject')
}}</label>
<input
type="text"
v-model="subject"
id="appointment-subject"
placeholder="Objet du rendez-vous"
:placeholder="t('forms.meetingSubject')"
class="w-full rounded-md border border-grey-200 px-16 py-12"
required
/>
<label for="appointment-details" class="sr-only">Détails du projet</label>
<label for="appointment-details" class="sr-only">{{
t('forms.meetingDetails')
}}</label>
<textarea
id="appointment-details"
name="details"
v-model="details"
cols="30"
rows="10"
placeholder="Décrivez votre demande…"
:placeholder="t('forms.meetingDetailsPlaceholder')"
class="w-full flex-1 rounded-md border border-grey-200 px-16 py-12"
required
></textarea>
<footer class="flex-columns w-full mt-16" style="column-gap: 0.5rem">
<button class="btn btn--black-10" @click="emits('close')">
Annuler
{{ t('buttons.cancel') }}
</button>
<button class="btn" type="submit">Soumettre</button>
<button class="btn" type="submit">{{ t('buttons.submit') }}</button>
</footer>
</form>
</Dialog>
@ -82,6 +84,9 @@ import { storeToRefs } from 'pinia';
import { useProjectsStore } from '../../stores/projects';
import { usePageStore } from '../../stores/page';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { page } = storeToRefs(usePageStore());
const { currentProjects } = storeToRefs(useProjectsStore());

View file

@ -3,7 +3,7 @@
<!-- Favorite button -->
<button
class="favorite"
:aria-label="isFavorite ? 'Retirer des favoris' : 'Ajouter aux favoris'"
:aria-label="isFavorite ? t('inspirations.removeFromFavorites') : t('inspirations.addToFavorites')"
:aria-pressed="isFavorite"
@click="toggleFavorite"
>
@ -34,6 +34,9 @@
import { computed } from "vue";
import { useUserStore } from "../../stores/user";
import { useApiStore } from "../../stores/api";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
// Props
const { item, inspirationUri } = defineProps({
@ -70,7 +73,7 @@ async function toggleFavorite() {
// Update item favorite users list based on API response
item.favoriteForUsers = newFavoriteUsers;
} catch (error) {
console.error("Failed to toggle favorite:", error);
console.error(t('errors.toggleFavoriteFailed'), error);
isFavorite.value = previousState; // Rollback on failure
}
}

View file

@ -10,12 +10,12 @@
<strong
class="notification__type | font-medium text-primary"
data-icon="calendar"
>Demande de rendez-vous</strong
>{{ t('notifications.meetingRequest') }}</strong
>
<span class="notification__client | text-grey-700"
>{{ notification.project.title }}
{{
notification.project.status === "draft" ? "(brouillon)" : ""
notification.project.status === "draft" ? t('notifications.draft') : ""
}}</span
>
<time
@ -29,7 +29,7 @@
v-if="notification.type"
class="notification__body | text-md font-medium | line-clamp"
v-html="
'Auteur : ' +
t('notifications.author') + ' ' +
(notification.author.name
? notification.author.name + ' (' + notification.author.email + ')'
: notification.author.email) +
@ -44,6 +44,9 @@
import { useNotificationsStore } from "../../stores/notifications";
import { useUserStore } from "../../stores/user";
import { useApiStore } from "../../stores/api";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { notification } = defineProps({ notification: Object });
const { formatDate } = useNotificationsStore();

View file

@ -3,14 +3,14 @@
class="notification | bg-white rounded-lg | p-16 | flow"
data-type="content"
@click="readNotification()"
title="Aller au contenu"
:title="t('notifications.goToContent')"
>
<header>
<p class="flex">
<strong
class="notification__type | font-medium text-primary"
data-icon="content"
>Contenu</strong
>{{ t('notifications.content') }}</strong
>
<span class="notification__client | text-grey-700">{{
notification.project.title
@ -36,6 +36,9 @@ import { useRouter } from "vue-router";
import { useNotificationsStore } from "../../stores/notifications";
import { useUserStore } from "../../stores/user";
import { useApiStore } from "../../stores/api";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const router = useRouter();
const { notification } = defineProps({ notification: Object });

View file

@ -10,12 +10,12 @@
<strong
class="notification__type | font-medium text-primary"
data-icon="document"
>Demande de création de projet</strong
>{{ t('notifications.projectRequest') }}</strong
>
<span class="notification__client | text-grey-700"
>{{ notification.project.title }}
{{
notification.project.status === "draft" ? "(brouillon)" : ""
notification.project.status === "draft" ? t('notifications.draft') : ""
}}</span
>
<time
@ -29,7 +29,7 @@
v-if="notification.type"
class="notification__body | text-md font-medium | line-clamp"
v-html="
'De la part de ' +
t('notifications.from') + ' ' +
(notification.author.name
? notification.author.name + ' (' + notification.author.email + ')'
: notification.author.email) +
@ -44,6 +44,9 @@
import { useNotificationsStore } from "../../stores/notifications";
import { useUserStore } from "../../stores/user";
import { useApiStore } from "../../stores/api";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { notification } = defineProps({ notification: Object });
const { formatDate } = useNotificationsStore();

View file

@ -14,9 +14,9 @@
data-icon="comment"
@click="isCommentsOpen = !isCommentsOpen"
>
<span class="sr-only"
>{{ isCommentsOpen ? 'Masquer' : 'Afficher' }} les commentaires</span
>
<span class="sr-only">{{
isCommentsOpen ? t('buttons.hideComments') : t('buttons.showComments')
}}</span>
</button>
<a
id="download-pdf"
@ -25,7 +25,7 @@
:href="openedFile.url"
download
>
<span class="sr-only">Télécharger le fichier PDF</span>
<span class="sr-only">{{ t('buttons.downloadPdf') }}</span>
</a>
<Comments v-if="isCommentsOpen" />
</template>
@ -36,6 +36,9 @@ import { ref, watch, computed, unref } from 'vue';
import { useDialogStore } from '../../stores/dialog';
import { storeToRefs } from 'pinia';
import { VPdfViewer, useLicense } from '@vue-pdf-viewer/viewer';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const licenseKey =
import.meta.env.VITE_VPV_LICENSE ??

View file

@ -31,7 +31,7 @@
:data-status="setStatus(project.steps, project.currentStep, step)"
>
<span class="pill" :data-icon="step.id">
<span>{{ step.label }}</span>
<span>{{ t('steps.' + step.id) }}</span>
</span>
</li>
</ol>
@ -42,6 +42,7 @@
import dayjs from 'dayjs';
import 'dayjs/locale/fr';
import { useProjectStore } from '../../stores/project';
import { useI18n } from 'vue-i18n';
dayjs.locale('fr');
@ -52,4 +53,5 @@ const frenchFormattedModified = dayjs(project.modified).format(
);
const { stepsLabels, setStatus, isEmptyBrief } = useProjectStore();
const { t } = useI18n();
</script>

View file

@ -5,7 +5,7 @@
:data-status="setStatus(page.steps, page.content.currentstep, step)"
>
<h2 :id="step.id">
<span :data-icon="step.id">{{ step.label }}</span>
<span :data-icon="step.id">{{ t('steps.' + step.id) }}</span>
</h2>
<div
ref="cards-node"
@ -29,6 +29,7 @@ import SimpleDocument from './cards/SimpleDocument.vue';
import VirtualSample from './cards/VirtualSample.vue';
import PhysicalSample from './cards/PhysicalSample.vue';
import { useUserStore } from '../../stores/user';
import { useI18n } from 'vue-i18n';
const { step } = defineProps({
step: Object,
@ -49,6 +50,7 @@ const { page } = usePageStore();
const { setStatus } = useProjectStore();
const cardsNode = useTemplateRef('cards-node');
const { user } = useUserStore();
const { t } = useI18n();
// Hooks
onMounted(() => {

View file

@ -5,7 +5,7 @@
modal
:draggable="false"
:dismissableMask="true"
header="Titre du PDF"
:header="t('dialogs.pdfTitle')"
class="dialog"
:class="[
{ 'with-comments': isCommentsOpen },
@ -22,7 +22,7 @@
class="btn"
@click="validate()"
>
Valider et envoyer le brief
{{ t('buttons.validate') }}
</button>
<h2
v-if="openedFile"
@ -52,7 +52,9 @@ import { useRoute, useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
import { useApiStore } from '../../stores/api';
import { usePageStore } from '../../stores/page';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { openedFile, isCommentsOpen } = storeToRefs(useDialogStore());
const router = useRouter();

View file

@ -6,12 +6,12 @@
<label
for="project-description"
class="flex | text-sm text-grey-700 | mb-8"
>Description du projet</label
>{{ t('forms.description') }}</label
>
<textarea
name="project-description"
id="project-description"
placeholder="Ajoutez une description générale de votre projet…"
:placeholder="t('forms.descriptionPlaceholder')"
rows="2"
class="border border-grey-200 | rounded-xl | p-16 | w-full"
v-model="page.content.description"
@ -19,7 +19,7 @@
></textarea>
</div>
<fieldset class="project-details__filters | flex-1">
<legend class="text-sm text-grey-700 | mb-8">Filtrer par tags</legend>
<legend class="text-sm text-grey-700 | mb-8">{{ t('forms.filterByTags') }}</legend>
<div class="flex" style="gap: var(--space-8)">
<button
class="btn btn--sm btn--grey"
@ -28,7 +28,7 @@
role="switch"
@click="clearTags()"
>
Voir tout
{{ t('buttons.seeAll') }}
</button>
<template v-for="tag in page.tags" :key="tag">
<input
@ -53,6 +53,9 @@ import { ref, watch } from "vue";
import { usePageStore } from "../../../stores/page";
import StringUtils from "../../../utils/string";
import debounce from "lodash/debounce";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { selectedTags } = defineProps({
selectedTags: Array,
@ -93,7 +96,7 @@ const saveDescription = debounce(() => {
console.log(json);
})
.catch((error) => {
console.error("Erreur lors de la sauvegarde :", error);
console.error(t('errors.saveFailed'), error);
isWaitingForSave.value = false;
});
}

View file

@ -3,7 +3,7 @@
v-model:visible="isOpen"
id="image-details"
modal
header="Détails de limage"
:header="t('dialogs.imageDetails')"
class="bg-white | text-grey-800 | rounded-2xl | overflow-hidden"
>
<picture :style="'--image: url('+image.url+')'">
@ -11,7 +11,7 @@
</picture>
<div class="flex flex-col | p-32" style="--row-gap: var(--space-32)">
<fieldset class="image__tags">
<legend class="text-sm text-grey-700 | mb-8">Tags</legend>
<legend class="text-sm text-grey-700 | mb-8">{{ t('forms.tags') }}</legend>
<div class="flex" style="gap: var(--space-8)">
<template v-for="(pageTag, index) in page.tags" :key="index">
<input
@ -33,12 +33,12 @@
<label
for="image-description"
class="flex | text-sm text-grey-700 | mb-8"
>Description de limage</label
>{{ t('forms.imageDescription') }}</label
>
<textarea
name="image-description"
id="image-description"
placeholder="Ajoutez une description à limage…"
:placeholder="t('forms.imageDescriptionPlaceholder')"
class="border border-grey-200 | rounded-xl | p-16 | w-full"
v-model="image.description"
@input="saveDescription()"
@ -49,7 +49,7 @@
class="btn btn--black-10 | ml-auto mt-auto"
@click="remove()"
>
Supprimer cette image
{{ t('buttons.deleteImage') }}
</button>
</div>
</Dialog>
@ -61,6 +61,9 @@ import { usePageStore } from "../../../stores/page";
import StringUtils from "../../../utils/string";
import Dialog from "primevue/dialog";
import debounce from "lodash/debounce";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { imageDetails } = defineProps({
imageDetails: Object,
@ -97,7 +100,7 @@ function saveTags() {
console.log(json);
})
.catch((error) => {
console.error("Erreur lors de la sauvegarde :", error);
console.error(t('errors.saveFailed'), error);
});
}
@ -122,7 +125,7 @@ const saveDescription = debounce(() => {
emit("");
})
.catch((error) => {
console.error("Erreur lors de la sauvegarde :", error);
console.error(t('errors.saveFailed'), error);
});
}, 1000);
@ -142,7 +145,7 @@ function remove() {
isOpen.value = false;
})
.catch((error) => {
console.error("Erreur lors de la suppression :", error);
console.error(t('errors.deleteFailed'), error);
});
}
</script>

View file

@ -5,7 +5,7 @@
:draggable="false"
:dismissableMask="true"
modal
header="Ajouter des images"
:header="t('dialogs.addImages')"
class="bg-white | text-grey-800 | rounded-2xl | overflow-hidden | p-32"
>
<div class="with-sidebar | h-full">
@ -56,19 +56,18 @@
id="delete-image"
v-model:visible="deleteIsOpen"
modal
header="Êtes-vous sûr de vouloir supprimer cette image ?"
:header="t('dialogs.deleteConfirm')"
class="bg-white | text-grey-800 | rounded-2xl | overflow-hidden | text-center | w-full max-w | p-16 pt-32"
style="--row-gap: var(--space-32); --max-w: 40rem"
>
<p class="text-grey-700 | px-16">
Si vous supprimez cette image, celle-ci disparaîtra de votre brief ainsi
que toutes les informations qui lui sont attribuées.
{{ t('dialogs.deleteWarning') }}
</p>
<template #footer>
<button class="btn btn--secondary | flex-1" @click="deleteIsOpen = false">
Annuler
{{ t('buttons.cancel') }}
</button>
<button class="btn | flex-1" @click="">Supprimer</button>
<button class="btn | flex-1" @click="">{{ t('buttons.delete') }}</button>
</template>
</Dialog>
</template>
@ -79,6 +78,9 @@ import ImagesEditPanel from './ImagesEditPanel.vue';
import { ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useAddImagesModalStore } from '../../../../stores/addImagesModal';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { isAddImagesModalOpen } = defineProps({
isAddImagesModalOpen: Boolean,

View file

@ -8,8 +8,8 @@
:multiple="true"
accept="image/*"
:maxFileSize="1000000"
invalidFileSizeMessage="Fichier trop lourd"
chooseLabel="Ajouter une ou plusieurs images"
:invalidFileSizeMessage="t('errors.saveFailed')"
:chooseLabel="t('forms.addImages')"
class="flex flex-col justify-center | bg-white | border border-grey-200 | text-grey-800 | font-medium | rounded-xl"
ref="uploadBtn"
>
@ -38,7 +38,7 @@
removeFileCallback,
}"
>
<div v-if="files.length > 0">Fichiers importés</div>
<div v-if="files.length > 0">{{ t('forms.uploadedFiles') }}</div>
</template>
</FileUpload>
<figure
@ -71,6 +71,9 @@ import { storeToRefs } from "pinia";
import { computed, ref } from "vue";
import { useAddImagesModalStore } from "../../../../stores/addImagesModal";
import ArrayUtils from "../../../../utils/array";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { page } = storeToRefs(usePageStore());
const toast = useToast();

View file

@ -3,7 +3,7 @@
v-if="images.length > 0"
:step="step"
:images="images"
:uri="'/' + step.uri"
:uri="addLocalePrefix(step.uri)"
/>
<Document v-if="pdf" :step="step" :pdf="pdf" />
@ -12,11 +12,11 @@
class="btn | w-full"
@click="goToImagesBrief()"
>
Ajouter un brief via la plateforme
{{ t('brief.addPlatform') }}
</button>
<div class="btn | w-full" v-if="!pdf && step.id === 'clientBrief'">
<label for="upload-pdf">
Ajouter un brief PDF
{{ t('brief.addPdf') }}
<input
id="upload-pdf"
type="file"
@ -35,10 +35,13 @@ import Images from "./Images.vue";
import Document from "./Document.vue";
import { useBriefStore } from "../../../stores/brief";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { addLocalePrefix } from "../../../utils/router";
const { step } = defineProps({ step: Object });
const { addPdf } = useBriefStore();
const router = useRouter();
const { t } = useI18n();
const images = computed(() => {
return step.files.filter((file) => file.type === "image");

View file

@ -25,7 +25,7 @@
v-if="pdf.comments?.length > 0"
class="order-last | text-sm text-primary font-medium"
>
<router-link :to="'/' + step.uri + '&comments=true'">
<router-link :to="addLocalePrefix(step.uri) + '&comments=true'">
{{ pdf.comments.length }} commentaire{{
pdf.comments.length > 1 ? "s" : ""
}}
@ -39,6 +39,7 @@ import { useRoute } from "vue-router";
import DateTime from "./DateTime.vue";
import { computed } from "vue";
import { useDesignToLightStore } from "../../../stores/designToLight";
import { addLocalePrefix } from "../../../utils/router";
const { step, pdf, index } = defineProps({
step: Object,

View file

@ -2,7 +2,7 @@
<article class="card">
<hgroup class="order-last">
<h3 class="card__title | font-serif | text-lg">
<router-link :to="uri" class="link-full">{{ step.label }}</router-link>
<router-link :to="uri" class="link-full">{{ t('steps.' + step.id) }}</router-link>
</h3>
</hgroup>
<DateTime :date="step.modified" />
@ -42,6 +42,7 @@ import DateTime from './DateTime.vue';
import { useDesignToLightStore } from '../../../stores/designToLight';
import { useVirtualSampleStore } from '../../../stores/virtualSample';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { images, step, uri } = defineProps({
images: Array,
@ -51,6 +52,7 @@ const { images, step, uri } = defineProps({
const { isDesignToLightStep } = useDesignToLightStore();
const { allVariations } = useVirtualSampleStore();
const { t } = useI18n();
const commentsCount = computed(() => {
let count = 0;

View file

@ -5,7 +5,7 @@
:style="'--cover: url(' + step.cover + ')'"
>
<h3 class="text-lg font-serif">
<router-link :to="'/' + step.uri" class="link-full">{{
<router-link :to="addLocalePrefix(step.uri)" class="link-full">{{
step.title
}}</router-link>
</h3>
@ -27,6 +27,7 @@
<script setup>
import dayjs from "dayjs";
import "dayjs/locale/fr";
import { addLocalePrefix } from "../../../utils/router";
const { step } = defineProps({ step: Object });
</script>

View file

@ -11,6 +11,7 @@ import Images from './Images.vue';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useVirtualSampleStore } from '../../../stores/virtualSample';
import { addLocalePrefix } from '../../../utils/router';
const { step } = defineProps({ step: Object });
@ -27,7 +28,7 @@ const images = computed(() => {
return allVariations.value.map((variation) => getFrontView(variation)) ?? [];
});
const uri = '/' + step.uri;
const uri = addLocalePrefix(step.uri);
function getFrontView(variation) {
if (variation.files.length === 1) return variation.files[0];

View file

@ -19,8 +19,8 @@
>
<span>{{
isCompareModeEnabled
? 'Quitter le mode comparer'
: 'Comparer les pistes'
? t('buttons.exitCompare')
: t('buttons.compareTracks')
}}</span>
</button>
</header>
@ -41,7 +41,7 @@
:backgroundColor="activeTrack.backgroundColor"
/>
<div v-else class="track-empty | bg-white rounded-xl w-full p-32">
<p>Contenu non disponible pour cette piste</p>
<p>{{ t('virtualSample.noContent') }}</p>
</div>
</template>
@ -49,7 +49,7 @@
v-if="isCompareModeEnabled && activeTracks.length < 2"
class="track-empty | bg-white rounded-xl w-full p-32"
>
<p>Sélectionnez sur la piste que vous souhaitez comparer</p>
<p>{{ t('virtualSample.selectToCompare') }}</p>
</div>
</div>
</div>
@ -62,11 +62,14 @@ import { usePageStore } from '../../../stores/page';
import { useDialogStore } from '../../../stores/dialog';
import { useVirtualSampleStore } from '../../../stores/virtualSample';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import Interactive360 from './Interactive360.vue';
import SingleImage from './SingleImage.vue';
import Selector from '../../Selector.vue';
import slugify from 'slugify';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();

View file

@ -5,7 +5,7 @@
modal
:draggable="false"
:dismissableMask="true"
header="Titre du rendu"
:header="t('dialogs.renderTitle')"
class="dialog"
:class="[
{ 'with-comments': isCommentsOpen },
@ -26,7 +26,7 @@
aria-controls="dynamic"
@click="activeTab = 'dynamic'"
>
<span>Présentation dynamique</span>
<span>{{ t('virtualSample.dynamicPresentation') }}</span>
</button>
<button
v-if="step.files.static"
@ -38,10 +38,10 @@
:aria-pressed="activeTab === 'static' ? true : false"
aria-controls="static"
>
<span>Vue statique</span>
<span>{{ t('virtualSample.staticView') }}</span>
</button>
</div>
<h2 class="font-serif text-lg">Échantillon virtuel</h2>
<h2 class="font-serif text-lg">{{ t('virtualSample.title') }}</h2>
</template>
<DynamicView id="dynamic" v-if="activeTab === 'dynamic'" />
@ -66,8 +66,8 @@
>
<span>{{
!isLoopAnimationEnabled
? 'Animation en boucle'
: 'Arrêter lanimation'
? t('buttons.loopAnimation')
: t('buttons.stopAnimation')
}}</span>
</button>
<button
@ -79,7 +79,7 @@
@click="isCommentsOpen = !isCommentsOpen"
>
<span class="sr-only"
>{{ isCommentsOpen ? 'Masquer' : 'Afficher' }} les commentaires</span
>{{ isCommentsOpen ? t('buttons.hideComments') : t('buttons.showComments') }}</span
>
</button>
</template>
@ -103,7 +103,9 @@ import { useVirtualSampleStore } from '../../../stores/virtualSample';
import { useDialogStore } from '../../../stores/dialog';
import { useRoute, useRouter } from 'vue-router';
import { usePageStore } from '../../../stores/page';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { file } = defineProps({
file: Object,
});
@ -141,12 +143,12 @@ watch(isOpen, (newValue) => {
const downloadText = computed(() => {
if (activeTab.value === 'dynamic') {
if (activeTracks.value.length === 1) {
return "Télécharger l'image";
return t('buttons.downloadImage');
} else {
return 'Télécharger les images';
return t('buttons.downloadImages');
}
} else {
return 'Télécharger le PDF';
return t('buttons.downloadPdf');
}
});