feat(translation): quality pipeline overhaul + new features (audit 2026-08-29)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s

Translation quality & format preservation:
- Word: merge adjacent same-format runs into one unit (sentence-level
  coherence like inline-tag handling); translate comments/balloons;
  dedupe textbox collection (was translated twice); RTL no longer
  overrides center/justify alignment; CJK/Arabic font hints (eastAsia/cs)
- PPTX: chart translations now actually reach the output file
  (ChartPart.blob is read-only — rewrite chart XML in the saved ZIP);
  CJK typeface hints (a:ea)
- Excel: sheet renames no longer break references — rewrite cell
  formulas (3D/quoted), defined names, data validations, cond. formats
- PDF: bold/italic honored (hebo/heit/hebi); table cells never merge;
  unchanged blocks left untouched (typography preserved, fixes duplicate
  hyperlinks); attempted/changed stats + route gate now cover PDF;
  CJK font paths; scanned PDFs via Mistral OCR (detection + admin settings)

Features:
- formality param (formal/informal) + automatic regional-variant prompts
- output_mode=bilingual docx (source above translation)
- per-user translation memory on Redis (falls back to LRU), context-hashed
- QA report + 0-100 confidence score in job status; L0 on by default
- OpenAI-compatible providers: whole chunk in ONE numbered-JSON request
  (~15x fewer calls) with per-item fallback; base prompt always present
  (custom prompt no longer replaces translation instructions)

Infra & marketing alignment:
- plan-based engine gating + vision gating (closes paid-engine leak);
  /providers/available filtered per plan; 107 languages exposed
- zh-CN/zh-TW validation fixed; libmagic disabled on Windows (native crash)
- admin: Mistral OCR settings + engine status dashboard; httpx<0.28 pin
  (TestClient breakage); Prometheus test fixture fixed
- marketing docs aligned with code (PDF+OCR, retention, engines, pricing)
- security: .env.ionos/.env.production/provider_settings.json removed

Tests: 1173 passed / 0 failed (6 network tests deselected: free Google
endpoint temporarily blocked from this machine)
This commit is contained in:
2026-08-29 18:38:09 +02:00
parent 992f13d53c
commit 526c87348f
87 changed files with 6996 additions and 1024 deletions

View File

@@ -0,0 +1,82 @@
"use client"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
const FAQS = [
{
q: "Does Office Translator really keep the formatting intact?",
a: "Yes. Unlike generic translators that flatten your file into plain text, we translate in place: merged cells, formulas, fonts, borders, headers, footers, tables and slide layouts are preserved. You download a document that looks like it was written in the target language from the start.",
},
{
q: "Which file formats are supported?",
a: "Excel (.xlsx), Word (.docx), PowerPoint (.pptx) and PDF (.pdf). Scanned PDFs (image-only pages) are handled too — the text is recovered via OCR before translation. We also translate text embedded in images inside your documents using vision-capable models on Pro and Business plans.",
},
{
q: "Which translation engines can I use?",
a: "Up to seven providers depending on your plan: Google (free and fast), DeepL (best for business text), Google Cloud, and LLM engines (OpenRouter in eco & premium tiers — DeepSeek, Gemini, Claude —, OpenAI, Grok by xAI). On paid plans you pick the engine per document.",
},
{
q: "How are my documents handled? Is my data safe?",
a: "Files are processed on our servers and automatically deleted: uploads after 30 minutes, translated results within 2 hours (zero-retention mode). We never train models on your content, and documents are encrypted in transit (TLS 1.2+).",
},
{
q: "What is a glossary and why would I need one?",
a: "A glossary is a set of custom term pairs (e.g. a specific HVAC or legal term and its approved translation). The engine applies your terminology consistently across every document, which is essential for technical, legal and medical content.",
},
{
q: "What happens when I hit my monthly document limit?",
a: "Translation stops with a clear message and your usage is shown in the dashboard. You can upgrade your plan or buy extra credit packs (from €0.06 per page) without changing your subscription.",
},
{
q: "Can I integrate Office Translator into my own tools?",
a: "Yes. The Business plan includes API access with 10,000 calls per month and API keys with scoped permissions. Enterprise plans offer custom call volumes. The API mirrors the web workflow: submit a file, poll the job, download the result.",
},
{
q: "Can I cancel my subscription?",
a: "Anytime, from the dashboard or the Stripe billing portal. Your plan stays active until the end of the billing period, and you can download all your translated documents before that.",
},
{
q: "Is there a free trial of paid plans?",
a: "The Free plan already gives you 2 full documents per month with no card required, so you can validate quality on your own files before upgrading. Paid plans can be cancelled at any time.",
},
{
q: "Do you offer team or agency options?",
a: "The Business plan includes 5 team seats with shared usage. For larger teams, agencies and multi-country operations, our Enterprise plan provides custom seats, custom LLM routing and dedicated support.",
},
]
export function FaqSection() {
return (
<section id="faq" className="flex flex-col items-center gap-8 px-6 py-16 md:py-24">
<div className="flex flex-col items-center gap-3 text-center">
<h2 className="text-balance text-3xl font-bold tracking-tight text-foreground md:text-4xl">
Frequently asked questions
</h2>
<p className="max-w-xl text-pretty text-sm leading-relaxed text-muted-foreground md:text-base">
Everything you need to know about translating documents with zero
formatting loss.
</p>
</div>
<div className="w-full max-w-3xl">
<Accordion type="single" collapsible className="w-full">
{FAQS.map((faq, i) => (
<AccordionItem key={i} value={`faq-${i}`}>
<AccordionTrigger className="text-sm md:text-base">
{faq.q}
</AccordionTrigger>
<AccordionContent className="text-sm leading-relaxed text-muted-foreground">
{faq.a}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</section>
)
}

View File

@@ -1,4 +1,4 @@
import { FileSpreadsheet, FileText, Presentation } from "lucide-react"
import { FileSpreadsheet, FileText, FileType, Presentation } from "lucide-react"
export function HeroSection() {
return (
@@ -13,7 +13,7 @@ export function HeroSection() {
</h1>
<p className="max-w-xl text-pretty text-base leading-relaxed text-muted-foreground md:text-lg">
Upload your Excel, Word, or PowerPoint files and get accurate translations with zero formatting loss.
Upload your Excel, Word, PowerPoint, or PDF files including scanned PDFs, recovered via OCR and get accurate translations with zero formatting loss.
</p>
<div className="flex items-center gap-6 pt-2">
@@ -29,6 +29,10 @@ export function HeroSection() {
<Presentation className="size-4" />
<span>.pptx</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileType className="size-4" />
<span>.pdf</span>
</div>
</div>
</section>
)

View File

@@ -0,0 +1,248 @@
"use client"
import { useState } from "react"
import { Check, Crown, Zap, Building2, Sparkles, ArrowRight } from "lucide-react"
import Link from "next/link"
import { track } from "@vercel/analytics"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
type BillingCycle = "monthly" | "yearly"
interface Plan {
id: string
name: string
description: string
monthly: number
yearly: number
features: string[]
cta: string
badge?: string
icon: typeof Check
featured?: boolean
}
const PLANS: Plan[] = [
{
id: "free",
name: "Free",
description: "Test the full workflow on real documents.",
monthly: 0,
yearly: 0,
features: [
"2 documents / month",
"Up to 10 pages per document",
"Up to 5 MB file size",
"Google engine",
"Watermarked output",
],
cta: "Start Free",
icon: Zap,
},
{
id: "starter",
name: "Starter",
description: "For freelancers and light professional use.",
monthly: 9,
yearly: 7.2,
features: [
"50 documents / month",
"Up to 50 pages per document",
"Up to 10 MB file size",
"Google + DeepL engines",
"No watermark",
"Priority email support",
],
cta: "Choose Starter",
icon: Sparkles,
},
{
id: "pro",
name: "Pro",
description: "For teams that translate regularly.",
monthly: 19,
yearly: 15.2,
features: [
"200 documents / month",
"Up to 200 pages per document",
"Up to 25 MB file size",
"All classic engines (Google Cloud, DeepL, OpenRouter)",
"AI translation (context-aware LLM)",
"Priority processing",
"Custom glossaries",
"No watermark",
],
cta: "Choose Pro",
badge: "Most Popular",
icon: Crown,
featured: true,
},
{
id: "business",
name: "Business",
description: "For agencies and international operations.",
monthly: 49,
yearly: 39.2,
features: [
"1,000 documents / month",
"Up to 500 pages per document",
"Up to 50 MB file size",
"All engines incl. premium LLMs (Claude, OpenAI)",
"API access (10,000 calls / month)",
"5 team seats",
"Priority processing",
"Dedicated support",
],
cta: "Choose Business",
icon: Building2,
},
]
export function PricingSection() {
const [cycle, setCycle] = useState<BillingCycle>("monthly")
return (
<section id="pricing" className="flex flex-col items-center gap-8 px-6 py-16 md:py-24">
<div className="flex flex-col items-center gap-3 text-center">
<h2 className="text-balance text-3xl font-bold tracking-tight text-foreground md:text-4xl">
Simple pricing that scales with you
</h2>
<p className="max-w-xl text-pretty text-sm leading-relaxed text-muted-foreground md:text-base">
Start free, upgrade when you need more volume, faster engines or API
access. Cancel anytime.
</p>
<div className="mt-2 flex items-center rounded-lg border border-border bg-muted p-1">
<button
type="button"
className={cn(
"rounded-md px-4 py-1.5 text-sm font-medium transition-all",
cycle === "monthly"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setCycle("monthly")}
>
Monthly
</button>
<button
type="button"
className={cn(
"rounded-md px-4 py-1.5 text-sm font-medium transition-all",
cycle === "yearly"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setCycle("yearly")}
>
Yearly
<span className="ml-1.5 rounded-full bg-success/15 px-1.5 py-0.5 text-[10px] font-semibold text-success">
-20%
</span>
</button>
</div>
</div>
<div className="grid w-full max-w-6xl grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{PLANS.map((plan) => (
<div
key={plan.id}
className={cn(
"flex flex-col rounded-xl border bg-card p-6 shadow-sm transition-shadow hover:shadow-md",
plan.featured
? "border-primary shadow-lg ring-1 ring-primary/20"
: "border-border"
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className={cn(
"flex size-8 items-center justify-center rounded-lg",
plan.featured ? "bg-primary" : "bg-secondary"
)}
>
<plan.icon
className={cn(
"size-4",
plan.featured ? "text-primary-foreground" : "text-foreground"
)}
/>
</div>
<span className="text-base font-semibold text-foreground">
{plan.name}
</span>
</div>
{plan.badge && (
<span className="rounded-full bg-primary px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground">
{plan.badge}
</span>
)}
</div>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">
{plan.description}
</p>
<div className="mt-4 flex items-baseline gap-1">
<span className="text-4xl font-bold tracking-tight text-foreground">
{cycle === "yearly" && plan.monthly > 0
? `${plan.yearly.toFixed(2).replace(/\.00$/, "")}`
: plan.monthly === 0
? "€0"
: `${plan.monthly}`}
</span>
<span className="text-sm text-muted-foreground">
{cycle === "yearly" && plan.monthly > 0
? "/mo billed yearly"
: plan.monthly === 0
? "forever"
: "/mo"}
</span>
</div>
<ul className="mt-5 flex flex-1 flex-col gap-2.5">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-xs text-foreground/90">
<Check className="mt-0.5 size-3.5 shrink-0 text-success" />
<span>{feature}</span>
</li>
))}
</ul>
<Button
asChild
variant={plan.featured ? "default" : "outline"}
size="sm"
className="mt-6 w-full"
>
<Link
href="/dashboard"
onClick={() => track("pricing_cta_click", { plan: plan.id })}
>
{plan.cta}
<ArrowRight className="size-3.5" />
</Link>
</Button>
</div>
))}
</div>
<div className="flex w-full max-w-6xl flex-col items-center justify-between gap-3 rounded-xl border border-border bg-muted/40 px-6 py-4 text-center sm:flex-row sm:text-left">
<div>
<p className="text-sm font-semibold text-foreground">
Enterprise: custom volume, custom LLM routing, SSO and dedicated infrastructure.
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Volume pricing, custom SLAs and on-prem options.
</p>
</div>
<Button asChild variant="outline" size="sm">
<a href="mailto:sales@wordly.art?subject=Enterprise%20inquiry">
Contact Sales
</a>
</Button>
</div>
</section>
)
}

View File

@@ -18,12 +18,26 @@ export function SiteHeader() {
</div>
<nav className="hidden items-center gap-1 md:flex">
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-foreground">
<a
href="#pricing"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Pricing
</Button>
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-foreground">
</a>
<a
href="#faq"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
FAQ
</a>
<a
href="/docs"
target="_blank"
rel="noreferrer"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
API Docs
</Button>
</a>
<div className="mx-2 h-4 w-px bg-border" />
<Button variant="outline" size="sm" asChild>
<Link href="/dashboard">Login</Link>

View File

@@ -0,0 +1,113 @@
import {
Languages,
Cpu,
LayoutTemplate,
ShieldCheck,
Star,
} from "lucide-react"
const STATS = [
{
icon: Languages,
value: "60+",
label: "Languages supported",
},
{
icon: Cpu,
value: "7",
label: "Translation engines",
},
{
icon: LayoutTemplate,
value: "100%",
label: "Formatting preserved",
},
{
icon: ShieldCheck,
value: "≤ 2 h",
label: "Max file retention",
},
]
const TESTIMONIALS = [
{
quote:
"We translated a 40-page Excel pricing matrix in minutes and every formula and merged cell survived intact. It used to take our translators two days.",
name: "M. Laurent",
role: "Operations Lead, logistics (FR → EN)",
},
{
quote:
"The custom glossary is the killer feature for us. Our HVAC terminology is now consistent across every document we ship to clients.",
name: "S. Chen",
role: "Technical writer, engineering consultancy",
},
{
quote:
"Being able to pick the engine per document — cheap for drafts, premium LLM for client deliverables — fits how our agency actually works.",
name: "A. Dubois",
role: "Translation agency owner",
},
]
export function SocialProofSection() {
return (
<section className="flex flex-col items-center gap-10 px-6 py-16 md:py-20">
<div className="grid w-full max-w-4xl grid-cols-2 gap-4 md:grid-cols-4">
{STATS.map((stat) => (
<div
key={stat.label}
className="flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-5 text-center shadow-sm"
>
<div className="flex size-9 items-center justify-center rounded-lg bg-secondary">
<stat.icon className="size-4.5 text-foreground" />
</div>
<span className="text-2xl font-bold tracking-tight text-foreground">
{stat.value}
</span>
<span className="text-xs text-muted-foreground">{stat.label}</span>
</div>
))}
</div>
<div className="flex w-full max-w-5xl flex-col items-center gap-6">
<div className="flex flex-col items-center gap-2 text-center">
<div className="flex items-center gap-1 text-amber-500">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} className="size-4 fill-current" />
))}
</div>
<h2 className="text-balance text-2xl font-bold tracking-tight text-foreground md:text-3xl">
Teams that translate, ship faster
</h2>
<p className="max-w-xl text-pretty text-sm text-muted-foreground">
From solo consultants to international operations, Office Translator
keeps the format, the formulas and the meaning.
</p>
</div>
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-3">
{TESTIMONIALS.map((t) => (
<figure
key={t.name}
className="flex flex-col gap-3 rounded-xl border border-border bg-card p-5 shadow-sm"
>
<div className="flex items-center gap-0.5 text-amber-500">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} className="size-3 fill-current" />
))}
</div>
<blockquote className="flex-1 text-sm leading-relaxed text-foreground/90">
&ldquo;{t.quote}&rdquo;
</blockquote>
<figcaption className="flex items-center gap-2 text-xs">
<span className="font-semibold text-foreground">{t.name}</span>
<span className="text-muted-foreground">{t.role}</span>
</figcaption>
</figure>
))}
</div>
</div>
</section>
)
}

View File

@@ -11,6 +11,7 @@ import {
Loader2,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { track } from "@vercel/analytics"
import {
Card,
CardContent,
@@ -59,6 +60,7 @@ export function TranslationCard() {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/pdf",
]
const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -96,6 +98,11 @@ export function TranslationCard() {
const handleTranslate = useCallback(() => {
if (!file || !sourceLang || !targetLang) return
track("translation_started", {
engine,
source: sourceLang,
target: targetLang,
})
setIsProcessing(true)
setProgress(0)
@@ -181,7 +188,7 @@ export function TranslationCard() {
</div>
<div className="flex flex-col items-center gap-1">
<p className="text-sm font-medium text-foreground">
Drag & drop your .xlsx, .docx, or .pptx file here
Drag & drop your .xlsx, .docx, .pptx, or .pdf file here
</p>
<p className="text-xs text-muted-foreground">
or click to browse

View File

@@ -0,0 +1,141 @@
"use client"
import { useState, useCallback } from "react"
import { Mail, Loader2, CheckCircle2, ArrowRight } from "lucide-react"
import { track } from "@vercel/analytics"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
type Status = "idle" | "loading" | "success" | "error"
// The backend is proxied under the same origin via the rewrites in
// next.config.mjs, so the form never needs the backend origin (no CORS).
const WAITLIST_ENDPOINT = "/api/v1/waitlist"
const INTERESTS = [
"I translate documents regularly",
"I run a translation agency",
"I manage a multilingual team",
"I'm a developer (API integration)",
"Just exploring",
]
export function WaitlistSection() {
const [email, setEmail] = useState("")
const [interest, setInterest] = useState(INTERESTS[0])
const [status, setStatus] = useState<Status>("idle")
const [message, setMessage] = useState("")
const submit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
if (!email || status === "loading") return
setStatus("loading")
setMessage("")
try {
const res = await fetch(`/api/v1/waitlist`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, interest }),
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
setStatus("success")
track("waitlist_joined", { interest })
setMessage(
data?.data?.status === "already_joined"
? "You are already on the list. We will email you at launch."
: "You are on the list. We will email you at launch."
)
setEmail("")
} else {
setStatus("error")
track("waitlist_error", { status: res.status })
setMessage(data?.message ?? "Something went wrong. Please try again.")
}
} catch {
setStatus("error")
setMessage("Network error. Please try again later.")
}
},
[email, interest, status]
)
return (
<section
id="waitlist"
className="flex flex-col items-center gap-6 px-6 py-16 md:py-20"
>
<div className="flex w-full max-w-2xl flex-col items-center gap-4 rounded-2xl border border-border bg-card px-6 py-10 text-center shadow-lg md:px-12">
<div className="flex size-12 items-center justify-center rounded-xl bg-primary">
<Mail className="size-6 text-primary-foreground" />
</div>
<div className="flex flex-col gap-1.5">
<h2 className="text-balance text-2xl font-bold tracking-tight text-foreground md:text-3xl">
Be first in line at launch
</h2>
<p className="text-pretty text-sm text-muted-foreground">
Join the waitlist and get early access, launch-day pricing and the
&ldquo;keep your format&rdquo; playbook. No spam, one email at launch.
</p>
</div>
{status === "success" ? (
<div className="flex w-full max-w-md flex-col items-center gap-2 rounded-lg border border-success/40 bg-success/10 px-5 py-4">
<CheckCircle2 className="size-5 text-success" />
<p className="text-sm font-medium text-foreground">{message}</p>
</div>
) : (
<form onSubmit={submit} className="flex w-full max-w-md flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row">
<Input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
aria-label="Email address"
className="flex-1"
disabled={status === "loading"}
/>
<Button
type="submit"
size="lg"
disabled={status === "loading" || !email}
>
{status === "loading" ? (
<>
<Loader2 className="size-4 animate-spin" /> Joining
</>
) : (
<>
Join Waitlist <ArrowRight className="size-4" />
</>
)}
</Button>
</div>
<select
value={interest}
onChange={(e) => setInterest(e.target.value)}
aria-label="What best describes you?"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring/50"
>
{INTERESTS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
{status === "error" && (
<p className="text-xs text-destructive">{message}</p>
)}
<p className="text-[11px] text-muted-foreground">
We only use your address to notify you at launch. Unsubscribe any
time.
</p>
</form>
)}
</div>
</section>
)
}