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
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:
82
office-translator-landing-page/components/faq-section.tsx
Normal file
82
office-translator-landing-page/components/faq-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
248
office-translator-landing-page/components/pricing-section.tsx
Normal file
248
office-translator-landing-page/components/pricing-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
“{t.quote}”
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
141
office-translator-landing-page/components/waitlist-section.tsx
Normal file
141
office-translator-landing-page/components/waitlist-section.tsx
Normal 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
|
||||
“keep your format” 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user