Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions __tests__/i18n-actions.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
58 changes: 58 additions & 0 deletions __tests__/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -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} <b>Alex</b> $&" })).toBe(
"你好,{count} <b>Alex</b> $&!",
);
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);
}
});
});
72 changes: 37 additions & 35 deletions app/(dashboard)/campaigns/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }>();

Expand Down Expand Up @@ -131,12 +133,12 @@ export default function CampaignDetailPage() {
if (notFound || !campaign) {
return (
<div className="panel rounded p-8 text-center">
<p className="text-sm text-muted">Campaign not found.</p>
<p className="text-sm text-muted">{t("Campaign not found.")}</p>
<button
onClick={() => router.push("/campaigns")}
className="mt-4 rounded border border-border px-4 py-2 text-sm text-muted hover:text-foreground"
>
Back to campaigns
{t("Back to campaigns")}
</button>
</div>
);
Expand All @@ -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 (
Expand All @@ -176,7 +178,7 @@ export default function CampaignDetailPage() {
href="/campaigns"
className="text-sm text-muted hover:text-foreground"
>
&larr; Campaigns
{t("← Campaigns")}
</Link>
</div>
<div className="flex items-center gap-2">
Expand All @@ -188,39 +190,39 @@ export default function CampaignDetailPage() {
: "bg-zinc-500/10 text-muted"
}`}
>
{campaign.isActive ? "LIVE" : "Paused"}
{campaign.isActive ? t("LIVE") : t("Paused")}
</span>
</div>

<Summary title="When someone comments on">
<Summary title={t("When someone comments on")}>
<div className="flex items-center gap-3">
{postThumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={postThumb}
alt="Post"
alt={t("Post")}
className="h-14 w-14 rounded object-cover"
/>
) : (
<div className="grid h-14 w-14 place-items-center rounded bg-surface-hover text-[10px] text-muted">
{campaign.matchAnyPost || campaign.pendingNextReel ? "Any" : "Post"}
{campaign.matchAnyPost || campaign.pendingNextReel ? "Any" : t("Post")}
</div>
)}
<span className="text-sm text-foreground">{trigger}</span>
</div>
</Summary>

<Summary title="And this comment has">
<Summary title={t("And this comment has")}>
<FieldBox>{matchText}</FieldBox>
{campaign.dmTriggerEnabled && (
<p className="text-xs text-muted">
Also replies when someone DMs{" "}
{campaign.matchAnyWord ? "anything" : "these words"}.
{t("Also replies when someone DMs")}{" "}
{campaign.matchAnyWord ? t("anything") : t("these words")}.
</p>
)}
{publicReplies.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-muted">Public reply under the post</p>
<p className="text-xs text-muted">{t("Public reply under the post")}</p>
{publicReplies.map((m, i) => (
<FieldBox key={i}>{m}</FieldBox>
))}
Expand All @@ -229,14 +231,14 @@ export default function CampaignDetailPage() {
</Summary>

{campaign.openingDmEnabled && (
<Summary title="They will get an opening DM">
<FieldBox>{campaign.openingDmMessage || "Opening message"}</FieldBox>
<FieldBox>{campaign.openingDmButtonLabel || "Button"}</FieldBox>
<Summary title={t("They will get an opening DM")}>
<FieldBox>{campaign.openingDmMessage || t("Opening message")}</FieldBox>
<FieldBox>{campaign.openingDmButtonLabel || t("Button")}</FieldBox>
</Summary>
)}

{campaign.requireFollow && (
<Summary title="They must follow first">
<Summary title={t("They must follow first")}>
<FieldBox>
{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"}
Expand All @@ -247,7 +249,7 @@ export default function CampaignDetailPage() {
</Summary>
)}

<Summary title="And then, they will get a DM">
<Summary title={t("And then, they will get a DM")}>
<FieldBox>{campaign.dmMessage}</FieldBox>
{hasLink && (
<FieldBox>{campaign.linkButtonLabel || "Open link"}</FieldBox>
Expand All @@ -260,7 +262,7 @@ export default function CampaignDetailPage() {
</Summary>

{hasLink && (
<Summary title="The exact link sent">
<Summary title={t("The exact link sent")}>
{campaign.trackedLinks
?.filter((link) => link.destinationUrl)
.map((link, i) => (
Expand All @@ -271,7 +273,7 @@ export default function CampaignDetailPage() {
</p>
</div>
<p className="text-xs text-muted">
{link.label ? `${link.label} · ` : ""}redirects to{" "}
{link.label ? `${link.label} · ` : ""}{t("redirects to")}{" "}
<span className="break-all">{link.destinationUrl}</span>
</p>
</div>
Expand All @@ -280,12 +282,12 @@ export default function CampaignDetailPage() {
)}

{campaign.followUpEnabled && campaign.followUpMessage && (
<Summary title="Then a follow-up message">
<Summary title={t("Then a follow-up message")}>
<FieldBox>{campaign.followUpMessage}</FieldBox>
<p className="text-xs text-muted">
{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.")}
</p>
</Summary>
)}
Expand All @@ -296,18 +298,18 @@ export default function CampaignDetailPage() {
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-3 border-b border-border pb-3">
<div className="flex gap-4">
<TabButton active={tab === "insights"} onClick={() => setTab("insights")}>
Insights
{t("Insights")}
</TabButton>
<TabButton active={tab === "preview"} onClick={() => setTab("preview")}>
Preview
{t("Preview")}
</TabButton>
</div>
<div className="flex items-center gap-2">
<Link
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")}
</Link>
<button
onClick={toggleActive}
Expand All @@ -318,7 +320,7 @@ export default function CampaignDetailPage() {
: "border-success/30 text-success hover:bg-success/10"
}`}
>
{campaign.isActive ? "Stop" : "Resume"}
{campaign.isActive ? t("Stop") : t("Resume")}
</button>
</div>
</div>
Expand All @@ -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] ?? ""}
Expand Down
Loading