@@ -159,7 +161,7 @@ export default function LogsPage() {
{!loading && logs.length === 0 && (
- No logs found
+ {t("No logs found")}
)}
@@ -184,7 +186,7 @@ export default function LogsPage() {
- {new Date(log.createdAt).toLocaleString("en-US", {
+ {new Date(log.createdAt).toLocaleString(locale, {
month: "short",
day: "numeric",
hour: "2-digit",
@@ -201,9 +203,11 @@ export default function LogsPage() {
{pagination && pagination.totalPages > 1 && (
- Showing {(pagination.page - 1) * pagination.limit + 1}–
- {Math.min(pagination.page * pagination.limit, pagination.total)} of{" "}
- {pagination.total}
+ {t("Showing {start}–{end} of {total}", {
+ start: (pagination.page - 1) * pagination.limit + 1,
+ end: Math.min(pagination.page * pagination.limit, pagination.total),
+ total: pagination.total,
+ })}
- Previous
+ {t("Previous")}
{page} / {pagination.totalPages}
@@ -227,7 +231,7 @@ export default function LogsPage() {
}}
className="px-3 py-1.5 rounded-lg text-xs font-medium text-muted border border-border hover:text-foreground hover:border-border-hover transition-all disabled:opacity-30 disabled:pointer-events-none"
>
- Next
+ {t("Next")}
diff --git a/app/(dashboard)/overview/page.tsx b/app/(dashboard)/overview/page.tsx
index eeb1021ec..441a12722 100644
--- a/app/(dashboard)/overview/page.tsx
+++ b/app/(dashboard)/overview/page.tsx
@@ -8,22 +8,24 @@
* the insights permission); likes and comments are always available.
*/
+import type { Locale } from "@/lib/i18n";
+import { useI18n } from "@/lib/i18n/provider";
import { useEffect, useState } from "react";
import AccountSelect from "@/components/account-select";
import StatCard from "@/components/stat-card";
import FollowerChart from "@/components/follower-chart";
import type { OverviewResponse } from "@/app/api/instagram/overview/route";
-function formatNumber(n: number | null): string {
+function formatNumber(n: number | null, locale: Locale): string {
if (n === null) return "—";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
- return n.toLocaleString();
+ return n.toLocaleString(locale);
}
-function formatDate(iso: string): string {
+function formatDate(iso: string, locale: Locale): string {
const d = new Date(iso);
- return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
+ return d.toLocaleDateString(locale, { month: "short", day: "numeric" });
}
const COUNT_OPTIONS = [
@@ -31,9 +33,10 @@ const COUNT_OPTIONS = [
{ value: "50", label: "Last 50" },
{ value: "100", label: "Last 100" },
{ value: "all", label: "All time" },
-];
+] as const;
export default function OverviewPage() {
+ const { t, locale } = useI18n();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -87,13 +90,13 @@ export default function OverviewPage() {
if (error) {
return (
@@ -110,25 +113,25 @@ export default function OverviewPage() {
{data.limitations?.map(note => {note}
)}
-
Overview
+
{t("Overview")}
- {data.provider !== "ZERNIO" && data.requestedCount === "all" ? "All-time" : "Recent"} —{" "}
- {totals.posts} post{totals.posts === 1 ? "" : "s"} from @
+ {data.provider !== "ZERNIO" && data.requestedCount === "all" ? t("All-time") : t("Recent")} —{" "}
+ {t(totals.posts === 1 ? "{count} post" : "{count} posts", { count: totals.posts })} {t("from @")}
{data.account.username}
- {data.truncated ? ` (capped at ${totals.posts})` : ""}
+ {data.truncated ? t(" (capped at {count})", { count: totals.posts }) : ""}
{followers !== null && (
// Kept out of the tile row below: that row sums the selected posts,
// whereas this is a current account-level total.
- {followers.toLocaleString()} followers
+ {followers.toLocaleString(locale)} {t("followers")}
)}
- Range
+ {t("Range")}
{COUNT_OPTIONS.map((o) => (
- {o.label}
+ {t(o.label)}
))}
@@ -159,29 +162,28 @@ export default function OverviewPage() {
{!insightsAvailable && (
- Views, reach, saved and shares need the insights permission.
+ {t("Views, reach, saved and shares need the insights permission.")}
- Reconnect your account to grant it — likes and comments are shown in
- the meantime.
+ {t("Reconnect your account to grant it — likes and comments are shown in the meantime.")}
- Reconnect Instagram
+ {t("Reconnect Instagram")}
)}
{/* Aggregate totals */}
-
-
-
-
-
-
+
+
+
+
+
+
{/* Follower trend — account-level, independent of the post range */}
@@ -189,9 +191,9 @@ export default function OverviewPage() {
{/* Per-post table */}
-
Posts
+
{t("Posts")}
{posts.length === 0 ? (
-
No posts found
+
{t("No posts found")}
) : (
// Eight metric columns can't compress into a phone; let the table keep
// its natural width and scroll inside the panel instead.
@@ -199,14 +201,14 @@ export default function OverviewPage() {
- Post
- Views
- Reach
- Likes
- Comments
- Saved
- Shares
- Date
+ {t("Post")}
+ {t("Views")}
+ {t("Reach")}
+ {t("Likes")}
+ {t("Comments")}
+ {t("Saved")}
+ {t("Shares")}
+ {t("Date")}
@@ -223,34 +225,34 @@ export default function OverviewPage() {
rel="noopener noreferrer"
className="text-foreground hover:text-accent truncate block"
>
- {p.caption || `${p.mediaType} post`}
+ {p.caption || t("{type} post", { type: p.mediaType })}
) : (
- {p.caption || `${p.mediaType} post`}
+ {p.caption || t("{type} post", { type: p.mediaType })}
)}
- {formatNumber(p.views)}
+ {formatNumber(p.views, locale)}
- {formatNumber(p.reach)}
+ {formatNumber(p.reach, locale)}
- {formatNumber(p.likes)}
+ {formatNumber(p.likes, locale)}
- {formatNumber(p.comments)}
+ {formatNumber(p.comments, locale)}
- {formatNumber(p.saved)}
+ {formatNumber(p.saved, locale)}
- {formatNumber(p.shares)}
+ {formatNumber(p.shares, locale)}
- {formatDate(p.timestamp)}
+ {formatDate(p.timestamp, locale)}
))}
diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx
index ae66ed769..197edf30d 100644
--- a/app/(dashboard)/settings/page.tsx
+++ b/app/(dashboard)/settings/page.tsx
@@ -1,5 +1,7 @@
"use client";
+import LanguageSwitcher from "@/components/language-switcher";
+import { useI18n } from "@/lib/i18n/provider";
import { Suspense, useEffect, useState } from "react";
import type { AccountOption } from "@/components/account-select";
import { ZernioConnection } from "@/components/zernio-connection";
@@ -48,6 +50,7 @@ interface WorkspaceMembersData {
}
export default function SettingsPage() {
+ const { t, label, locale } = useI18n();
const [data, setData] = useState(null);
const [membersData, setMembersData] = useState(
null
@@ -77,7 +80,7 @@ export default function SettingsPage() {
}
async function disconnectInstagram(instagramAccountId: string) {
- if (!confirm("Disconnect Instagram? Campaigns for this account will stop sending DMs.")) {
+ if (!confirm(t("Disconnect Instagram? Campaigns for this account will stop sending DMs."))) {
return;
}
@@ -104,7 +107,7 @@ export default function SettingsPage() {
setMembersData(payload.data);
setInviteEmail("");
} else {
- setMemberError(payload.error ?? "Could not invite member");
+ setMemberError(payload.error ?? t("Could not invite member"));
}
setBusy(null);
}
@@ -138,17 +141,23 @@ export default function SettingsPage() {
+
+ {t("Interface language")}
+
+ {t("Saved in this browser. Campaign messages stay unchanged.")}
+
+
- Instagram Connection
+ {t("Instagram Connection")}
-
Status
+
{t("Status")}
- Comment webhooks and private replies depend on this connection.
+ {t("Comment webhooks and private replies depend on this connection.")}
- {accounts.length > 0 ? "Connected" : "Not connected"}
+ {accounts.length > 0 ? t("Connected") : t("Not connected")}
-
Accounts
+
{t("Accounts")}
- {accounts.length} connected Instagram profile
- {accounts.length === 1 ? "" : "s"}
+ {t(accounts.length === 1 ? "{count} connected Instagram profile" : "{count} connected Instagram profiles", { count: accounts.length })}
- {accounts.length > 0 ? `${accounts.length} connected` : "None"}
+ {accounts.length > 0 ? t("{count} connected", { count: accounts.length }) : t("None")}
{accounts.length === 0 && (
- Connect an Instagram professional account to launch campaigns.
+ {t("Connect an Instagram professional account to launch campaigns.")}
)}
{accounts.map((account) => (
@@ -191,11 +199,11 @@ export default function SettingsPage() {
@{account.username}
- {account.provider === "ZERNIO" ? "Connected via Zernio" : <>Token expires{" "}
+ {account.provider === "ZERNIO" ? t("Connected via Zernio") : <>{t("Token expires")}{" "}
{account.tokenExpiresAt
- ? new Date(account.tokenExpiresAt).toLocaleDateString()
- : "not available"}>}{" "}
- · {account.webhookSubscribed ? "Webhook ready" : "Webhook pending"}
+ ? new Date(account.tokenExpiresAt).toLocaleDateString(locale)
+ : t("not available")}>}{" "}
+ · {account.webhookSubscribed ? t("Webhook ready") : t("Webhook pending")}
{busy === `disconnect:${account.id}`
- ? "Disconnecting..."
- : "Disconnect"}
+ ? t("Disconnecting...")
+ : t("Disconnect")}
))}
@@ -217,13 +225,13 @@ export default function SettingsPage() {
href="/api/instagram/connect"
className="px-4 py-2 rounded text-sm font-medium transition-colors bg-accent text-white hover:bg-accent-hover"
>
- Connect using your own Meta app
+ {t("Connect using your own Meta app")}
- Team
+ {t("Team")}
{membersData?.members.map((member) => (
- {member.user.name ?? member.user.email ?? "Unknown member"}
+ {member.user.name ?? member.user.email ?? t("Unknown member")}
{member.user.email}
- {member.role}
+ {label(member.role)}
))}
@@ -246,7 +254,7 @@ export default function SettingsPage() {
{membersData?.invitations.length ? (
- Pending invites
+ {t("Pending invites")}
{membersData.invitations.map((invitation) => (
@@ -259,7 +267,7 @@ export default function SettingsPage() {
{invitation.email}
- {invitation.role} · {invitation.inviteUrl}
+ {label(invitation.role)} · {invitation.inviteUrl}
@@ -270,7 +278,7 @@ export default function SettingsPage() {
}
className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted transition-colors hover:border-border-hover hover:text-foreground"
>
- Copy
+ {t("Copy")}
- Revoke
+ {t("Revoke")}
@@ -307,15 +315,15 @@ export default function SettingsPage() {
}
className="rounded border border-border bg-surface px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-accent/40"
>
-
Member
-
Admin
+
{t("Member")}
+
{t("Admin")}
- {busy === "invite" ? "Inviting..." : "Invite"}
+ {busy === "invite" ? t("Inviting...") : t("Invite")}
{memberError && (
{memberError}
@@ -325,14 +333,14 @@ export default function SettingsPage() {
- Usage
+ {t("Usage")}
- DMs sent this month
+ {t("DMs sent this month")}
- Self-hosted — no plan limits.
+ {t("Self-hosted — no plan limits.")}
diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx
index 78dd9b625..065208dd0 100644
--- a/app/invite/[token]/page.tsx
+++ b/app/invite/[token]/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import { getI18n } from "@/lib/i18n/server";
import Link from "next/link";
import { notFound } from "next/navigation";
import InvitationAcceptCard from "@/components/invitation-accept-card";
@@ -9,12 +10,16 @@ type InvitePageProps = {
params: Promise<{ token: string }>;
};
-export const metadata: Metadata = {
- title: "Accept Workspace Invitation - OpenReply",
- robots: { index: false, follow: false },
-};
+export async function generateMetadata(): Promise {
+ const { t } = await getI18n();
+ return {
+ title: t("Accept Workspace Invitation - OpenReply"),
+ robots: { index: false, follow: false },
+ };
+}
export default async function InvitePage({ params }: InvitePageProps) {
+ const { t, label } = await getI18n();
const { token } = await params;
const [session, invitation] = await Promise.all([
auth(),
@@ -40,19 +45,18 @@ export default async function InvitePage({ params }: InvitePageProps) {
- Workspace invitation
+ {t("Workspace invitation")}
- Join {invitation.workspace.name}
+ {t("Join {workspace}", { workspace: invitation.workspace.name })}
- You were invited as {invitation.role.toLowerCase()} for{" "}
- {invitation.email}.
+ {t("You were invited as {role} for {email}.", { role: label(invitation.role), email: invitation.email })}
{expired ? (
- This invitation has expired. Ask the workspace owner to resend it.
+ {t("This invitation has expired. Ask the workspace owner to resend it.")}
) : (
;
}) {
+ const { t } = await getI18n();
if (await isPublicDemoHost()) {
return (
@@ -29,12 +34,10 @@ export default async function LoginPage({
- Sign-in is off on this demo
+ {t("Sign-in is off on this demo")}
- This is the public demo — it doesn’t create real accounts
- or send DMs. To use OpenReply for real, clone it and run your
- own instance with your own Meta app and domain.
+ {t("This is the public demo — it doesn’t create real accounts or send DMs. To use OpenReply for real, clone it and run your own instance with your own Meta app and domain.")}
- Clone it yourself ↗
+ {t("Clone it yourself")} ↗
@@ -75,8 +78,8 @@ export default async function LoginPage({
{selectedTemplate
- ? `Sign in to use the ${selectedTemplate.title} template.`
- : "Sign in by email, then connect your Instagram professional account."}
+ ? t("Sign in to use the {name} template.", { name: selectedTemplate.title })
+ : t("Sign in by email, then connect your Instagram professional account.")}
@@ -86,7 +89,7 @@ export default async function LoginPage({
{selectedTemplate && !checkEmail && (
- Template selected
+ {t("Template selected")}
{selectedTemplate.title}
@@ -96,10 +99,9 @@ export default async function LoginPage({
{checkEmail ? (
-
Check your email
+
{t("Check your email")}
- We sent you a secure sign-in link. Open it on this device to
- continue.
+ {t("We sent you a secure sign-in link. Open it on this device to continue.")}
) : (
@@ -109,7 +111,7 @@ export default async function LoginPage({
htmlFor="email"
className="block text-sm font-medium text-foreground"
>
- Work email
+ {t("Work email")}
- Email me a magic link
+ {t("Email me a magic link")}
)}
diff --git a/app/reports/[shareSlug]/page.tsx b/app/reports/[shareSlug]/page.tsx
index f6672460d..19722381b 100644
--- a/app/reports/[shareSlug]/page.tsx
+++ b/app/reports/[shareSlug]/page.tsx
@@ -1,4 +1,6 @@
import type { Metadata } from "next";
+import type { I18n } from "@/lib/i18n";
+import { getI18n } from "@/lib/i18n/server";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getCampaignReportBySlug } from "@/lib/reports/data";
@@ -7,9 +9,9 @@ type ReportPageProps = {
params: Promise<{ shareSlug: string }>;
};
-function formatDate(date: Date | null) {
- if (!date) return "No sends yet";
- return date.toLocaleDateString("en-US", {
+function formatDate(date: Date | null, { locale, t }: Pick
) {
+ if (!date) return t("No sends yet");
+ return date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
year: "numeric",
@@ -41,26 +43,28 @@ function MetricCard({
export async function generateMetadata({
params,
}: ReportPageProps): Promise {
+ const { locale, t } = await getI18n();
const { shareSlug } = await params;
- const report = await getCampaignReportBySlug(shareSlug);
+ const report = await getCampaignReportBySlug(shareSlug, locale);
if (!report) {
return {
- title: "Report Not Found",
+ title: t("Report Not Found"),
robots: { index: false, follow: false },
};
}
return {
- title: `${report.campaign.name} Campaign Report`,
- description: `Read-only Instagram comment-to-DM campaign report for ${report.campaign.name}.`,
+ title: t("{name} Campaign Report", { name: report.campaign.name }),
+ description: t("Read-only Instagram comment-to-DM campaign report for {name}.", { name: report.campaign.name }),
robots: { index: false, follow: false },
};
}
export default async function ReportPage({ params }: ReportPageProps) {
+ const { locale, t } = await getI18n();
const { shareSlug } = await params;
- const report = await getCampaignReportBySlug(shareSlug);
+ const report = await getCampaignReportBySlug(shareSlug, locale);
if (!report) {
notFound();
@@ -78,7 +82,7 @@ export default async function ReportPage({ params }: ReportPageProps) {
- Client campaign report
+ {t("Client campaign report")}
{report.campaign.name}
@@ -93,25 +97,25 @@ export default async function ReportPage({ params }: ReportPageProps) {
)}
·
- {report.campaign.isActive ? "Active campaign" : "Paused campaign"}
+ {report.campaign.isActive ? t("Active campaign") : t("Paused campaign")}
- Workspace
+ {t("Workspace")}
{report.workspace.name}
- Generated {formatDate(report.generatedAt)}
+ {t("Generated")} {formatDate(report.generatedAt, { locale, t })}
{report.branded && (
- Powered by OpenReply
+ {t("Powered by OpenReply")}
)}
@@ -122,29 +126,29 @@ export default async function ReportPage({ params }: ReportPageProps) {
@@ -153,14 +157,14 @@ export default async function ReportPage({ params }: ReportPageProps) {
- Last 7 Days
+ {t("Last 7 Days")}
- Sent replies and tracked clicks by day.
+ {t("Sent replies and tracked clicks by day.")}
- Last send: {formatDate(report.metrics.latestSentAt)}
+ {t("Last send:")} {formatDate(report.metrics.latestSentAt, { locale, t })}
@@ -172,14 +176,14 @@ export default async function ReportPage({ params }: ReportPageProps) {
style={{
height: `${Math.max((day.sent / maxDaily) * 100, 4)}%`,
}}
- title={`${day.sent} sent`}
+ title={t("{count} sent", { count: day.sent })}
/>
@@ -191,22 +195,22 @@ export default async function ReportPage({ params }: ReportPageProps) {
- Sent replies
+ {t("Sent replies")}
- Link clicks
+ {t("Link clicks")}
- Top Keywords
+ {t("Top Keywords")}
{report.topKeywords.length === 0 && (
- No matched keyword data yet.
+ {t("No matched keyword data yet.")}
)}
{report.topKeywords.map((keyword) => (
@@ -226,11 +230,11 @@ export default async function ReportPage({ params }: ReportPageProps) {
- Tracked Links
+ {t("Tracked Links")}
{report.trackedLinks.length === 0 && (
- This campaign does not have a tracked link.
+ {t("This campaign does not have a tracked link.")}
)}
{report.trackedLinks.map((link) => (
@@ -252,11 +256,11 @@ export default async function ReportPage({ params }: ReportPageProps) {
- Campaign Setup
+ {t("Campaign Setup")}
- Keywords
+ {t("Keywords")}
{report.campaign.keywords.map((keyword) => (
@@ -271,15 +275,15 @@ export default async function ReportPage({ params }: ReportPageProps) {
- Created
+ {t("Created")}
- {formatDate(report.campaign.createdAt)}
+ {formatDate(report.campaign.createdAt, { locale, t })}
@@ -299,7 +303,7 @@ export default async function ReportPage({ params }: ReportPageProps) {
{report.branded && (
- Built with OpenReply, the Instagram comment-to-DM campaign OS.
+ {t("Built with OpenReply, the Instagram comment-to-DM campaign OS.")}
)}
diff --git a/app/reports/layout.tsx b/app/reports/layout.tsx
new file mode 100644
index 000000000..6324eb623
--- /dev/null
+++ b/app/reports/layout.tsx
@@ -0,0 +1 @@
+export { default } from "@/components/localized-layout";
diff --git a/app/verify-request/layout.tsx b/app/verify-request/layout.tsx
new file mode 100644
index 000000000..6324eb623
--- /dev/null
+++ b/app/verify-request/layout.tsx
@@ -0,0 +1 @@
+export { default } from "@/components/localized-layout";
diff --git a/app/verify-request/page.tsx b/app/verify-request/page.tsx
index 8606c32a9..9fa7b1e62 100644
--- a/app/verify-request/page.tsx
+++ b/app/verify-request/page.tsx
@@ -1,11 +1,16 @@
+import { getI18n } from "@/lib/i18n/server";
import Link from "next/link";
-export const metadata = {
- title: "Check your email - OpenReply",
- description: "A sign-in link was sent to your email.",
-};
+export async function generateMetadata() {
+ const { t } = await getI18n();
+ return {
+ title: t("Check your email - OpenReply"),
+ description: t("A sign-in link was sent to your email."),
+ };
+}
-export default function VerifyRequestPage() {
+export default async function VerifyRequestPage() {
+ const { t } = await getI18n();
return (
@@ -16,14 +21,13 @@ export default function VerifyRequestPage() {
-
Check your email
+
{t("Check your email")}
- We sent you a secure sign-in link. Open it on this device to
- continue.
+ {t("We sent you a secure sign-in link. Open it on this device to continue.")}
- Back to sign in
+ {t("Back to sign in")}
diff --git a/components/account-select.tsx b/components/account-select.tsx
index de6f5a554..3d0e66d0d 100644
--- a/components/account-select.tsx
+++ b/components/account-select.tsx
@@ -1,5 +1,8 @@
"use client";
+import { useI18n } from "@/lib/i18n/provider";
+
+
export interface AccountOption {
id: string;
username: string;
@@ -20,19 +23,20 @@ export default function AccountSelect({
value,
onChange,
includeAll = true,
- label = "Instagram account",
+ label,
}: AccountSelectProps) {
+ const { t } = useI18n();
return (
- {label}
+ {label ?? t("Instagram account")}
onChange(event.target.value)}
className="min-w-52 rounded-xl border border-border bg-surface px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-accent/40"
>
- {includeAll && All accounts }
+ {includeAll && {t("All accounts")} }
{accounts.map((account) => (
@{account.username}
diff --git a/components/campaign-builder.tsx b/components/campaign-builder.tsx
index ba97b280b..7a98ba3eb 100644
--- a/components/campaign-builder.tsx
+++ b/components/campaign-builder.tsx
@@ -12,6 +12,7 @@
* follow / email / follow-up steps arrive in later turns.
*/
+import { useI18n } from "@/lib/i18n/provider";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import AccountSelect, { type AccountOption } from "@/components/account-select";
@@ -130,6 +131,7 @@ function Toggle({
}
export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderProps) {
+ const { t } = useI18n();
const router = useRouter();
const [loading, setLoading] = useState(mode === "edit");
@@ -386,14 +388,14 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
async function handleSubmit(activeValue: boolean) {
setError(null);
- if (!selectedAccountId) return setError("Connect an Instagram account first.");
+ if (!selectedAccountId) return setError(t("Connect an Instagram account first."));
if (triggerScope === "specific" && !postId)
- return setError("Pick a post or reel to trigger the campaign.");
+ return setError(t("Pick a post or reel to trigger the campaign."));
if (matchMode === "specific" && keywords.length === 0)
- return setError("Add at least one keyword, or switch to any word.");
- if (!dmMessage.trim()) return setError("Add the DM with the link.");
+ return setError(t("Add at least one keyword, or switch to any word."));
+ if (!dmMessage.trim()) return setError(t("Add the DM with the link."));
if (openingDmEnabled && (!openingDmMessage.trim() || !openingDmButtonLabel.trim()))
- return setError("Your opening DM needs a message and a button label.");
+ return setError(t("Your opening DM needs a message and a button label."));
setSaving(true);
@@ -492,13 +494,13 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
setError(
firstField
? `${firstField}: ${fieldErrors[firstField][0]}`
- : data.error ?? "Failed to save campaign"
+ : data.error ?? t("Failed to save campaign")
);
if (typeof window !== "undefined")
window.scrollTo({ top: 0, behavior: "smooth" });
}
} catch {
- setError("Failed to save campaign");
+ setError(t("Failed to save campaign"));
} finally {
setSaving(false);
}
@@ -539,12 +541,12 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
if (notFound) {
return (
-
Campaign not found.
+
{t("Campaign not found.")}
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")}
);
@@ -555,11 +557,10 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
{importQueue && (
- Importing {importTotal - importQueue.length + 1} of {importTotal}.
+ {t("Importing {current} of {total}.", { current: importTotal - importQueue.length + 1, total: importTotal })}
{" "}
- Fields are prefilled from your CSV. Pick the reel, edit anything, and
- save to load the next one — or Skip if you don’t want this one.
+ {t("Fields are prefilled from your CSV. Pick the reel, edit anything, and save to load the next one — or Skip if you don’t want this one.")}
)}
@@ -570,18 +571,18 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
{mode === "edit" ? (
<>
- {name || "Untitled campaign"}
+ {name || t("Untitled campaign")}
- {isActive ? "LIVE" : "PAUSED"}
+ {isActive ? t("LIVE") : t("PAUSED")}
>
) : (
- New campaign
+ {t("New campaign")}
)}
@@ -592,7 +593,7 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
disabled={saving}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:text-foreground disabled:opacity-50"
>
- {importQueue.length > 1 ? "Skip" : "Skip & finish"}
+ {importQueue.length > 1 ? t("Skip") : t("Skip & finish")}
)}
{mode === "edit" &&
@@ -603,7 +604,7 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
disabled={saving}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:text-foreground disabled:opacity-50"
>
- Stop
+ {t("Stop")}
) : (
- Go Live
+ {t("Go Live")}
))}
- {saving ? "Saving…" : mode === "new" ? "Go Live" : "Save changes"}
+ {saving ? t("Saving…") : mode === "new" ? t("Go Live") : t("Save changes")}
@@ -639,13 +640,13 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
- Campaign name{" "}
- (optional)
+ {t("Campaign name")}{" "}
+ {t("(optional)")}
setName(e.target.value)}
- placeholder="e.g. YC referral"
+ placeholder={t("e.g. YC referral")}
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"
maxLength={100}
/>
@@ -661,18 +662,18 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
setPostThumb(null);
}}
includeAll={false}
- label="Instagram account"
+ label={t("Instagram account")}
/>
)}
-
+
setTriggerScope("specific")}
>
- a specific post or reel
+ {t("a specific post or reel")}
{triggerScope === "specific" && (
@@ -688,44 +689,44 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
checked={triggerScope === "any"}
onSelect={() => setTriggerScope("any")}
>
- any post or reel
+ {t("any post or reel")}
setTriggerScope("next")}
>
- next post or reel
+ {t("next post or reel")}
-
+
setMatchMode("specific")}
>
- a specific word or words
+ {t("a specific word or words")}
{matchMode === "specific" && (
setKeywordText(e.target.value)}
- placeholder="Enter a word or multiple"
+ placeholder={t("Enter a word or multiple")}
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"
/>
-
Use commas to separate words
+
{t("Use commas to separate words")}
)}
setMatchMode("any")}
>
- any word
+ {t("any word")}
- also reply when someone DMs{" "}
- {matchMode === "any" ? "anything" : "these words"}
+ {t("also reply when someone DMs")}{" "}
+ {matchMode === "any" ? t("anything") : t("these words")}
{matchMode === "any"
- ? "Every DM to this account gets the reply below — use with care."
- : "A DM containing any of these words gets the same reply, no comment needed."}
+ ? t("Every DM to this account gets the reply below — use with care.")
+ : t("A DM containing any of these words gets the same reply, no comment needed.")}
)}
- reply to their comments under the post
+ {t("reply to their comments under the post")}
(idx === i ? e.target.value : m))
)
}
- placeholder="Sent you a DM! 📩"
+ placeholder={t("Sent you a DM! 📩")}
maxLength={1000}
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"
/>
@@ -772,7 +773,7 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
)
}
className="shrink-0 px-2 text-muted hover:text-error"
- aria-label="Remove reply"
+ aria-label={t("Remove reply")}
>
✕
@@ -787,21 +788,20 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
}
className="text-xs font-medium text-accent hover:underline"
>
- + Add another reply
+ {t("+ Add another reply")}
)}
- One is picked at random each time, so replies don't look
- identical.
+ {t("One is picked at random each time, so replies don't look identical.")}
)}
-
+
-
an opening DM
+
{t("an opening DM")}
setOpeningDmEnabled(!openingDmEnabled)}
@@ -812,7 +812,7 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
)}
@@ -983,7 +980,7 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
{/* Right: preview */}
-
Preview
+
{t("Preview")}
part === "{link}" ? (
@@ -105,7 +108,7 @@ function renderMessage(text: string, hasLink: boolean, linkUrl?: string) {
}
>
{/* Show the actual link being sent, not a placeholder token. */}
- {linkUrl || (hasLink ? "your link" : "{link}")}
+ {linkUrl || (hasLink ? linkPlaceholder : "{link}")}
) : (
{part}
@@ -189,6 +192,7 @@ function PostScreen({
postThumb: string | null;
caption: string;
}) {
+ const { t } = useI18n();
return (
@@ -196,7 +200,7 @@ function PostScreen({
{Ico.back("h-5 w-5")}
{username}
-
Posts
+
{t("Posts")}
@@ -220,10 +224,10 @@ function PostScreen({
{username} {" "}
- {caption || "Applications close rly soon!!"}
+ {caption || t("Applications close rly soon!!")}
- View all comments
+ {t("View all comments")}
{Ico.home("h-6 w-6")}
@@ -249,6 +253,7 @@ function CommentsScreen({
publicReplyEnabled: boolean;
publicReplyMessage: string;
}) {
+ const { t } = useI18n();
const reactions = ["❤️", "🙌", "🔥", "👏", "😢", "😍", "😮", "😂"];
return (
@@ -256,17 +261,17 @@ function CommentsScreen({
-
Comments
+
{t("Comments")}
{SAMPLE_USER} {" "}
- Now
+ {t("Now")}
{sampleComment || "yc"}
-
Reply
+
{t("Reply")}
{Ico.heart("h-3.5 w-3.5 text-zinc-500")}
@@ -277,10 +282,10 @@ function CommentsScreen({
{username} {" "}
- Now
+ {t("Now")}
-
{publicReplyMessage || "Sent you a DM! 📩"}
-
Reply
+
{publicReplyMessage || t("Sent you a DM! 📩")}
+
{t("Reply")}
{Ico.heart("h-3.5 w-3.5 text-zinc-500")}
@@ -295,7 +300,7 @@ function CommentsScreen({
- Add a comment for {username}…
+ {t("Add a comment for")} {username}…
@@ -344,6 +349,7 @@ function DmScreen({
// Present on the keyword-trigger thread: the DM the user sends to start it.
inboundMessage?: string;
}) {
+ const { t } = useI18n();
return (
@@ -361,7 +367,7 @@ function DmScreen({
{inboundMessage !== undefined && (
- {inboundMessage || "their message"}
+ {inboundMessage || t("their message")}
)}
@@ -370,15 +376,15 @@ function DmScreen({
-
{openingDmMessage || "Your opening message…"}
+
{openingDmMessage || t("Your opening message…")}
- {openingDmButtonLabel || "Button label"}
+ {openingDmButtonLabel || t("Button label")}
- {openingDmButtonLabel || "Button label"}
+ {openingDmButtonLabel || t("Button label")}
>
@@ -418,10 +424,10 @@ function DmScreen({
{(!showCard || bodyText) && (
{!revealMessage
- ? "Write a message"
+ ? t("Write a message")
: showCard
? bodyText
- : renderMessage(revealMessage, hasLink, linkUrl)}
+ : renderMessage(revealMessage, hasLink, linkUrl, t("your link"))}
)}
{showCard && (
@@ -444,7 +450,7 @@ function DmScreen({
<>
{followUpDelayMinutes > 0 && (
- {followUpDelayMinutes} min later
+ {followUpDelayMinutes} {t("min later")}
)}
@@ -453,7 +459,7 @@ function DmScreen({
{followUpMessage.trim()
? followUpMessage.replace(/\{username\}/g, SAMPLE_USER)
- : "Btw just wanted to say thanks for following me, I appreciate the support 🙌"}
+ : t("Btw just wanted to say thanks for following me, I appreciate the support 🙌")}
@@ -465,7 +471,7 @@ function DmScreen({
{Ico.camera("h-4 w-4")}
-
Message…
+
{t("Message…")}
);
@@ -474,13 +480,14 @@ function DmScreen({
/* ----------------------------- root ----------------------------- */
export default function CampaignPreview(props: CampaignPreviewProps) {
+ const { t } = useI18n();
const { tab, onTabChange } = props;
const tabs: { key: PreviewTab; label: string }[] = [
- { key: "post", label: "Post" },
- { key: "comments", label: "Comments" },
+ { key: "post", label: t("Post") },
+ { key: "comments", label: t("Comments") },
{ key: "dm", label: "DM" },
...(props.dmTriggerEnabled
- ? [{ key: "dmTrigger" as const, label: "DM trigger" }]
+ ? [{ key: "dmTrigger" as const, label: t("DM trigger") }]
: []),
];
diff --git a/components/demo-notice.tsx b/components/demo-notice.tsx
index b55deca0d..81a0137db 100644
--- a/components/demo-notice.tsx
+++ b/components/demo-notice.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useI18n } from "@/lib/i18n/provider";
import { useSyncExternalStore } from "react";
import { DEMO_HOST } from "@/lib/env";
@@ -53,6 +54,7 @@ function dismiss() {
}
export function DemoNotice({ variant }: { variant: "banner" | "panel" }) {
+ const { t } = useI18n();
const visible = useSyncExternalStore(
subscribe,
getSnapshot,
@@ -65,23 +67,21 @@ export function DemoNotice({ variant }: { variant: "banner" | "panel" }) {
return (
- {DEMO_HOST} is a
- demo. OpenReply is self-hosted — signing in here will not send DMs for
- your account.{" "}
+ {DEMO_HOST} {t("is a demo. OpenReply is self-hosted — signing in here will not send DMs for your account.")}{" "}
- Deploy your own copy
+ {t("Deploy your own copy")}
.
@@ -93,24 +93,22 @@ export function DemoNotice({ variant }: { variant: "banner" | "panel" }) {
return (
- {DEMO_HOST} is a demo instance. {" "}
- Signing in here will not send DMs for your Instagram account. OpenReply
- is self-hosted, so it only works on a deployment you run yourself, with
- your own Meta app and your own domain.{" "}
+ {DEMO_HOST} {t("is a demo instance.")} {" "}
+ {t("Signing in here will not send DMs for your Instagram account. OpenReply is self-hosted, so it only works on a deployment you run yourself, with your own Meta app and your own domain.")}{" "}
- Read the setup guide
+ {t("Read the setup guide")}
.
diff --git a/components/follower-chart.tsx b/components/follower-chart.tsx
index 8d4af5f15..38d04c570 100644
--- a/components/follower-chart.tsx
+++ b/components/follower-chart.tsx
@@ -12,6 +12,8 @@
* already running then.
*/
+import type { Locale } from "@/lib/i18n";
+import { useI18n } from "@/lib/i18n/provider";
import { useState } from "react";
import {
CartesianGrid,
@@ -35,22 +37,22 @@ const SERIES_COLOR = "#f97316";
const GRID_COLOR = "#e4e4e7";
const AXIS_TEXT = "#71717a";
-function formatCompact(n: number): string {
+function formatCompact(n: number, locale: Locale): string {
if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (Math.abs(n) >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
- return n.toLocaleString();
+ return n.toLocaleString(locale);
}
-function formatDay(iso: string): string {
- return new Date(`${iso}T00:00:00Z`).toLocaleDateString(undefined, {
+function formatDay(iso: string, locale: Locale): string {
+ return new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, {
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
-function formatSigned(n: number): string {
- return `${n > 0 ? "+" : ""}${n.toLocaleString()}`;
+function formatSigned(n: number, locale: Locale): string {
+ return `${n > 0 ? "+" : ""}${n.toLocaleString(locale)}`;
}
function ChartTooltip({
@@ -60,18 +62,19 @@ function ChartTooltip({
active?: boolean;
payload?: Array<{ payload: FollowerChartPoint }>;
}) {
+ const { t, locale } = useI18n();
if (!active || !payload?.length) return null;
const point = payload[0].payload;
return (
-
{formatDay(point.date)}
+
{formatDay(point.date, locale)}
- {point.followers.toLocaleString()} followers
+ {point.followers.toLocaleString(locale)} {t("followers")}
{point.delta !== null && point.delta !== 0 && (
0 ? "text-success" : "text-error"}>
- {formatSigned(point.delta)} that day
+ {formatSigned(point.delta, locale)} {t("that day")}
)}
@@ -85,6 +88,7 @@ export default function FollowerChart({
data: FollowerChartPoint[];
followers: number | null;
}) {
+ const { t, locale } = useI18n();
const [showTable, setShowTable] = useState(false);
const current = followers ?? data.at(-1)?.followers ?? null;
@@ -99,19 +103,19 @@ export default function FollowerChart({
- Followers over time
+ {t("Followers over time")}
{current === null
- ? "Follower count unavailable"
- : `${current.toLocaleString()} now`}
+ ? t("Follower count unavailable")
+ : t("{count} now", { count: current.toLocaleString(locale) })}
{net !== null && (
<>
{" · "}
= 0 ? "text-success" : "text-error"}>
- {formatSigned(net)}
+ {formatSigned(net, locale)}
{" "}
- over {data.length} days
+ {t("over {count} days", { count: data.length })}
>
)}
@@ -122,20 +126,19 @@ export default function FollowerChart({
onClick={() => setShowTable((v) => !v)}
className="rounded border border-border px-3 py-1.5 text-xs font-medium text-muted transition-colors hover:border-border-hover hover:text-foreground"
>
- {showTable ? "Show chart" : "Show table"}
+ {showTable ? t("Show chart") : t("Show table")}
)}
{data.length < 2 ? (
-
Collecting follower history
+
{t("Collecting follower history")}
{data.length === 0
- ? "No snapshots recorded yet."
- : "One day recorded so far."}{" "}
- A point is added daily — the chart appears once there are at least
- two.
+ ? t("No snapshots recorded yet.")
+ : t("One day recorded so far.")}{" "}
+ {t("A point is added daily — the chart appears once there are at least two.")}
) : showTable ? (
@@ -143,22 +146,22 @@ export default function FollowerChart({
- Date
- Followers
- Change
+ {t("Date")}
+ {t("Followers")}
+ {t("Change")}
{[...data].reverse().map((p) => (
- {formatDay(p.date)}
+ {formatDay(p.date, locale)}
- {p.followers.toLocaleString()}
+ {p.followers.toLocaleString(locale)}
- {p.delta === null ? "—" : formatSigned(p.delta)}
+ {p.delta === null ? "—" : formatSigned(p.delta, locale)}
))}
@@ -179,14 +182,14 @@ export default function FollowerChart({
/>
formatDay(value, locale)}
tick={{ fill: AXIS_TEXT, fontSize: 12 }}
stroke={GRID_COLOR}
tickLine={false}
minTickGap={24}
/>
formatCompact(value, locale)}
tick={{ fill: AXIS_TEXT, fontSize: 12 }}
stroke={GRID_COLOR}
tickLine={false}
diff --git a/components/instagram-connect-notice.tsx b/components/instagram-connect-notice.tsx
index 922edc0dc..1b2b4f0b7 100644
--- a/components/instagram-connect-notice.tsx
+++ b/components/instagram-connect-notice.tsx
@@ -1,5 +1,7 @@
"use client";
+import type { StaticMessageKey } from "@/lib/i18n";
+import { useI18n } from "@/lib/i18n/provider";
import { useSearchParams } from "next/navigation";
type Tone = "error" | "warning" | "success";
@@ -10,7 +12,7 @@ const TONE_CLASSES: Record = {
success: "border-success/20 bg-success/10 text-success",
};
-const MESSAGES: Record = {
+const MESSAGES: Record = {
denied: {
tone: "warning",
title: "Instagram connection cancelled",
@@ -38,6 +40,7 @@ const MESSAGES: Record =
};
export function InstagramConnectNotice() {
+ const { t } = useI18n();
const searchParams = useSearchParams();
const status = searchParams.get("instagram");
@@ -49,13 +52,13 @@ export function InstagramConnectNotice() {
.filter(Boolean);
return (
-
+
- Set{" "}
+ {t("Set")}{" "}
{missing.length > 0
- ? "these environment variables"
- : "the required environment variables"}{" "}
- and restart the server:
+ ? t("these environment variables")
+ : t("the required environment variables")}{" "}
+ {t("and restart the server:")}
{missing.length > 0 && (
@@ -67,10 +70,8 @@ export function InstagramConnectNotice() {
)}
- See docs/setup.md for how to
- obtain each value. Note that{" "}
- ENCRYPTION_KEY must be a
- 64-character hex string.
+ {t("See")} docs/setup.md {t("for how to obtain each value. Note that")}{" "}
+ ENCRYPTION_KEY {t("must be a 64-character hex string.")}
);
@@ -80,11 +81,9 @@ export function InstagramConnectNotice() {
const reason = searchParams.get("reason");
return (
-
+
- Instagram accepted the login but the connection could not be
- completed. This is usually a mismatched redirect URI or an app that is
- missing the required permissions.
+ {t("Instagram accepted the login but the connection could not be completed. This is usually a mismatched redirect URI or an app that is missing the required permissions.")}
{reason && (
@@ -99,8 +98,8 @@ export function InstagramConnectNotice() {
if (!known) return null;
return (
-
- {known.detail}
+
+ {t(known.detail)}
);
}
diff --git a/components/invitation-accept-card.tsx b/components/invitation-accept-card.tsx
index c6c67b5c1..827d96820 100644
--- a/components/invitation-accept-card.tsx
+++ b/components/invitation-accept-card.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useI18n } from "@/lib/i18n/provider";
import { useState } from "react";
interface InvitationAcceptCardProps {
@@ -13,6 +14,7 @@ export default function InvitationAcceptCard({
isSignedIn,
invitedEmail,
}: InvitationAcceptCardProps) {
+ const { t } = useI18n();
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState(null);
@@ -29,7 +31,7 @@ export default function InvitationAcceptCard({
window.location.assign("/dashboard");
return;
}
- setMessage(payload.error ?? "Could not accept invitation");
+ setMessage(payload.error ?? t("Could not accept invitation"));
setBusy(false);
}
@@ -39,7 +41,7 @@ export default function InvitationAcceptCard({
href="/login"
className="inline-flex items-center justify-center rounded-xl bg-accent px-5 py-3 text-sm font-semibold text-white transition hover:bg-accent-hover"
>
- Sign in to accept
+ {t("Sign in to accept")}
);
}
@@ -52,11 +54,11 @@ export default function InvitationAcceptCard({
disabled={busy}
className="inline-flex items-center justify-center rounded-xl bg-accent px-5 py-3 text-sm font-semibold text-white transition hover:bg-accent-hover disabled:opacity-50"
>
- {busy ? "Accepting..." : "Accept invitation"}
+ {busy ? t("Accepting...") : t("Accept invitation")}
{message && {message}
}
- Use the magic link account for {invitedEmail}.
+ {t("Use the magic link account for")} {invitedEmail}.
);
diff --git a/components/keyword-input.tsx b/components/keyword-input.tsx
index 1b1207e78..d73054325 100644
--- a/components/keyword-input.tsx
+++ b/components/keyword-input.tsx
@@ -6,6 +6,7 @@
* Tag-style input for adding/removing keywords.
*/
+import { useI18n } from "@/lib/i18n/provider";
import { useState, type KeyboardEvent } from "react";
interface KeywordInputProps {
@@ -15,6 +16,7 @@ interface KeywordInputProps {
}
export default function KeywordInput({ keywords, onChange, max = 10 }: KeywordInputProps) {
+ const { t } = useI18n();
const [input, setInput] = useState("");
function addKeyword(value: string) {
@@ -52,10 +54,10 @@ export default function KeywordInput({ keywords, onChange, max = 10 }: KeywordIn
removeKeyword(keyword)}
- aria-label={`Remove ${keyword}`}
+ aria-label={t("Remove {keyword}", { keyword })}
className="text-muted hover:text-error"
>
- Remove
+ {t("Remove")}
))}
@@ -64,12 +66,12 @@ export default function KeywordInput({ keywords, onChange, max = 10 }: KeywordIn
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
- placeholder={keywords.length === 0 ? "Type keyword and press Enter..." : ""}
+ placeholder={keywords.length === 0 ? t("Type keyword and press Enter...") : ""}
className="flex-1 min-w-[120px] bg-transparent text-sm text-foreground placeholder:text-zinc-500 outline-none"
/>
- {keywords.length}/{max} keywords · Press Enter or comma to add
+ {keywords.length}/{max} {t("keywords · Press Enter or comma to add")}
);
diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx
new file mode 100644
index 000000000..a62bea1db
--- /dev/null
+++ b/components/language-switcher.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { setLocale } from "@/lib/i18n/actions";
+import { useI18n } from "@/lib/i18n/provider";
+
+export default function LanguageSwitcher() {
+ const { locale, t } = useI18n();
+ const [pending, startTransition] = useTransition();
+ const [failed, setFailed] = useState(false);
+
+ return (
+
+
+ {t("Language")}
+ {
+ const nextLocale = event.target.value;
+ setFailed(false);
+ startTransition(async () => {
+ try {
+ await setLocale(nextLocale);
+ } catch {
+ setFailed(true);
+ }
+ });
+ }}
+ className="min-h-9 rounded border border-border bg-surface px-2 text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:opacity-60"
+ >
+
+ English
+
+
+ 繁體中文
+
+
+
+ {failed && (
+
+ {t("Could not change language. Please try again.")}
+
+ )}
+
+ );
+}
diff --git a/components/localized-layout.tsx b/components/localized-layout.tsx
new file mode 100644
index 000000000..4fde69df8
--- /dev/null
+++ b/components/localized-layout.tsx
@@ -0,0 +1,20 @@
+import { I18nProvider } from "@/lib/i18n/provider";
+import { getI18n } from "@/lib/i18n/server";
+import LanguageSwitcher from "@/components/language-switcher";
+
+// Keep request cookies below the root layout so marketing pages stay static.
+export default async function LocalizedLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const { locale } = await getI18n();
+ return (
+
+
+
+
+ {children}
+
+ );
+}
diff --git a/components/post-picker.tsx b/components/post-picker.tsx
index e55858aaa..647eeb20a 100644
--- a/components/post-picker.tsx
+++ b/components/post-picker.tsx
@@ -9,6 +9,7 @@
* Fetches from /api/instagram/posts.
*/
+import { useI18n } from "@/lib/i18n/provider";
import { useEffect, useState } from "react";
import { readCache, writeCache } from "@/lib/client-cache";
@@ -43,6 +44,7 @@ export default function PostPicker({
usedPostIds,
onSelect,
}: PostPickerProps) {
+ const { t } = useI18n();
const [limitations, setLimitations] = useState([]);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
@@ -113,8 +115,8 @@ export default function PostPicker({
if (error) {
return (
-
{error}
-
Connect your Instagram account first
+
{error === "Failed to load posts" ? t("Failed to load posts") : error}
+
{t("Connect your Instagram account first")}
);
}
@@ -122,7 +124,7 @@ export default function PostPicker({
if (posts.length === 0) {
return (
-
No posts found
+
{t("No posts found")}
);
}
@@ -149,21 +151,21 @@ export default function PostPicker({
// cleared, which is the case this whole change exists to avoid.
setShown(PAGE_SIZE);
}}
- placeholder="Search your posts by caption…"
+ placeholder={t("Search your posts by caption…")}
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"
/>
{posts.length}
{visible.length === 0 ? (
- No posts match “{query}”
+ {t("No posts match “")}{query}{t("”")}
) : (
<>
{usedPostIds && Object.keys(usedPostIds).length > 0 && (
- Already used
+ {t("Already used")}
)}
{/* auto-rows-min + content-start keep each row at its natural height.
@@ -188,7 +190,7 @@ export default function PostPicker({
setHoveredId((cur) => (cur === post.id ? null : cur))
}
aria-pressed={isSelected}
- title={isUsed ? `Already used by "${usedByName}"` : undefined}
+ title={isUsed && usedByName ? t("Already used by \"{name}\"", { name: usedByName }) : undefined}
className={`
relative aspect-square rounded overflow-hidden border-2
${
@@ -203,14 +205,14 @@ export default function PostPicker({
{thumb ? (
) : (
- No image
+ {t("No image")}
)}
{showVideo && (
@@ -229,7 +231,7 @@ export default function PostPicker({
)}
{isSelected && (
- Selected
+ {t("Selected")}
)}
@@ -242,7 +244,7 @@ export default function PostPicker({
onClick={() => setShown((n) => n + PAGE_SIZE)}
className="w-full rounded-lg border border-border py-2 text-sm text-muted hover:text-foreground"
>
- Show {Math.min(PAGE_SIZE, remaining)} more
+ {t("Show")} {Math.min(PAGE_SIZE, remaining)} {t("more")}
)}
>
diff --git a/components/sidebar.tsx b/components/sidebar.tsx
index 2b17c5431..f092209de 100644
--- a/components/sidebar.tsx
+++ b/components/sidebar.tsx
@@ -6,6 +6,8 @@
* Text-only nav with active state and workspace section.
*/
+import LanguageSwitcher from "@/components/language-switcher";
+import { useI18n } from "@/lib/i18n/provider";
import Link from "next/link";
import Image from "next/image";
import { zernioLink } from "@/lib/zernio-links";
@@ -19,7 +21,7 @@ const navItems = [
{ label: "DM Logs", href: "/logs" },
{ label: "Settings", href: "/settings" },
{ label: "Diagnostics", href: "/diagnostics" },
-];
+] as const;
interface SidebarProps {
isOpen: boolean;
@@ -32,6 +34,7 @@ export default function Sidebar({
onClose,
workspaceName,
}: SidebarProps) {
+ const { t } = useI18n();
const pathname = usePathname();
return (
@@ -82,22 +85,23 @@ export default function Sidebar({
}
`}
>
- {item.label}
+ {t(item.label)}
);
})}
diff --git a/components/status-badge.tsx b/components/status-badge.tsx
index 9804852ac..b43c37aaa 100644
--- a/components/status-badge.tsx
+++ b/components/status-badge.tsx
@@ -1,8 +1,12 @@
+"use client";
+
+import type { StaticMessageKey } from "@/lib/i18n";
+import { useI18n } from "@/lib/i18n/provider";
/**
* Status label for DM status. Plain text; color carries the state.
*/
-const statusConfig: Record = {
+const statusConfig: Record = {
SENT: { text: "text-success", label: "Sent" },
FAILED: { text: "text-error", label: "Failed" },
PENDING: { text: "text-warning", label: "Pending" },
@@ -17,11 +21,12 @@ interface StatusBadgeProps {
}
export default function StatusBadge({ status }: StatusBadgeProps) {
+ const { t } = useI18n();
const config = statusConfig[status] ?? statusConfig.PENDING;
return (
- {config.label}
+ {t(config.label)}
);
}
diff --git a/components/top-bar.tsx b/components/top-bar.tsx
index 234cbacbf..8275bfd11 100644
--- a/components/top-bar.tsx
+++ b/components/top-bar.tsx
@@ -6,10 +6,15 @@
* Page title, mobile hamburger, and connection status.
*/
+import type { StaticMessageKey } from "@/lib/i18n";
+import { useI18n } from "@/lib/i18n/provider";
import { usePathname } from "next/navigation";
-const pageTitles: Record = {
+const pageTitles: Record = {
"/dashboard": "Dashboard",
+ "/overview": "Overview",
+ "/inbox": "Inbox",
+ "/campaigns/import": "Import campaigns",
"/campaigns": "Campaigns",
"/campaigns/new": "New Campaign",
"/automations": "Campaigns",
@@ -30,8 +35,12 @@ export default function TopBar({
instagramUsername,
instagramAccountCount,
}: TopBarProps) {
+ const { t } = useI18n();
const pathname = usePathname();
- const title = pageTitles[pathname] ?? "Dashboard";
+ const title: StaticMessageKey = pageTitles[pathname] ?? (
+ pathname.endsWith("/edit") ? "Edit campaign"
+ : pathname.startsWith("/campaigns/") ? "Campaign details" : "Dashboard"
+ );
return (
diff --git a/components/zernio-connection.tsx b/components/zernio-connection.tsx
index 4d99f00bb..ab93098af 100644
--- a/components/zernio-connection.tsx
+++ b/components/zernio-connection.tsx
@@ -1,5 +1,8 @@
+"use client";
+
'use client';
+import { useI18n } from "@/lib/i18n/provider";
import { useCallback, useEffect, useState } from 'react';
import Image from 'next/image';
import { zernioLink } from '@/lib/zernio-links';
@@ -11,6 +14,7 @@ type ConnectionData = {
};
export function ZernioConnection({ canManage }: { canManage: boolean }) {
+ const { t } = useI18n();
const [data, setData] = useState(null);
const [apiKey, setApiKey] = useState('');
const [profileId, setProfileId] = useState('');
@@ -28,7 +32,7 @@ export function ZernioConnection({ canManage }: { canManage: boolean }) {
void fetch('/api/zernio/settings', { cache: 'no-store' }).then(r => r.json()).then(result => {
if (!result.success) throw new Error(result.error);
setData(result.data); setProfileId(result.data.profileId ?? '');
- }).catch(e => setError(e instanceof Error ? e.message : 'Could not load connection.'));
+ }).catch(e => setError(e instanceof Error ? e.message : "Could not load connection."));
}
}, [canManage, refresh]);
@@ -42,40 +46,40 @@ export function ZernioConnection({ canManage }: { canManage: boolean }) {
if (result.data?.authUrl) { window.location.assign(result.data.authUrl); return; }
if (path === 'accounts') { window.location.reload(); return; }
await refresh();
- } catch (e) { setError(e instanceof Error ? e.message : 'Could not update connection.'); }
+ } catch (e) { setError(e instanceof Error ? e.message : t("Could not update connection.")); }
finally { setBusy(false); }
}
return (
-
Easier Instagram setup Optional connection provider
-
+
{t("Easier Instagram setup")} {t("Optional connection provider")}
+
- Connect Instagram without creating your own Meta developer app. Zernio is a paid service and an OpenReply sponsor. Your campaigns and hosting stay in OpenReply.
- Get a Zernio API key View pricing
- {!canManage ? Ask your workspace owner or admin to configure Zernio.
: <>
- {error && {error}
}
+ {t("Connect Instagram without creating your own Meta developer app. Zernio is a paid service and an OpenReply sponsor. Your campaigns and hosting stay in OpenReply.")}
+ {t("Get a Zernio API key")} {t("View pricing")}
+ {!canManage ? {t("Ask your workspace owner or admin to configure Zernio.")}
: <>
+ {error && {error === "Could not load connection." ? t("Could not load connection.") : error}
}
{!data?.configured ? { e.preventDefault(); void act({ method: 'POST', body: { apiKey } }); }}>
- Zernio API key
+ {t("Zernio API key")}
setApiKey(e.target.value)} required className="w-full rounded-lg border border-zernio-border bg-white px-3 py-2 text-sm" />
- Use an unrestricted, read-write key with Inbox access. OpenReply registers a webhook for this workspace. The key is encrypted and never shown again.
- {busy ? 'Saving…' : 'Save API key'}
+ {t("Use an unrestricted, read-write key with Inbox access. OpenReply registers a webhook for this workspace. The key is encrypted and never shown again.")}
+ {busy ? t("Saving…") : t("Save API key")}
:
-
API key saved securely.
+
{t("API key saved securely.")}
{ e.preventDefault(); void act({ method: 'PUT', body: { profileId } }); }}>
- Zernio profile
- setProfileId(e.target.value)} required className="w-full rounded-lg border border-zernio-border bg-white px-3 py-2 text-sm">Select a profile {data.profiles.map(p => {p.name} )}
- {!data.profiles.length && Create a profile in Zernio, then refresh this page.
}
- {busy ? 'Configuring…' : data.webhookReady && profileId === data.profileId ? 'Repair webhook connection' : 'Save profile and configure webhook'}
+ {t("Zernio profile")}
+ setProfileId(e.target.value)} required className="w-full rounded-lg border border-zernio-border bg-white px-3 py-2 text-sm">{t("Select a profile")} {data.profiles.map(p => {p.name} )}
+ {!data.profiles.length && {t("Create a profile in Zernio, then refresh this page.")}
}
+ {busy ? t("Configuring…") : data.webhookReady && profileId === data.profileId ? t("Repair webhook connection") : t("Save profile and configure webhook")}
{data.webhookReady &&
-
Webhook configured. Choose an Instagram account for OpenReply:
- {data.accounts.map(a =>
@{a.username} void act({ path: 'accounts', method: 'POST', body: { accountId: a.id } })} className="rounded-lg border border-zernio-border bg-white px-3 py-2 disabled:opacity-50">{a.connected ? 'Connected' : 'Use in OpenReply'}
)}
-
void act({ path: 'connect', method: 'POST' })} className="rounded-lg border border-zernio-border bg-white px-3 py-2 text-sm disabled:opacity-50">Connect another Instagram account
-
After connecting Instagram, return here and select it for OpenReply. Keep Zernio automations off for these campaigns to avoid sending twice.
+
{t("Webhook configured. Choose an Instagram account for OpenReply:")}
+ {data.accounts.map(a =>
@{a.username} void act({ path: 'accounts', method: 'POST', body: { accountId: a.id } })} className="rounded-lg border border-zernio-border bg-white px-3 py-2 disabled:opacity-50">{a.connected ? t("Connected") : t("Use in OpenReply")}
)}
+
void act({ path: 'connect', method: 'POST' })} className="rounded-lg border border-zernio-border bg-white px-3 py-2 text-sm disabled:opacity-50">{t("Connect another Instagram account")}
+
{t("After connecting Instagram, return here and select it for OpenReply. Keep Zernio automations off for these campaigns to avoid sending twice.")}
}
-
{ if (confirm('Remove the Zernio key and this OpenReply webhook? Disconnect its Instagram accounts from OpenReply first.')) void act({ method: 'DELETE' }); }} className="text-xs underline underline-offset-4 disabled:opacity-50">Remove Zernio connection
+
{ if (confirm(t("Remove the Zernio key and this OpenReply webhook? Disconnect its Instagram accounts from OpenReply first."))) void act({ method: 'DELETE' }); }} className="text-xs underline underline-offset-4 disabled:opacity-50">{t("Remove Zernio connection")}
}
>}
diff --git a/docs/localization.md b/docs/localization.md
new file mode 100644
index 000000000..665d0295f
--- /dev/null
+++ b/docs/localization.md
@@ -0,0 +1,56 @@
+# Interface languages
+
+OpenReply defaults to English. Choose **English** or **繁體中文** in the dashboard
+sidebar, under **Settings → Interface language**, or on the sign-in screen.
+The choice is stored in a browser cookie for one year and applies to the
+dashboard, sign-in screens, workspace invitations, and shared campaign reports.
+
+Changing the interface language does not translate campaign names, keywords,
+outgoing messages, button text, imported CSV data, or Instagram content. An
+unsaved campaign stays in the editor when switching languages. Default outgoing
+message content retains its original values in both languages.
+
+Public marketing, template playbooks, legal pages, authentication emails, and
+raw API/provider error details remain in English. Those are outside this
+interface translation's scope.
+
+## Adding or changing copy
+
+- `lib/i18n/zh-TW.json` maps English source copy to Traditional Chinese. Use
+ complete sentences with named placeholders when word order can vary.
+- Client components use `useI18n()`; server components use `await getI18n()`.
+ `t("Hello, {name}!", { name })` checks both the message key and required
+ placeholders at compile time. Never pass user content as a translation key.
+- `label(value)` translates known status, role, and weekday display labels.
+ Unknown values pass through; stored values and API filters stay unchanged.
+- Format dates and numbers with the selected `locale`. Translate input hints,
+ not values already entered by the user.
+- Remove unused catalog entries when removing UI copy. The catalog tests check
+ that translations retain their interpolation fields and contain plain text.
+
+## Rendering and persistence
+
+The locale cookie is validated on the server; missing or unsupported values
+fall back to English. A server action saves it and re-renders the current route
+without remounting the editor. The action sets a same-site, HTTP-only cookie,
+with the secure flag in production. There is no translation dependency, new
+database column, or change to the sending worker.
+
+Providers live in the localized route layouts, below the root layout. This
+keeps the public marketing and template pages statically rendered. Localized
+content has a server-rendered `lang` attribute; the provider also updates the
+document language while mounted and restores it when leaving these routes.
+
+## Validation
+
+Run the contribution checks in `CONTRIBUTING.md`. The locale unit tests cover
+preference validation, persistence, English fallback, interpolation, and status
+labels. For browser verification:
+
+1. Start with no locale cookie: sign-in should display English.
+2. Switch to Traditional Chinese, reload, and navigate to another localized
+ route: the selected language, labels, and dates should remain consistent.
+3. In the campaign editor, change a message and toggle an option without saving.
+ Switch languages: both draft values and the selected post should remain.
+4. Switch back to English, including on a narrow viewport using the sidebar.
+5. Visit a public marketing page: it should remain English.
diff --git a/lib/i18n/actions.ts b/lib/i18n/actions.ts
new file mode 100644
index 000000000..193e87a69
--- /dev/null
+++ b/lib/i18n/actions.ts
@@ -0,0 +1,16 @@
+"use server";
+
+import { cookies } from "next/headers";
+import { isLocale, LOCALE_COOKIE } from "./index";
+
+export async function setLocale(value: string) {
+ if (!isLocale(value)) throw new Error("Unsupported locale");
+ const cookieStore = await cookies();
+ cookieStore.set(LOCALE_COOKIE, value, {
+ path: "/",
+ maxAge: 60 * 60 * 24 * 365,
+ sameSite: "lax",
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ });
+}
diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts
new file mode 100644
index 000000000..77fcba3eb
--- /dev/null
+++ b/lib/i18n/index.ts
@@ -0,0 +1,71 @@
+import zhTW from "./zh-TW.json";
+
+export const LOCALE_COOKIE = "openreply-locale";
+export type Locale = "en" | "zh-TW";
+export type MessageKey = keyof typeof zhTW;
+
+export function isLocale(value: unknown): value is Locale {
+ return value === "en" || value === "zh-TW";
+}
+
+export function resolveLocale(value: unknown): Locale {
+ return isLocale(value) ? value : "en";
+}
+
+type Placeholders =
+ S extends `${string}{${infer Name}}${infer Rest}`
+ ? Name | Placeholders
+ : never;
+export type StaticMessageKey = {
+ [K in MessageKey]: [Placeholders] extends [never] ? K : never;
+}[MessageKey];
+type MessageArgs = [Placeholders] extends [never]
+ ? []
+ : [values: Record, string | number>];
+
+// English is the source language. Only application-owned copy belongs here;
+// campaign messages, account names and API values are never translation keys.
+const labels: Record = {
+ ALL: "All",
+ SENT: "Sent",
+ FAILED: "Failed",
+ PENDING: "Pending",
+ SKIPPED_RATE_LIMIT: "Rate limited",
+ SKIPPED_PLAN_LIMIT: "Plan limit",
+ SKIPPED_DEDUP: "Dedup",
+ OWNER: "Owner",
+ ADMIN: "Admin",
+ MEMBER: "Member",
+ all: "All",
+ active: "Active",
+ paused: "Paused",
+ waiting: "Waiting",
+ delayed: "Delayed",
+ failed: "Failed",
+ Mon: "Mon",
+ Tue: "Tue",
+ Wed: "Wed",
+ Thu: "Thu",
+ Fri: "Fri",
+ Sat: "Sat",
+ Sun: "Sun",
+};
+
+export function createI18n(locale: Locale) {
+ function t(key: K, ...args: MessageArgs): string {
+ const message = locale === "zh-TW" ? zhTW[key] : key;
+ const values = args[0] as Record | undefined;
+ return message.replace(/\{(\w+)\}/g, (placeholder, name: string) =>
+ values?.[name] === undefined ? placeholder : String(values[name]),
+ );
+ }
+
+ return {
+ locale,
+ t,
+ label: (value: string) =>
+ Object.hasOwn(labels, value) ? t(labels[value]) : value,
+ };
+}
+
+export type I18n = ReturnType;
diff --git a/lib/i18n/provider.tsx b/lib/i18n/provider.tsx
new file mode 100644
index 000000000..a6c2a945e
--- /dev/null
+++ b/lib/i18n/provider.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { createContext, useContext, useEffect, useMemo } from "react";
+import { createI18n, type Locale } from "./index";
+
+// Shared components on the English marketing pages work without a provider.
+const I18nContext = createContext(createI18n("en"));
+
+export function I18nProvider({
+ locale,
+ children,
+}: {
+ locale: Locale;
+ children: React.ReactNode;
+}) {
+ const value = useMemo(() => createI18n(locale), [locale]);
+
+ useEffect(() => {
+ const previous = document.documentElement.lang;
+ document.documentElement.lang = locale;
+ return () => {
+ document.documentElement.lang = previous;
+ };
+ }, [locale]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function useI18n() {
+ return useContext(I18nContext);
+}
diff --git a/lib/i18n/server.ts b/lib/i18n/server.ts
new file mode 100644
index 000000000..9c7630471
--- /dev/null
+++ b/lib/i18n/server.ts
@@ -0,0 +1,8 @@
+import { cache } from "react";
+import { cookies } from "next/headers";
+import { createI18n, LOCALE_COOKIE, resolveLocale } from "./index";
+
+export const getI18n = cache(async () => {
+ const cookieStore = await cookies();
+ return createI18n(resolveLocale(cookieStore.get(LOCALE_COOKIE)?.value));
+});
diff --git a/lib/i18n/zh-TW.json b/lib/i18n/zh-TW.json
new file mode 100644
index 000000000..f86ef55dc
--- /dev/null
+++ b/lib/i18n/zh-TW.json
@@ -0,0 +1,450 @@
+{
+ " (capped at {count})": "(最多顯示 {count} 則)",
+ "% CTR": "% 點擊率",
+ "(no text)": "(無文字內容)",
+ "(optional)": "(選填)",
+ "+ Add A Link": "+ 新增連結",
+ "+ Add A Second Link": "+ 新增第二個連結",
+ "+ Add another reply": "+ 新增另一則回覆",
+ ". Keywords go in one cell, separated by commas. Use": "。多個關鍵字請放在同一個儲存格,以逗號分隔。訊息中可使用",
+ ". Optional:": "。選填欄位:",
+ "2 links": "2 個連結",
+ "A DM containing any of these words gets the same reply, no comment needed.": "收到含有任一關鍵字的私訊時,也會傳送相同回覆。",
+ "A point is added daily — the chart appears once there are at least two.": "每天記錄一次,累積至少兩天後即可顯示圖表。",
+ "A sign-in link was sent to your email.": "登入連結已寄到你的電子信箱。",
+ "A specific post or reel": "指定貼文或 Reels",
+ "API key saved securely.": "API 金鑰已安全儲存。",
+ "Accept Workspace Invitation - OpenReply": "接受工作區邀請 - OpenReply",
+ "Accept invitation": "接受邀請",
+ "Accepting...": "正在接受邀請…",
+ "Account": "帳號",
+ "Account already connected": "此帳號已連線",
+ "Accounts": "帳號",
+ "Active": "啟用中",
+ "Active Campaigns": "啟用中的活動",
+ "Active campaign": "活動啟用中",
+ "Add a comment for": "新增留言給",
+ "Add at least one keyword, or switch to any word.": "請新增至少一個關鍵字,或改為「任何文字」。",
+ "Add the DM with the link.": "請填寫要傳送的私訊與連結。",
+ "Admin": "管理員",
+ "After connecting Instagram, return here and select it for OpenReply. Keep Zernio automations off for these campaigns to avoid sending twice.": "連接 Instagram 後,請回到此頁選取帳號。請關閉 Zernio 中對應的自動化,以免重複傳送。",
+ "All": "全部",
+ "All accounts": "全部帳號",
+ "All time": "全部期間",
+ "All-time": "全部",
+ "Already used": "已用於其他活動",
+ "Already used by \"{name}\"": "已用於「{name}」",
+ "Also replies when someone DMs": "收到私訊時也回覆",
+ "And then, they will get": "接著傳送",
+ "And then, they will get a DM": "接著傳送私訊",
+ "And this comment has": "且留言符合以下條件",
+ "Any comment": "任何留言",
+ "Any post or reel": "任何貼文或 Reels",
+ "Applications close rly soon!!": "留言即可收到詳細資訊!",
+ "Ask your workspace owner or admin to configure Zernio.": "請聯絡工作區擁有者或管理員設定 Zernio。",
+ "Back": "返回",
+ "Back to campaigns": "返回活動列表",
+ "Back to conversations": "返回對話列表",
+ "Back to sign in": "返回登入頁",
+ "Btw just wanted to say thanks for following me, I appreciate the support 🙌": "謝謝你的追蹤與支持 🙌",
+ "Built with OpenReply, the Instagram comment-to-DM campaign OS.": "使用 OpenReply 建立 Instagram 留言自動私訊活動。",
+ "Button": "按鈕",
+ "Button label": "按鈕文字",
+ "Button label (e.g. Open link)": "按鈕文字(例如:開啟連結)",
+ "CTR": "點擊率",
+ "Campaign": "活動",
+ "Campaign DM Failures And Skips": "私訊失敗與略過紀錄",
+ "Campaign Setup": "活動設定",
+ "Campaign details": "活動詳情",
+ "Campaign name": "活動名稱",
+ "Campaign not found.": "找不到此活動。",
+ "Campaign post": "活動貼文",
+ "Campaign reel": "活動 Reels",
+ "Campaigns": "自動回覆活動",
+ "Cancel": "取消",
+ "Change": "變化",
+ "Check your email": "請查看電子郵件",
+ "Check your email - OpenReply": "請查看電子郵件 - OpenReply",
+ "Clicks": "連結點擊",
+ "Clicks divided by sent replies.": "連結點擊次數除以已傳送私訊數。",
+ "Client campaign report": "活動成效報表",
+ "Clone it yourself": "自行部署",
+ "Close": "關閉",
+ "Collecting follower history": "正在累積追蹤人數紀錄",
+ "Comment": "留言",
+ "Comment webhooks and private replies depend on this connection.": "接收留言事件與自動私訊需要此連線。",
+ "Commenter": "留言者",
+ "Comments": "留言",
+ "Configuring…": "設定中…",
+ "Connect": "連接",
+ "Connect Instagram": "連接 Instagram",
+ "Connect Instagram without creating your own Meta developer app. Zernio is a paid service and an OpenReply sponsor. Your campaigns and hosting stay in OpenReply.": "使用 Zernio 可省去自行建立 Meta 開發者應用程式的步驟。Zernio 是付費服務,也是 OpenReply 的贊助商;活動與網站仍由 OpenReply 管理。",
+ "Connect an Instagram account first.": "請先連接 Instagram 帳號。",
+ "Connect an Instagram professional account to launch campaigns.": "請連接 Instagram 專業帳號以啟用活動。",
+ "Connect another Instagram account": "連接另一個 Instagram 帳號",
+ "Connect using your own Meta app": "使用自己的 Meta 應用程式連線",
+ "Connect your Instagram account first": "請先連接 Instagram 帳號",
+ "Connected": "已連線",
+ "Connected via Zernio": "透過 Zernio 連線",
+ "Conversations": "對話",
+ "Copied!": "已複製!",
+ "Copy": "複製",
+ "Copy URL": "複製網址",
+ "Could not accept invitation": "無法接受邀請",
+ "Could not change language. Please try again.": "無法切換語言,請再試一次。",
+ "Could not invite member": "無法邀請成員",
+ "Could not load connection.": "無法載入連線狀態。",
+ "Could not stage the import in this browser.": "無法在此瀏覽器準備匯入資料。",
+ "Could not update connection.": "無法更新連線。",
+ "Create Campaign": "建立活動",
+ "Create a profile in Zernio, then refresh this page.": "請先在 Zernio 建立設定檔,再重新整理此頁。",
+ "Create your first comment-to-DM campaign to turn a post or reel into a measurable conversation flow.": "建立第一個留言自動私訊活動,設定觸發貼文、關鍵字與回覆內容。",
+ "Created": "建立時間",
+ "DM Logs": "私訊紀錄",
+ "DM trigger": "私訊觸發",
+ "DMs Sent": "已傳送私訊",
+ "DMs sent": "已傳送私訊",
+ "DMs sent this month": "本月已傳送私訊",
+ "DMs — Last 7 Days": "最近 7 天的私訊",
+ "Dashboard": "儀表板",
+ "Date": "日期",
+ "Dedup": "重複略過",
+ "Delayed": "延遲中",
+ "Delete": "刪除",
+ "Delete this campaign? This cannot be undone.": "確定要刪除此活動嗎?刪除後無法復原。",
+ "Deploy your own copy": "部署自己的版本",
+ "Details unavailable": "無法取得詳細資料",
+ "Diagnostics": "系統診斷",
+ "Disconnect": "中斷連線",
+ "Disconnect Instagram? Campaigns for this account will stop sending DMs.": "確定要中斷 Instagram 連線嗎?此帳號的活動將停止傳送私訊。",
+ "Disconnecting...": "正在中斷連線…",
+ "Dismiss demo notice": "關閉示範網站提示",
+ "Down": "下降",
+ "Duplicate": "複製活動",
+ "Duplicates, limits, or no-send outcomes.": "因重複、限制或其他條件而未傳送。",
+ "Easier Instagram setup": "更簡便的 Instagram 連線方式",
+ "Edit": "編輯",
+ "Edit campaign": "編輯活動",
+ "Email me a magic link": "寄送登入連結",
+ "Enter a word or multiple": "輸入一個或多個關鍵字",
+ "Every DM to this account gets the reply below — use with care.": "此帳號收到的每則私訊都會觸發以下回覆,請確認適用範圍。",
+ "Failed": "失敗",
+ "Failed to load conversations": "無法載入對話",
+ "Failed to load overview": "無法載入帳號總覽",
+ "Failed to load posts": "無法載入貼文",
+ "Failed to save campaign": "活動儲存失敗",
+ "Failed to send message": "訊息傳送失敗",
+ "Fields are prefilled from your CSV. Pick the reel, edit anything, and save to load the next one — or Skip if you don’t want this one.": "已從 CSV 帶入欄位。選擇 Reels 並調整內容,儲存後即可檢查下一筆,也可以略過此筆。",
+ "Fill with a sample": "填入範例",
+ "Follow gate": "先追蹤再傳送",
+ "Follower count unavailable": "無法取得追蹤人數",
+ "Followers": "追蹤人數",
+ "Followers over time": "追蹤人數趨勢",
+ "Fri": "週五",
+ "Generated": "產生時間",
+ "Get a Zernio API key": "取得 Zernio API 金鑰",
+ "Go Live": "啟用活動",
+ "Health, queues, webhook failures, billing events, and worker alerts.": "查看服務健康狀態、佇列、Webhook 錯誤與背景處理器警示。",
+ "Healthy": "正常",
+ "Hello, {name}!": "你好,{name}!",
+ "Hey there! I'm so happy you're here 😊": "嗨,很高興你來了 😊",
+ "Import": "匯入",
+ "Import campaigns": "匯入活動",
+ "Importing {current} of {total}.": "正在匯入第 {current} / {total} 筆。",
+ "Inbox": "收件匣",
+ "Insights": "成效分析",
+ "Instagram Connection": "Instagram 連線",
+ "Instagram accepted the login but the connection could not be completed. This is usually a mismatched redirect URI or an app that is missing the required permissions.": "Instagram 已接受登入,但連線未完成。通常是回呼網址不符,或應用程式缺少必要權限。",
+ "Instagram account": "Instagram 帳號",
+ "Instagram app not configured": "Instagram 應用程式尚未設定",
+ "Instagram connection cancelled": "已取消 Instagram 連線",
+ "Instagram connection expired": "Instagram 連線已逾時",
+ "Instagram connection failed": "Instagram 連線失敗",
+ "Instagram could not load the details of this conversation. Other conversations are still available. You can check this chat in Instagram.": "Instagram 無法載入此對話的詳細內容,其他對話仍可使用。你可以到 Instagram 查看此對話。",
+ "Instagram could not load this conversation.": "Instagram 無法載入這段對話。",
+ "Instagram post": "Instagram 貼文",
+ "Instagram webhook": "Instagram Webhook",
+ "Interface language": "介面語言",
+ "Invite": "邀請",
+ "Inviting...": "邀請中…",
+ "Join {workspace}": "加入 {workspace}",
+ "Keywords": "關鍵字",
+ "LIVE": "啟用中",
+ "Language": "語言",
+ "Last 100": "最近 100 則",
+ "Last 25": "最近 25 則",
+ "Last 50": "最近 50 則",
+ "Last 7 Days": "最近 7 天",
+ "Last heartbeat {seconds}s ago": "{seconds} 秒前收到狀態回報",
+ "Last send:": "最近傳送:",
+ "Likes": "按讚數",
+ "Link clicks": "連結點擊",
+ "Loading…": "載入中…",
+ "Login - OpenReply": "登入 - OpenReply",
+ "Member": "成員",
+ "Menu": "選單",
+ "Message…": "訊息…",
+ "Mon": "週一",
+ "More actions": "更多操作",
+ "Needs attention": "需要處理",
+ "New Campaign": "新增活動",
+ "New campaign": "新增活動",
+ "Next": "下一頁",
+ "No DM failures or skips.": "目前沒有失敗或略過的私訊。",
+ "No activity yet": "尚無動態",
+ "No campaigns match your search.": "找不到符合搜尋條件的活動。",
+ "No campaigns yet": "尚未建立活動",
+ "No conversations yet.": "尚無對話。",
+ "No failed webhook events.": "目前沒有 Webhook 接收錯誤。",
+ "No heartbeat found": "尚未收到背景處理器的狀態回報",
+ "No image": "無圖片",
+ "No keyword matches yet": "尚無關鍵字符合紀錄",
+ "No keywords": "未設定關鍵字",
+ "No logs found": "尚無符合條件的紀錄",
+ "No match": "未符合條件",
+ "No matched keyword data yet.": "尚無關鍵字統計。",
+ "No messages.": "尚無訊息。",
+ "No operational events recorded.": "目前沒有系統事件。",
+ "No posts found": "找不到貼文",
+ "No posts match “": "找不到符合「",
+ "No sends yet": "尚未傳送",
+ "No snapshots recorded yet.": "尚無追蹤人數紀錄。",
+ "No token refresh failures.": "目前沒有權杖更新錯誤。",
+ "No worker alerts recorded.": "目前沒有背景處理器警示。",
+ "None": "無",
+ "Not attached": "未指定",
+ "Not connected": "尚未連線",
+ "Not permitted": "權限不足",
+ "Now": "剛剛",
+ "One day recorded so far.": "目前已有一天的紀錄。",
+ "One is picked at random each time, so replies don't look identical.": "每次隨機選擇一則,讓回覆內容有所變化。",
+ "Only workspace owners and admins can connect an Instagram account.": "只有工作區擁有者與管理員可以連接 Instagram 帳號。",
+ "Open on Instagram": "在 Instagram 開啟",
+ "OpenReply - Open source Instagram comment-to-DM automation": "OpenReply|Instagram 留言自動私訊",
+ "Opening message": "開場訊息",
+ "Operational Event Timeline": "系統事件紀錄",
+ "Optional connection provider": "選用的連線服務",
+ "Overview": "帳號總覽",
+ "Owner": "擁有者",
+ "PAUSED": "已暫停",
+ "Paste a CSV with a header row and at least one campaign.": "請貼上包含標題列與至少一筆活動的 CSV。",
+ "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": "貼上 CSV,每一列代表一個活動。匯入後可逐筆檢查、編輯內容並選擇 Reels,再儲存活動。必填欄位為",
+ "Paused": "已暫停",
+ "Paused campaign": "活動已暫停",
+ "Pending": "待處理",
+ "Pending invites": "待接受的邀請",
+ "Pick a post or reel to trigger the campaign.": "請選擇觸發此活動的貼文或 Reels。",
+ "Plan limit": "用量限制",
+ "Play reel preview": "播放 Reels 預覽",
+ "Post": "貼文",
+ "Posts": "貼文",
+ "Powered by OpenReply": "由 OpenReply 提供",
+ "Preview": "預覽",
+ "Previous": "上一頁",
+ "Private replies successfully sent.": "成功傳送的私訊。",
+ "Production Diagnostics": "正式環境診斷",
+ "Public reply under the post": "在貼文下公開回覆",
+ "Queue": "佇列",
+ "Range": "範圍",
+ "Rate limited": "頻率限制",
+ "Reach": "觸及人數",
+ "Read the setup guide": "閱讀設定指南",
+ "Read-only Instagram comment-to-DM campaign report for {name}.": "「{name}」的 Instagram 留言自動私訊成效報表(唯讀)。",
+ "Recent": "最近",
+ "Recent Activity": "最近動態",
+ "Recent Worker Alerts": "最近的背景處理器警示",
+ "Reconnect Instagram": "重新連接 Instagram",
+ "Reconnect your account to grant it — likes and comments are shown in the meantime.": "請重新連線以授予權限,目前仍可查看按讚與留言數。",
+ "Refresh": "重新整理",
+ "Remove": "移除",
+ "Remove Zernio connection": "移除 Zernio 連線",
+ "Remove reply": "移除此回覆",
+ "Remove the Zernio key and this OpenReply webhook? Disconnect its Instagram accounts from OpenReply first.": "確定要移除 Zernio 金鑰與此 OpenReply Webhook 嗎?請先中斷相關 Instagram 帳號的連線。",
+ "Remove {keyword}": "移除 {keyword}",
+ "Repair webhook connection": "修復 Webhook 連線",
+ "Replies that need operational review.": "需要檢查原因的回覆。",
+ "Reply": "回覆",
+ "Report Not Found": "找不到報表",
+ "Resume": "重新啟用",
+ "Review and import": "檢查並匯入",
+ "Revoke": "撤銷",
+ "Row {row} is missing keywords or a message.": "第 {row} 列缺少關鍵字或私訊內容。",
+ "Sat": "週六",
+ "Save API key": "儲存 API 金鑰",
+ "Save changes": "儲存變更",
+ "Save profile and configure webhook": "儲存設定檔並設定 Webhook",
+ "Saved": "收藏次數",
+ "Saved in this browser. Campaign messages stay unchanged.": "在此瀏覽器記住選擇,活動訊息內容不會改變。",
+ "Saving…": "儲存中…",
+ "Search campaigns by name, keyword, or message…": "搜尋活動名稱、關鍵字或訊息…",
+ "Search your posts by caption…": "依貼文內容搜尋…",
+ "Second button label": "第二個按鈕文字",
+ "See": "請參閱",
+ "See activity": "查看紀錄",
+ "Select a conversation to read and reply.": "選擇對話以查看及回覆訊息。",
+ "Select a profile": "選擇設定檔",
+ "Selected": "已選取",
+ "Self-hosted": "自架版本",
+ "Self-hosted — no plan limits.": "自架版本,無方案用量限制。",
+ "Send": "傳送",
+ "Send it": "傳送時間:連結送出",
+ "Send me the link": "傳送連結給我",
+ "Sending…": "傳送中…",
+ "Sends": "傳送次數",
+ "Sent": "已傳送",
+ "Sent replies": "已傳送私訊",
+ "Sent replies and tracked clicks by day.": "每日私訊傳送與連結點擊次數。",
+ "Sent right after the link.": "傳送連結後立即寄出。",
+ "Sent right after they tap through.": "對方點擊後立即傳送。",
+ "Sent you a DM! 📩": "已私訊給你囉!📩",
+ "Sent {minutes} min after the link.": "傳送連結 {minutes} 分鐘後寄出。",
+ "Sent {minutes} min after they tap through.": "對方點擊 {minutes} 分鐘後傳送。",
+ "Set": "請設定",
+ "Settings": "設定",
+ "Shares": "分享次數",
+ "Show": "顯示",
+ "Show chart": "顯示圖表",
+ "Show table": "顯示表格",
+ "Showing {start}–{end} of {total}": "顯示第 {start}–{end} 筆,共 {total} 筆",
+ "Sign in by email, then connect your Instagram professional account.": "使用電子郵件登入,再連接你的 Instagram 專業帳號。",
+ "Sign in to accept": "登入以接受邀請",
+ "Sign in to manage Instagram comment-to-DM campaigns.": "登入以管理 Instagram 留言自動私訊活動。",
+ "Sign in to use the {name} template.": "登入以使用「{name}」範本。",
+ "Sign-in is off on this demo": "示範網站未開放登入",
+ "Signing in here will not send DMs for your Instagram account. OpenReply is self-hosted, so it only works on a deployment you run yourself, with your own Meta app and your own domain.": "在此登入不會替你的 Instagram 帳號傳送私訊。請自行部署 OpenReply,並設定自己的 Meta 應用程式與網域。",
+ "Skip": "略過",
+ "Skip & finish": "略過並結束",
+ "Skipped": "已略過",
+ "Source post": "觸發貼文",
+ "Status": "狀態",
+ "Stop": "暫停",
+ "Sun": "週日",
+ "Supported by": "贊助商",
+ "Team": "團隊成員",
+ "Template selected": "已選擇範本",
+ "That Instagram account is connected to another workspace. Disconnect it there first, or connect a different account.": "此 Instagram 帳號已連接至其他工作區。請先在原工作區中斷連線,或改用另一個帳號。",
+ "The exact link sent": "實際傳送的連結",
+ "The login link was missing or older than 10 minutes. Click Connect Instagram to start a fresh attempt.": "登入連結缺失或已超過 10 分鐘。請點選「連接 Instagram」重新開始。",
+ "Then a follow-up message": "接著傳送後續訊息",
+ "They must follow first": "先要求追蹤帳號",
+ "They will get": "先傳送",
+ "They will get an opening DM": "先傳送開場私訊",
+ "This campaign does not have a tracked link.": "此活動尚未設定追蹤連結。",
+ "This invitation has expired. Ask the workspace owner to resend it.": "此邀請已過期,請聯絡工作區擁有者重新寄送。",
+ "This is the public demo — it doesn’t create real accounts or send DMs. To use OpenReply for real, clone it and run your own instance with your own Meta app and domain.": "這是公開示範網站,不會建立真實帳號或傳送私訊。若要使用 OpenReply,請自行部署,並設定自己的 Meta 應用程式與網域。",
+ "Thu": "週四",
+ "Time": "時間",
+ "Toggle sidebar": "切換側邊選單",
+ "Token Refresh Failures": "權杖更新錯誤",
+ "Token expires": "權杖到期日:",
+ "Top Keywords": "熱門關鍵字",
+ "Tracked Links": "追蹤連結",
+ "Tracked link visits from replies.": "透過回覆中的追蹤連結造訪的次數。",
+ "Tue": "週二",
+ "Type keyword and press Enter...": "輸入關鍵字後按 Enter…",
+ "Unknown error": "未知錯誤",
+ "Unknown member": "未知成員",
+ "Untitled campaign": "未命名活動",
+ "Usage": "使用量",
+ "Use an unrestricted, read-write key with Inbox access. OpenReply registers a webhook for this workspace. The key is encrypted and never shown again.": "請使用具備收件匣存取權、可讀寫且未限制範圍的金鑰。OpenReply 會為此工作區註冊 Webhook,並加密保存金鑰。",
+ "Use commas to separate words": "多個關鍵字請以逗號分隔",
+ "Use in OpenReply": "在 OpenReply 使用",
+ "Use the magic link account for": "請使用以下信箱的登入連結:",
+ "View Instagram post": "查看 Instagram 貼文",
+ "View all comments": "查看全部留言",
+ "View pricing": "查看費用",
+ "Views": "觀看次數",
+ "Views, reach, saved and shares need the insights permission.": "觀看、觸及、收藏與分享數需要洞察報告權限。",
+ "Waiting": "等待中",
+ "Waiting for next reel": "等待下一則 Reels",
+ "We send the link only after they tap the button and Instagram confirms the follow. If it can't be verified, we send it anyway.": "對方點擊按鈕且 Instagram 確認已追蹤後,才傳送連結。若無法驗證追蹤狀態,仍會傳送連結。",
+ "We sent you a secure sign-in link. Open it on this device to continue.": "安全登入連結已寄到你的信箱,請在這台裝置上開啟。若未看到郵件,請檢查垃圾郵件資料夾。",
+ "Webhook Failures": "Webhook 接收錯誤",
+ "Webhook configured. Choose an Instagram account for OpenReply:": "Webhook 已設定,請選擇要在 OpenReply 使用的 Instagram 帳號:",
+ "Webhook pending": "Webhook 尚未就緒",
+ "Webhook ready": "Webhook 已就緒",
+ "Wed": "週三",
+ "When someone comments on": "當有人在以下內容留言",
+ "Work email": "電子郵件",
+ "Worker health": "背景處理器狀態",
+ "Workspace": "工作區",
+ "Workspace invitation": "工作區邀請",
+ "Write a message": "輸入訊息內容",
+ "Write a reply… (Enter to send, Shift+Enter for a new line)": "輸入回覆…(Enter 傳送,Shift+Enter 換行)",
+ "You declined the permission prompt on Instagram. Start again and accept all requested permissions.": "你未同意 Instagram 的權限要求。請重新連線並允許所需權限。",
+ "You were invited as {role} for {email}.": "已邀請 {email} 以「{role}」身分加入。",
+ "You: ": "你:",
+ "Your next reel": "下一則 Reels",
+ "Your opening DM needs a message and a button label.": "請填寫開場私訊內容與按鈕文字。",
+ "Your opening message…": "輸入開場訊息…",
+ "Zernio API key": "Zernio API 金鑰",
+ "Zernio profile": "Zernio 設定檔",
+ "Zernio, OpenReply sponsor": "Zernio,OpenReply 贊助商",
+ "a DM with a link": "含有連結的私訊",
+ "a follow requirement first": "先要求追蹤帳號",
+ "a follow-up thank-you message": "後續感謝訊息",
+ "a specific post or reel": "指定貼文或 Reels",
+ "a specific word or words": "指定關鍵字",
+ "also reply when someone DMs": "收到私訊時也回覆",
+ "an opening DM": "開場私訊",
+ "and": "與",
+ "and restart the server:": "並重新啟動伺服器:",
+ "any post or reel": "任何貼文或 Reels",
+ "any word": "任何文字",
+ "anything": "任何內容",
+ "clicks": "次點擊",
+ "e.g. YC referral": "例如:留言領取 AI 工具",
+ "failed": "則失敗",
+ "followers": "位追蹤者",
+ "for how to obtain each value. Note that": "了解各項設定值的取得方式。",
+ "from @": ",帳號 @",
+ "i'm following": "我已追蹤",
+ "in the message to insert the tracked link.": "插入追蹤連結。",
+ "inserts the tracked link;": "會插入追蹤連結;",
+ "is a demo instance.": "是示範網站。",
+ "is a demo. OpenReply is self-hosted — signing in here will not send DMs for your account.": "是示範網站。OpenReply 需自行部署,在此登入不會替你的帳號傳送私訊。",
+ "keywords · Press Enter or comma to add": "個關鍵字 · 按 Enter 或逗號新增",
+ "min later": "分鐘後",
+ "minutes after the link": "分鐘後",
+ "more": "更多",
+ "must be a 64-character hex string.": "必須是 64 字元的十六進位字串。",
+ "next post or reel": "下一則貼文或 Reels",
+ "nice!": "想了解!",
+ "not available": "無資料",
+ "over {count} days": "過去 {count} 天",
+ "personalizes it. Max 24 hours, to stay inside Instagram's messaging window.": "會帶入對方帳號名稱。最長延遲 24 小時,以符合 Instagram 的訊息傳送期限。",
+ "personalizes.": "會帶入對方的帳號名稱。",
+ "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": "傳送連結前,請先追蹤我的帳號。這份資源免費提供,如果對你有幫助,也歡迎在 GitHub 給專案一顆星。完成追蹤後,點擊下方按鈕即可收到連結。",
+ "redirects to": "轉址至",
+ "reply to their comments under the post": "同時在貼文下公開回覆留言",
+ "runs": "次觸發",
+ "sent": "則已傳送",
+ "skipped": "則已略過",
+ "that day": "當天變化",
+ "the required environment variables": "必要的環境變數",
+ "their message": "對方的訊息",
+ "there": "朋友",
+ "these environment variables": "以下環境變數",
+ "these words": "這些關鍵字",
+ "your link": "你的連結",
+ "{count} accounts": "{count} 個帳號",
+ "{count} campaign": "{count} 個活動",
+ "{count} campaigns": "{count} 個活動",
+ "{count} clicks": "{count} 次點擊",
+ "{count} connected": "{count} 個已連線",
+ "{count} connected Instagram profile": "已連接 {count} 個 Instagram 帳號",
+ "{count} connected Instagram profiles": "已連接 {count} 個 Instagram 帳號",
+ "{count} connected account": "已連接 {count} 個帳號",
+ "{count} connected accounts": "已連接 {count} 個帳號",
+ "{count} contact": "{count} 位聯絡人",
+ "{count} contacts": "{count} 位聯絡人",
+ "{count} now": "目前 {count} 位",
+ "{count} of {total} campaigns": "顯示 {count} / {total} 個活動",
+ "{count} post": "{count} 則貼文",
+ "{count} posts": "{count} 則貼文",
+ "{count} sent": "{count} 則已傳送",
+ "{name} Campaign Report": "{name} 活動報表",
+ "{type} post": "{type} 貼文",
+ "”": "」",
+ "← Campaigns": "← 返回活動列表"
+}
diff --git a/lib/reports/data.ts b/lib/reports/data.ts
index 5eabe98c8..71b5479f4 100644
--- a/lib/reports/data.ts
+++ b/lib/reports/data.ts
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db/client";
+import type { Locale } from "@/lib/i18n";
import {
calculateCtr,
normalizeTopKeywords,
@@ -26,7 +27,7 @@ function getDayWindow(daysAgo: number) {
return { start, end };
}
-export async function getCampaignReportBySlug(shareSlug: string) {
+export async function getCampaignReportBySlug(shareSlug: string, locale: Locale = "en") {
const automation = await prisma.automation.findFirst({
where: {
reportShareSlug: shareSlug,
@@ -140,7 +141,7 @@ export async function getCampaignReportBySlug(shareSlug: string) {
]);
return {
- date: start.toLocaleDateString("en-US", {
+ date: start.toLocaleDateString(locale, {
month: "short",
day: "numeric",
}),