feat(review,teams): review foundation — segments, side-by-side editor, rebuild, XLIFF, team workspaces
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 2m14s

Foundations:
- TranslationSegment model + migration f7e8d9c0b1a2 (segments, workspaces,
  workspace_members, glossaries.workspace_id)
- SegmentRecorder injected into all 4 translators: unique (source,
  translation) pairs captured per job and persisted (best-effort)
- set_segment_overrides: human-reviewed translations applied verbatim on
  rebuild — top priority over TM and provider, zero API calls

Review API (routes/review_routes.py):
- GET /translations/{id}/segments (owner or job token)
- PATCH /segments/{id} edit/approve — feeds the per-user TM so approved
  translations are reused in later jobs
- POST /translations/{id}/rebuild — rebuild document with reviewed text
- GET/POST /translations/{id}/xliff — XLIFF 1.2 export/import (edited
  segments export their reviewed text)

Review editor (frontend /dashboard/reviews/[jobId]):
- side-by-side source/translation table, inline edit, approve (single or
  all), rebuild & download (auth blob), XLIFF export/import, 13 locales
- 'Relire et corriger' link on the translation-complete screen

Team workspaces (routes/workspace_routes.py + /dashboard/teams):
- Workspace/WorkspaceMember models, roles owner/admin/member
- create (Business plan), list with seat usage, invite by email with
  seat-limit enforcement (Business=5, Enterprise unlimited), removal
- shared glossaries: workspace members can use a glossary shared to their
  workspace (access check extended)

Tests: 1184 passed / 0 failed (11 new: recorder, overrides, docx
capture->rebuild e2e, XLIFF structure/escaping, seats, workspace CRUD,
shared glossary access)
This commit is contained in:
2026-08-29 19:04:32 +02:00
parent 526c87348f
commit b4e873ad2c
31 changed files with 2399 additions and 54 deletions

View File

@@ -1,4 +1,4 @@
import { FileText, BookText, User, type LucideIcon } from 'lucide-react';
import { FileText, BookText, User, Users, type LucideIcon } from 'lucide-react';
export interface NavItem {
labelKey: string;
@@ -9,8 +9,9 @@ export interface NavItem {
export const baseNavItems: NavItem[] = [
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
{ labelKey: 'dashboard.nav.glossaries', href: '/dashboard/glossaries', icon: BookText, proOnly: true },
{ labelKey: 'dashboard.nav.teams', href: '/dashboard/teams', icon: Users, proOnly: true },
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
// API Keys nav item temporarily removed per request — uncomment to restore.
// { labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true },
];

View File

@@ -0,0 +1,400 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'next/navigation';
import {
Check,
CheckCheck,
Download,
FileDown,
FileUp,
Loader2,
Pencil,
Undo2,
} from 'lucide-react';
import { apiClient, API_BASE_URL } from '@/lib/apiClient';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/toast';
import { cn } from '@/lib/utils';
interface Segment {
id: string;
segment_index: number;
source_text: string;
translated_text: string;
status: 'pending' | 'approved' | 'edited';
reviewed_text: string | null;
}
interface SegmentsResponse {
data: {
job_id: string;
file_name: string | null;
status: string;
segments: Segment[];
counts: { total: number; pending: number; approved: number; edited: number };
};
}
interface RebuildResponse {
data: { job_id: string; rebuilt: boolean; segments_applied: number; download_url: string };
}
function authHeaders(): Record<string, string> {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
async function downloadProtected(url: string, filename: string) {
const res = await fetch(`${API_BASE_URL}${url}`, { headers: authHeaders() });
if (!res.ok) throw new Error(`Téléchargement échoué (HTTP ${res.status})`);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
}
export default function ReviewPage() {
const params = useParams<{ jobId: string }>();
const jobId = params?.jobId ?? '';
const notify = useToast();
const [segments, setSegments] = useState<Segment[]>([]);
const [fileName, setFileName] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState('');
const [savingId, setSavingId] = useState<string | null>(null);
const [isRebuilding, setIsRebuilding] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
setIsLoading(true);
setLoadError(null);
try {
const res = await apiClient.get<SegmentsResponse>(
`/api/v1/translations/${jobId}/segments`
);
setSegments(res.data.segments);
setFileName(res.data.file_name);
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Erreur de chargement');
} finally {
setIsLoading(false);
}
}, [jobId]);
useEffect(() => {
if (jobId) load();
}, [jobId, load]);
const counts = useMemo(() => {
const pending = segments.filter((s) => s.status === 'pending').length;
const approved = segments.filter((s) => s.status === 'approved').length;
const edited = segments.filter((s) => s.status === 'edited').length;
return { total: segments.length, pending, approved, edited };
}, [segments]);
const patchSegment = async (
id: string,
body: { reviewed_text?: string; status?: string }
) => {
setSavingId(id);
try {
await apiClient.patch(`/api/v1/segments/${id}`, body);
setSegments((prev) =>
prev.map((s) =>
s.id === id
? {
...s,
reviewed_text: body.reviewed_text ?? s.reviewed_text,
status: (body.status ?? s.status) as Segment['status'],
}
: s
)
);
} catch (err) {
notify.error({
title: 'Erreur',
description: err instanceof Error ? err.message : 'Mise à jour impossible',
});
} finally {
setSavingId(null);
}
};
const approveAll = async () => {
const pending = segments.filter((s) => s.status === 'pending');
for (const seg of pending) {
// sequential is fine — server-side each is a tiny PATCH
await patchSegment(seg.id, { status: 'approved' });
}
notify.success({
title: 'Segments approuvés',
description: `${pending.length} segment(s) approuvé(s).`,
});
};
const rebuild = async () => {
setIsRebuilding(true);
try {
const res = await apiClient.post<RebuildResponse>(
`/api/v1/translations/${jobId}/rebuild`
);
await downloadProtected(
res.data.download_url,
`relu_${fileName ?? jobId}`
);
notify.success({
title: 'Document reconstruit',
description: `${res.data.segments_applied} segment(s) relu(s) appliqué(s).`,
});
} catch (err) {
notify.error({
title: 'Reconstruction impossible',
description: err instanceof Error ? err.message : undefined,
});
} finally {
setIsRebuilding(false);
}
};
const exportXliff = async () => {
try {
await downloadProtected(
`/api/v1/translations/${jobId}/xliff`,
`${jobId}.xliff`
);
} catch (err) {
notify.error({
title: 'Export XLIFF échoué',
description: err instanceof Error ? err.message : undefined,
});
}
};
const importXliff = async (file: File) => {
setIsImporting(true);
try {
const text = await file.text();
const res = await fetch(
`${API_BASE_URL}/api/v1/translations/${jobId}/xliff`,
{
method: 'POST',
headers: { ...authHeaders() },
body: text,
}
);
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(
json?.detail?.message || json?.message || `HTTP ${res.status}`
);
}
const json = await res.json();
notify.success({
title: 'XLIFF importé',
description: `${json.data.segments_updated} segment(s) mis à jour.`,
});
await load();
} catch (err) {
notify.error({
title: 'Import XLIFF échoué',
description: err instanceof Error ? err.message : undefined,
});
} finally {
setIsImporting(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const statusBadge = (status: Segment['status']) => {
if (status === 'approved')
return <Badge className="bg-green-500/15 text-green-600 border-green-500/30">Approuvé</Badge>;
if (status === 'edited')
return <Badge className="bg-blue-500/15 text-blue-600 border-blue-500/30">Modifié</Badge>;
return <Badge variant="outline" className="text-muted-foreground">À relire</Badge>;
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground">Relecture</h1>
<p className="text-sm text-muted-foreground truncate max-w-xl">
{fileName ?? jobId} {counts.total} segments ({counts.pending} à
relire, {counts.approved} approuvés, {counts.edited} modifiés)
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={approveAll} disabled={counts.pending === 0}>
<CheckCheck className="size-3.5" /> Tout approuver
</Button>
<Button variant="outline" size="sm" onClick={exportXliff} disabled={!segments.length}>
<FileDown className="size-3.5" /> XLIFF
</Button>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
>
{isImporting ? <Loader2 className="size-3.5 animate-spin" /> : <FileUp className="size-3.5" />}
Importer XLIFF
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xliff,.xlf,application/xliff+xml"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) importXliff(f);
}}
/>
<Button size="sm" onClick={rebuild} disabled={isRebuilding || counts.approved + counts.edited === 0}>
{isRebuilding ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
Reconstruire et télécharger
</Button>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-sm text-red-500">
{loadError}
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="w-1/2 px-4 py-2.5 font-medium">Source</th>
<th className="w-1/2 px-4 py-2.5 font-medium">Traduction</th>
<th className="w-40 px-4 py-2.5 font-medium">Statut</th>
</tr>
</thead>
<tbody>
{segments.map((seg) => {
const isEditing = editingId === seg.id;
const finalText = seg.status === 'edited' && seg.reviewed_text
? seg.reviewed_text
: seg.translated_text;
return (
<tr key={seg.id} className="border-t border-border align-top">
<td className="px-4 py-3 text-muted-foreground">
{seg.source_text}
</td>
<td className="px-4 py-3">
{isEditing ? (
<div className="space-y-2">
<textarea
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
<div className="flex gap-2">
<Button
size="sm"
disabled={savingId === seg.id || !draft.trim()}
onClick={async () => {
await patchSegment(seg.id, {
reviewed_text: draft,
status: 'edited',
});
setEditingId(null);
}}
>
{savingId === seg.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
Enregistrer
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingId(null)}
>
Annuler
</Button>
</div>
</div>
) : (
<div
className={cn(
'whitespace-pre-wrap',
seg.status === 'edited' && 'text-blue-600 dark:text-blue-400'
)}
>
{finalText || <span className="italic text-muted-foreground"> vide </span>}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-col items-start gap-2">
{statusBadge(seg.status)}
{!isEditing && (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
title="Modifier la traduction"
onClick={() => {
setEditingId(seg.id);
setDraft(finalText);
}}
>
<Pencil className="size-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
title="Approuver tel quel"
disabled={seg.status === 'approved' || savingId === seg.id}
onClick={() => patchSegment(seg.id, { status: 'approved' })}
>
<Check className="size-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
title="Remettre à relire"
disabled={seg.status === 'pending'}
onClick={() => patchSegment(seg.id, { status: 'pending' })}
>
<Undo2 className="size-3" />
</Button>
</div>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,267 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Users, Plus, UserPlus, Trash2, Loader2, Crown } from 'lucide-react';
import { apiClient } from '@/lib/apiClient';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/toast';
interface WorkspaceMember {
id: string;
user_id: string;
role: string;
created_at: string | null;
}
interface Workspace {
id: string;
name: string;
owner_id: string;
member_count: number;
members: WorkspaceMember[];
my_role?: string;
seat_limit?: number; // -1 = illimité
}
export default function TeamsPage() {
const notify = useToast();
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [needsBusiness, setNeedsBusiness] = useState(false);
const [newName, setNewName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [inviteEmail, setInviteEmail] = useState<Record<string, string>>({});
const [inviteRole, setInviteRole] = useState<Record<string, string>>({});
const [busyWs, setBusyWs] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
try {
const res = await apiClient.get<{ data: Workspace[] }>('/api/v1/workspaces');
setWorkspaces(res.data);
setNeedsBusiness(false);
} catch (err) {
const message = err instanceof Error ? err.message : '';
if (message.includes('Business') || message.includes('PLAN_REQUIRED')) {
setNeedsBusiness(true);
} else {
notify.error({ title: 'Erreur', description: message || 'Chargement impossible' });
}
} finally {
setIsLoading(false);
}
}, [notify]);
useEffect(() => {
load();
}, [load]);
const createWorkspace = async () => {
if (!newName.trim()) return;
setIsCreating(true);
try {
await apiClient.post('/api/v1/workspaces', { name: newName.trim() });
setNewName('');
notify.success({ title: 'Espace créé', description: 'Vous êtes propriétaire.' });
await load();
} catch (err) {
notify.error({
title: 'Création impossible',
description: err instanceof Error ? err.message : undefined,
});
} finally {
setIsCreating(false);
}
};
const inviteMember = async (wsId: string) => {
const email = (inviteEmail[wsId] || '').trim();
if (!email) return;
setBusyWs(wsId);
try {
await apiClient.post(`/api/v1/workspaces/${wsId}/members`, {
email,
role: inviteRole[wsId] || 'member',
});
setInviteEmail((p) => ({ ...p, [wsId]: '' }));
notify.success({ title: 'Membre ajouté', description: email });
await load();
} catch (err) {
notify.error({
title: 'Ajout impossible',
description: err instanceof Error ? err.message : undefined,
});
} finally {
setBusyWs(null);
}
};
const removeMember = async (wsId: string, userId: string) => {
setBusyWs(wsId);
try {
await apiClient.delete(`/api/v1/workspaces/${wsId}/members/${userId}`);
await load();
} catch (err) {
notify.error({
title: 'Retrait impossible',
description: err instanceof Error ? err.message : undefined,
});
} finally {
setBusyWs(null);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-blue-600/20">
<Users className="size-5 text-blue-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Espaces de travail</h1>
<p className="text-sm text-muted-foreground">
Partagez vos glossaires avec votre équipe (plan Business 5 sièges).
</p>
</div>
</div>
</div>
{needsBusiness ? (
<div className="rounded-lg border border-border bg-card p-6 text-center">
<p className="text-sm text-muted-foreground">
Les espaces de travail nécessitent le plan Business. Passez au plan
supérieur pour collaborer avec votre équipe.
</p>
</div>
) : (
<>
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-border bg-card p-4">
<div className="flex-1 min-w-56 space-y-1.5">
<Label htmlFor="ws-name">Nouvel espace de travail</Label>
<Input
id="ws-name"
placeholder="Ex. Équipe traduction"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
<Button onClick={createWorkspace} disabled={isCreating || !newName.trim()}>
{isCreating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
Créer
</Button>
</div>
<div className="space-y-4">
{workspaces.map((ws) => (
<div key={ws.id} className="rounded-lg border border-border bg-card p-4 space-y-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{ws.name}</span>
{ws.my_role === 'owner' && (
<Badge className="gap-1 bg-amber-500/15 text-amber-600 border-amber-500/30">
<Crown className="size-3" /> Propriétaire
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{ws.member_count}
{ws.seat_limit === -1 ? ' sièges (illimité)' : ` / ${ws.seat_limit ?? '?'} sièges`}
</span>
</div>
<ul className="divide-y divide-border rounded-md border border-border">
{ws.members.map((m) => (
<li key={m.id} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="flex items-center gap-2">
<span className="text-muted-foreground">{m.user_id.slice(0, 8)}</span>
<Badge variant="outline">{m.role}</Badge>
</span>
{m.role !== 'owner' && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-red-500"
disabled={busyWs === ws.id || ws.my_role === 'member'}
onClick={() => removeMember(ws.id, m.user_id)}
title="Retirer du workspace"
>
<Trash2 className="size-3.5" />
</Button>
)}
</li>
))}
</ul>
{(ws.my_role === 'owner' || ws.my_role === 'admin') && (
<div className="flex flex-wrap items-end gap-2">
<div className="flex-1 min-w-48 space-y-1.5">
<Label htmlFor={`invite-${ws.id}`}>Ajouter un membre (e-mail)</Label>
<Input
id={`invite-${ws.id}`}
type="email"
placeholder="collegue@entreprise.com"
value={inviteEmail[ws.id] || ''}
onChange={(e) =>
setInviteEmail((p) => ({ ...p, [ws.id]: e.target.value }))
}
/>
</div>
<Select
value={inviteRole[ws.id] || 'member'}
onValueChange={(v) => setInviteRole((p) => ({ ...p, [ws.id]: v }))}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Membre</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
onClick={() => inviteMember(ws.id)}
disabled={busyWs === ws.id || !(inviteEmail[ws.id] || '').trim()}
>
{busyWs === ws.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<UserPlus className="size-3.5" />
)}
Inviter
</Button>
</div>
)}
</div>
))}
{workspaces.length === 0 && (
<div className="rounded-lg border border-border bg-card p-6 text-center text-sm text-muted-foreground">
Aucun espace pour l&apos;instant créez le premier ci-dessus.
</div>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -1,9 +1,11 @@
'use client';
import Link from 'next/link';
import { useState, useEffect, useRef } from 'react';
import {
CheckCircle2, Download, Plus, Loader2, FileText,
Timer, Activity, TrendingUp,
Timer, Activity, TrendingUp, BookOpenCheck,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useNotification } from '@/components/ui/notification';
@@ -157,6 +159,18 @@ export function TranslationComplete({
)}
</Button>
<Button
variant="outline"
size="lg"
className="h-11 w-full gap-2"
asChild
>
<Link href={`/dashboard/reviews/${jobId}`}>
<BookOpenCheck className="size-4" />
Relire et corriger la traduction
</Link>
</Button>
<Button
variant="outline"
size="lg"

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "وصول مميز",
"dashboard.checkoutSyncError": "خطأ في مزامنة الدفع.",
"dashboard.networkRefresh": "خطأ في الشبكة. يرجى تحديث الصفحة.",
"dashboard.continueToTranslate": "متابعة إلى الترجمة"
"dashboard.continueToTranslate": "متابعة إلى الترجمة",
"dashboard.nav.teams": "الفريق"
}

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "Premium-Zugang",
"dashboard.checkoutSyncError": "Fehler beim Synchronisieren der Zahlung.",
"dashboard.networkRefresh": "Netzwerkfehler. Bitte aktualisieren Sie die Seite.",
"dashboard.continueToTranslate": "Weiter zur Übersetzung"
"dashboard.continueToTranslate": "Weiter zur Übersetzung",
"dashboard.nav.teams": "Team"
}

View File

@@ -111,5 +111,6 @@
"dashboard.topbar.premiumAccess": "Premium Access",
"dashboard.checkoutSyncError": "Error syncing payment.",
"dashboard.networkRefresh": "Network error. Please refresh the page.",
"dashboard.continueToTranslate": "Continue to translation"
"dashboard.continueToTranslate": "Continue to translation",
"dashboard.nav.teams": "Team"
}

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "Acceso Premium",
"dashboard.checkoutSyncError": "Error al sincronizar el pago.",
"dashboard.networkRefresh": "Error de red. Por favor, actualice la página.",
"dashboard.continueToTranslate": "Continuar a la traducción"
"dashboard.continueToTranslate": "Continuar a la traducción",
"dashboard.nav.teams": "Equipo"
}

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "دسترسی ویژه",
"dashboard.checkoutSyncError": "خطا در همگام‌سازی پرداخت.",
"dashboard.networkRefresh": "خطای شبکه. لطفاً صفحه را تازه‌سازی کنید.",
"dashboard.continueToTranslate": "ادامه به ترجمه"
"dashboard.continueToTranslate": "ادامه به ترجمه",
"dashboard.nav.teams": "تیم"
}

View File

@@ -111,5 +111,6 @@
"dashboard.topbar.premiumAccess": "Accès Premium",
"dashboard.checkoutSyncError": "Erreur lors de la synchronisation du paiement.",
"dashboard.networkRefresh": "Erreur réseau. Veuillez rafraîchir la page.",
"dashboard.continueToTranslate": "Continuer vers la traduction"
"dashboard.continueToTranslate": "Continuer vers la traduction",
"dashboard.nav.teams": "Équipe"
}

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "Accesso Premium",
"dashboard.checkoutSyncError": "Errore di sincronizzazione del pagamento.",
"dashboard.networkRefresh": "Errore di rete. Aggiorna la pagina.",
"dashboard.continueToTranslate": "Vai alla traduzione"
"dashboard.continueToTranslate": "Vai alla traduzione",
"dashboard.nav.teams": "Squadra"
}

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "プレミアムアクセス",
"dashboard.checkoutSyncError": "支払いの同期エラー。",
"dashboard.networkRefresh": "ネットワークエラー。ページを更新してください。",
"dashboard.continueToTranslate": "翻訳に進む"
"dashboard.continueToTranslate": "翻訳に進む",
"dashboard.nav.teams": "チーム"
}

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "프리미엄 액세스",
"dashboard.checkoutSyncError": "결제 동기화 오류.",
"dashboard.networkRefresh": "네트워크 오류. 페이지를 새로 고치세요.",
"dashboard.continueToTranslate": "번역으로 계속"
"dashboard.continueToTranslate": "번역으로 계속",
"dashboard.nav.teams": "팀"
}

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "Premium-toegang",
"dashboard.checkoutSyncError": "Fout bij synchroniseren van betaling.",
"dashboard.networkRefresh": "Netwerkfout. Vernieuw de pagina.",
"dashboard.continueToTranslate": "Doorgaan naar vertaling"
"dashboard.continueToTranslate": "Doorgaan naar vertaling",
"dashboard.nav.teams": "Team"
}

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "Acesso Premium",
"dashboard.checkoutSyncError": "Erro ao sincronizar o pagamento.",
"dashboard.networkRefresh": "Erro de rede. Atualize a página.",
"dashboard.continueToTranslate": "Continuar para a tradução"
"dashboard.continueToTranslate": "Continuar para a tradução",
"dashboard.nav.teams": "Equipe"
}

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "Премиум-доступ",
"dashboard.checkoutSyncError": "Ошибка синхронизации платежа.",
"dashboard.networkRefresh": "Ошибка сети. Обновите страницу.",
"dashboard.continueToTranslate": "Перейти к переводу"
"dashboard.continueToTranslate": "Перейти к переводу",
"dashboard.nav.teams": "Команда"
}

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "高级访问",
"dashboard.checkoutSyncError": "同步付款时出错。",
"dashboard.networkRefresh": "网络错误。请刷新页面。",
"dashboard.continueToTranslate": "继续翻译"
"dashboard.continueToTranslate": "继续翻译",
"dashboard.nav.teams": "团队"
}