diff --git a/README.md b/README.md index 70ef8db18..feb337268 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ OpenReply is built around Meta's official Instagram private replies. It does not - Multiple Instagram accounts. Connect several professional accounts under one workspace, each with its own limits. - Workspaces and roles. Owner, admin, and member roles with invite links, useful if you run this for clients. - Campaign templates. Start from a preset instead of a blank form. +- English and Traditional Chinese interface, with a saved language preference. See [interface languages](docs/localization.md). - Inbox. Read your Instagram DM conversations and reply from the dashboard, inside Meta's 24-hour messaging window. Cached so it loads instantly on repeat visits. - DM logs. Every send, skip, and failure is logged with a reason. - Self-comment filtering. Your own comments never trigger a reply, since Meta rejects DMing yourself anyway. diff --git a/__tests__/i18n-actions.test.ts b/__tests__/i18n-actions.test.ts new file mode 100644 index 000000000..8def0585d --- /dev/null +++ b/__tests__/i18n-actions.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const store = vi.hoisted(() => ({ get: vi.fn(), set: vi.fn() })); +vi.mock("next/headers", () => ({ cookies: async () => store })); + +import { setLocale } from "../lib/i18n/actions"; +import { getI18n } from "../lib/i18n/server"; +import { LOCALE_COOKIE } from "../lib/i18n"; + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => vi.unstubAllEnvs()); + +describe("language preference", () => { + it("renders a saved language on the server before hydration", async () => { + store.get.mockReturnValue({ value: "zh-TW" }); + const { locale, t } = await getI18n(); + expect(store.get).toHaveBeenCalledWith(LOCALE_COOKIE); + expect(locale).toBe("zh-TW"); + expect(t("Settings")).toBe("設定"); + }); + + it("falls back to English for an invalid cookie", async () => { + store.get.mockReturnValue({ value: "unsupported" }); + expect((await getI18n()).locale).toBe("en"); + }); + + it("persists only the language cookie, across routes and browser restarts", async () => { + vi.stubEnv("NODE_ENV", "production"); + await setLocale("zh-TW"); + expect(store.set).toHaveBeenCalledExactlyOnceWith(LOCALE_COOKIE, "zh-TW", { + path: "/", + maxAge: 31_536_000, + sameSite: "lax", + httpOnly: true, + secure: true, + }); + }); + + it("allows switching back to English during local development", async () => { + vi.stubEnv("NODE_ENV", "development"); + await setLocale("en"); + expect(store.set).toHaveBeenCalledWith( + LOCALE_COOKIE, + "en", + expect.objectContaining({ secure: false }), + ); + }); + + it("rejects an unsupported locale without writing cookies", async () => { + await expect(setLocale("en; path=/")).rejects.toThrow("Unsupported locale"); + expect(store.set).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/i18n.test.ts b/__tests__/i18n.test.ts new file mode 100644 index 000000000..c7573f94d --- /dev/null +++ b/__tests__/i18n.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { createI18n, resolveLocale } from "../lib/i18n"; +import zhTW from "../lib/i18n/zh-TW.json"; + +describe("interface translations", () => { + it("keeps English as the default for absent or unsupported preferences", () => { + for (const value of [undefined, null, "", "fr", "zh-CN", "../zh-TW"]) { + expect(resolveLocale(value)).toBe("en"); + } + expect(resolveLocale("zh-TW")).toBe("zh-TW"); + }); + + it("renders both interface languages from the same keys", () => { + expect(createI18n("en").t("Campaigns")).toBe("Campaigns"); + expect(createI18n("zh-TW").t("Campaigns")).toBe("自動回覆活動"); + }); + + it("allows sentence order to differ between languages", () => { + const values = { count: 2 }; + expect(createI18n("en").t("{count} connected accounts", values)).toBe( + "2 connected accounts", + ); + expect(createI18n("zh-TW").t("{count} connected accounts", values)).toBe( + "已連接 2 個帳號", + ); + }); + + it("preserves interpolation values verbatim, including user content and zero", () => { + const { t } = createI18n("zh-TW"); + expect(t("Hello, {name}!", { name: "{count} Alex $&" })).toBe( + "你好,{count} Alex $&!", + ); + expect(t("{count} campaigns", { count: 0 })).toBe("0 個活動"); + }); + + it("translates display labels without changing stored codes or unknown values", () => { + const codes = ["SENT", "OWNER", "active", "CUSTOM_STATUS"]; + expect(codes.map(createI18n("zh-TW").label)).toEqual([ + "已傳送", + "擁有者", + "啟用中", + "CUSTOM_STATUS", + ]); + expect(codes).toEqual(["SENT", "OWNER", "active", "CUSTOM_STATUS"]); + expect(createI18n("zh-TW").label("toString")).toBe("toString"); + expect(createI18n("zh-TW").label("__proto__")).toBe("__proto__"); + }); + + it("has complete, plain-text translations with matching interpolation fields", () => { + const placeholders = (text: string) => + [...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort(); + for (const [source, translation] of Object.entries(zhTW)) { + expect(translation.trim(), source).not.toBe(""); + expect(placeholders(translation), source).toEqual(placeholders(source)); + expect(source, source).not.toMatch(/&(?:[a-z]+|#\d+);/i); + } + }); +}); diff --git a/app/(dashboard)/campaigns/[id]/page.tsx b/app/(dashboard)/campaigns/[id]/page.tsx index 03d4df0d6..b0f2a9d5b 100644 --- a/app/(dashboard)/campaigns/[id]/page.tsx +++ b/app/(dashboard)/campaigns/[id]/page.tsx @@ -8,6 +8,7 @@ * live in the top bar. */ +import { useI18n } from "@/lib/i18n/provider"; import { useEffect, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; @@ -57,6 +58,7 @@ interface Campaign { type Tab = "insights" | "preview"; export default function CampaignDetailPage() { + const { t } = useI18n(); const router = useRouter(); const { id } = useParams<{ id: string }>(); @@ -131,12 +133,12 @@ export default function CampaignDetailPage() { if (notFound || !campaign) { return (
-

Campaign not found.

+

{t("Campaign not found.")}

); @@ -152,19 +154,19 @@ export default function CampaignDetailPage() { const hasSecondLink = Boolean(campaign.trackedLinks?.[1]?.destinationUrl); const trigger = campaign.matchAnyPost - ? "Any post or reel" + ? t("Any post or reel") : campaign.pendingNextReel - ? "Your next reel" - : "A specific post or reel"; + ? t("Your next reel") + : t("A specific post or reel"); const matchText = campaign.matchAnyWord - ? "Any comment" - : campaign.keywords.join(", ") || "No keywords"; + ? t("Any comment") + : campaign.keywords.join(", ") || t("No keywords"); const metrics = [ - { label: "Sends", value: campaign.analytics.sent }, - { label: "Clicks", value: campaign.analytics.clicks }, - { label: "CTR", value: `${campaign.analytics.ctr}%` }, - { label: "Failed", value: campaign.analytics.failed }, + { label: t("Sends"), value: campaign.analytics.sent }, + { label: t("Clicks"), value: campaign.analytics.clicks }, + { label: t("CTR"), value: `${campaign.analytics.ctr}%` }, + { label: t("Failed"), value: campaign.analytics.failed }, ]; return ( @@ -176,7 +178,7 @@ export default function CampaignDetailPage() { href="/campaigns" className="text-sm text-muted hover:text-foreground" > - ← Campaigns + {t("← Campaigns")}
@@ -188,39 +190,39 @@ export default function CampaignDetailPage() { : "bg-zinc-500/10 text-muted" }`} > - {campaign.isActive ? "LIVE" : "Paused"} + {campaign.isActive ? t("LIVE") : t("Paused")}
- +
{postThumb ? ( // eslint-disable-next-line @next/next/no-img-element Post ) : (
- {campaign.matchAnyPost || campaign.pendingNextReel ? "Any" : "Post"} + {campaign.matchAnyPost || campaign.pendingNextReel ? "Any" : t("Post")}
)} {trigger}
- + {matchText} {campaign.dmTriggerEnabled && (

- Also replies when someone DMs{" "} - {campaign.matchAnyWord ? "anything" : "these words"}. + {t("Also replies when someone DMs")}{" "} + {campaign.matchAnyWord ? t("anything") : t("these words")}.

)} {publicReplies.length > 0 && (
-

Public reply under the post

+

{t("Public reply under the post")}

{publicReplies.map((m, i) => ( {m} ))} @@ -229,14 +231,14 @@ export default function CampaignDetailPage() {
{campaign.openingDmEnabled && ( - - {campaign.openingDmMessage || "Opening message"} - {campaign.openingDmButtonLabel || "Button"} + + {campaign.openingDmMessage || t("Opening message")} + {campaign.openingDmButtonLabel || t("Button")} )} {campaign.requireFollow && ( - + {campaign.followPromptMessage || "quick favor before i send your link. i don't make any money from this, it's free. if you want to support me, just don't unfollow after, and star the repo on github if it helps you. tap the button once you're following and i'll send it over"} @@ -247,7 +249,7 @@ export default function CampaignDetailPage() { )} - + {campaign.dmMessage} {hasLink && ( {campaign.linkButtonLabel || "Open link"} @@ -260,7 +262,7 @@ export default function CampaignDetailPage() { {hasLink && ( - + {campaign.trackedLinks ?.filter((link) => link.destinationUrl) .map((link, i) => ( @@ -271,7 +273,7 @@ export default function CampaignDetailPage() {

- {link.label ? `${link.label} · ` : ""}redirects to{" "} + {link.label ? `${link.label} · ` : ""}{t("redirects to")}{" "} {link.destinationUrl}

@@ -280,12 +282,12 @@ export default function CampaignDetailPage() { )} {campaign.followUpEnabled && campaign.followUpMessage && ( - + {campaign.followUpMessage}

{campaign.followUpDelayMinutes && campaign.followUpDelayMinutes > 0 - ? `Sent ${campaign.followUpDelayMinutes} min after the link.` - : "Sent right after the link."} + ? t("Sent {minutes} min after the link.", { minutes: campaign.followUpDelayMinutes }) + : t("Sent right after the link.")}

)} @@ -296,10 +298,10 @@ export default function CampaignDetailPage() {
setTab("insights")}> - Insights + {t("Insights")} setTab("preview")}> - Preview + {t("Preview")}
@@ -307,7 +309,7 @@ export default function CampaignDetailPage() { href={`/campaigns/${campaign.id}/edit`} className="rounded border border-border px-3 py-1.5 text-sm text-muted hover:text-foreground" > - Edit + {t("Edit")}
@@ -345,7 +347,7 @@ export default function CampaignDetailPage() { avatarUrl={avatarUrl} postThumb={postThumb} caption="" - sampleComment={campaign.matchAnyWord ? "nice!" : campaign.keywords[0] ?? "LINK"} + sampleComment={campaign.matchAnyWord ? t("nice!") : campaign.keywords[0] ?? "LINK"} dmTriggerEnabled={campaign.dmTriggerEnabled} publicReplyEnabled={campaign.publicReplyEnabled} publicReplyMessage={publicReplies[0] ?? ""} diff --git a/app/(dashboard)/campaigns/import/page.tsx b/app/(dashboard)/campaigns/import/page.tsx index 4c0c394bf..1e116c5cf 100644 --- a/app/(dashboard)/campaigns/import/page.tsx +++ b/app/(dashboard)/campaigns/import/page.tsx @@ -8,6 +8,7 @@ * each campaign and pick its reel before saving. */ +import { useI18n } from "@/lib/i18n/provider"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import AccountSelect, { type AccountOption } from "@/components/account-select"; @@ -19,6 +20,7 @@ const SAMPLE = `keywords,dm_message,public_reply,tracked_url,opening_dm,opening_ "LINK,SHOP","grab it here: {link}","dmed u",,,`; export default function ImportCampaignsPage() { + const { t } = useI18n(); const router = useRouter(); const [accounts, setAccounts] = useState([]); const [selectedAccountId, setSelectedAccountId] = useState(""); @@ -42,7 +44,7 @@ export default function ImportCampaignsPage() { setError(null); const parsed = parseCsv(csv); if (parsed.length === 0) { - setError("Paste a CSV with a header row and at least one campaign."); + setError(t("Paste a CSV with a header row and at least one campaign.")); return; } @@ -56,7 +58,7 @@ export default function ImportCampaignsPage() { .slice(0, 10); const dmMessage = (r.dm_message ?? r.message ?? "").trim(); if (keywords.length === 0 || !dmMessage) { - setError(`Row ${i + 1} is missing keywords or a message.`); + setError(t("Row {row} is missing keywords or a message.", { row: i + 1 })); return; } rows.push({ @@ -76,7 +78,7 @@ export default function ImportCampaignsPage() { window.localStorage.setItem(IMPORT_ACCOUNT_KEY, selectedAccountId); } } catch { - setError("Could not stage the import in this browser."); + setError(t("Could not stage the import in this browser.")); return; } router.push("/campaigns/new"); @@ -85,21 +87,17 @@ export default function ImportCampaignsPage() { return (
-

Import campaigns

+

{t("Import campaigns")}

- Paste a CSV with one row per campaign. Each row opens in the builder - prefilled and editable, so you can review it and pick the reel before - saving. Required columns are{" "} - keywords and{" "} - dm_message. Optional:{" "} + {t("Paste a CSV with one row per campaign. Each row opens in the builder prefilled and editable, so you can review it and pick the reel before saving. Required columns are")}{" "} + keywords {t("and")}{" "} + dm_message{t(". Optional:")}{" "} name,{" "} public_reply,{" "} tracked_url,{" "} opening_dm,{" "} - opening_dm_button. Keywords go in - one cell, separated by commas. Use{" "} - {"{link}"} in the message to - insert the tracked link. + opening_dm_button{t(". Keywords go in one cell, separated by commas. Use")}{" "} + {"{link}"} {t("in the message to insert the tracked link.")}

@@ -112,14 +110,14 @@ export default function ImportCampaignsPage() { {accounts.length > 1 && (
)} @@ -138,7 +136,7 @@ export default function ImportCampaignsPage() { onClick={() => setCsv(SAMPLE)} className="text-xs text-muted hover:text-foreground" > - Fill with a sample + {t("Fill with a sample")}
@@ -147,13 +145,13 @@ export default function ImportCampaignsPage() { onClick={startImport} className="px-5 py-2 rounded bg-accent text-sm font-medium text-white hover:bg-accent-hover" > - Review and import + {t("Review and import")} diff --git a/app/(dashboard)/campaigns/page.tsx b/app/(dashboard)/campaigns/page.tsx index 1cea26e8e..1c764f442 100644 --- a/app/(dashboard)/campaigns/page.tsx +++ b/app/(dashboard)/campaigns/page.tsx @@ -6,6 +6,7 @@ * Shows all campaigns as cards with toggle and delete. */ +import { useI18n } from "@/lib/i18n/provider"; import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -63,6 +64,7 @@ interface Campaign { } export default function CampaignsPage() { + const { t, label } = useI18n(); const router = useRouter(); const [automations, setAutomations] = useState([]); const [accounts, setAccounts] = useState([]); @@ -228,7 +230,7 @@ export default function CampaignsPage() { } async function deleteAutomation(id: string) { - if (!confirm("Delete this campaign? This cannot be undone.")) return; + if (!confirm(t("Delete this campaign? This cannot be undone."))) return; try { await fetch(`/api/automations?id=${id}`, { method: "DELETE" }); setAutomations((prev) => prev.filter((a) => a.id !== id)); @@ -282,11 +284,9 @@ export default function CampaignsPage() {

- {filtered.length} {filtered.length !== automations.length - ? ` of ${automations.length}` - : ""}{" "} - campaign{automations.length !== 1 ? "s" : ""} + ? t("{count} of {total} campaigns", { count: filtered.length, total: automations.length }) + : t(automations.length === 1 ? "{count} campaign" : "{count} campaigns", { count: automations.length })}

@@ -301,13 +301,13 @@ export default function CampaignsPage() { href="/campaigns/import" className="flex-1 rounded border border-border px-4 py-2 text-center text-sm font-medium text-muted hover:text-foreground sm:flex-none" > - Import + {t("Import")} - New Campaign + {t("New Campaign")}
@@ -318,13 +318,13 @@ export default function CampaignsPage() { setSearch(e.target.value)} - placeholder="Search campaigns by name, keyword, or message…" + placeholder={t("Search campaigns by name, keyword, or message…")} className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-foreground placeholder:text-zinc-500 focus:border-accent/40 focus:outline-none" />
{(["all", "active", "paused"] as const).map((s) => ( ))}
@@ -343,15 +343,15 @@ export default function CampaignsPage() { {/* Empty state */} {automations.length === 0 && (
-

No campaigns yet

+

{t("No campaigns yet")}

- Create your first comment-to-DM campaign to turn a post or reel into a measurable conversation flow. + {t("Create your first comment-to-DM campaign to turn a post or reel into a measurable conversation flow.")}

- Create Campaign + {t("Create Campaign")}
)} @@ -359,7 +359,7 @@ export default function CampaignsPage() { {/* No matches for the current filter */} {automations.length > 0 && filtered.length === 0 && (
- No campaigns match your search. + {t("No campaigns match your search.")}
)} @@ -384,13 +384,13 @@ export default function CampaignsPage() { e.stopPropagation(); setPlayingVideo({ url: videoUrl, postUrl: auto.postUrl }); }} - aria-label="Play reel preview" + aria-label={t("Play reel preview")} className="shrink-0" > {/* eslint-disable-next-line @next/next/no-img-element */} Campaign reel { e.currentTarget.style.display = "none"; @@ -408,7 +408,7 @@ export default function CampaignsPage() { {/* eslint-disable-next-line @next/next/no-img-element */} Campaign post { e.currentTarget.style.display = "none"; @@ -430,21 +430,21 @@ export default function CampaignsPage() { : "bg-zinc-500/10 text-muted" }`} > - {auto.isActive ? "Active" : "Paused"} + {auto.isActive ? t("Active") : t("Paused")} {auto.pendingNextReel && ( - Waiting for next reel + {t("Waiting for next reel")} )} {auto.requireFollow && ( - Follow gate + {t("Follow gate")} )} {auto.trackedLinks.length >= 2 && ( - 2 links + {t("2 links")} )} @@ -462,7 +462,7 @@ export default function CampaignsPage() { {/* DM preview */} -

“{auto.dmMessage}”

+

“{auto.dmMessage}{t("”")}

{/* Tracked link sent */} {auto.trackedLinks[0]?.trackedUrl && ( @@ -474,20 +474,20 @@ export default function CampaignsPage() { {/* Stats */}
- {auto._count.dmLogs} runs + {auto._count.dmLogs} {t("runs")} · - {auto.analytics.ctr}% CTR + {auto.analytics.ctr}{t("% CTR")} · - {auto.analytics.sent} sent + {auto.analytics.sent} {t("sent")} · - {auto.analytics.skipped} skipped + {auto.analytics.skipped} {t("skipped")} · - {auto.analytics.failed} failed + {auto.analytics.failed} {t("failed")} · - {auto.analytics.clicks} clicks + {auto.analytics.clicks} {t("clicks")}
{auto.analytics.topKeywords.length > 0 && ( @@ -515,7 +515,7 @@ export default function CampaignsPage() { onClick={() => void copyReelUrl(auto)} className="shrink-0 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted transition-colors hover:border-border-hover hover:text-foreground" > - {copiedId === auto.id ? "Copied!" : "Copy URL"} + {copiedId === auto.id ? t("Copied!") : t("Copy URL")} )} {/* Toggle */} @@ -540,7 +540,7 @@ export default function CampaignsPage() { onClick={() => setMenuOpenId((cur) => (cur === auto.id ? null : auto.id)) } - aria-label="More actions" + aria-label={t("More actions")} className="px-2 py-1 rounded text-lg leading-none text-muted hover:text-foreground" > ⋯ @@ -556,7 +556,7 @@ export default function CampaignsPage() { onClick={() => void duplicateAutomation(auto.id)} className="block w-full px-3 py-2 text-left text-sm text-foreground hover:bg-surface-hover" > - Duplicate + {t("Duplicate")} @@ -596,7 +596,7 @@ export default function CampaignsPage() { rel="noreferrer" className="text-zinc-300 hover:text-white" > - Open on Instagram + {t("Open on Instagram")} )}