From e7f17fa096123d8dac06ec05ebdca60a7a59384c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=97=D7=99=D7=99=D7=9D=20=D7=A8=D7=91=D7=99?= <178530714+haim1120@users.noreply.github.com> Date: Tue, 12 May 2026 01:48:35 +0300 Subject: [PATCH] feat: add i18n language switching --- next.config.js | 6 +- package.json | 1 + src/app/{ => [locale]}/groups/[id]/page.tsx | 43 ++++-- src/app/{ => [locale]}/groups/new/page.tsx | 7 +- src/app/{ => [locale]}/groups/page.tsx | 6 +- src/app/[locale]/layout.tsx | 32 +++++ src/app/[locale]/page.tsx | 111 ++++++++++++++ src/app/page.tsx | 152 +------------------- src/components/ConnectWallet.tsx | 8 +- src/components/ContributeModal.tsx | 15 +- src/components/CreateGroupForm.tsx | 34 ++--- src/components/GroupCard.tsx | 27 +++- src/components/LanguageSwitcher.tsx | 52 +++++++ src/components/MemberList.tsx | 15 +- src/components/Navbar.tsx | 23 ++- src/components/RoundProgress.tsx | 15 +- src/i18n/config.ts | 9 ++ src/i18n/messages.ts | 18 +++ src/i18n/request.ts | 12 ++ src/messages/en.json | 152 ++++++++++++++++++++ src/messages/fr.json | 152 ++++++++++++++++++++ src/middleware.ts | 12 ++ 22 files changed, 690 insertions(+), 212 deletions(-) rename src/app/{ => [locale]}/groups/[id]/page.tsx (76%) rename src/app/{ => [locale]}/groups/new/page.tsx (62%) rename src/app/{ => [locale]}/groups/page.tsx (91%) create mode 100644 src/app/[locale]/layout.tsx create mode 100644 src/app/[locale]/page.tsx create mode 100644 src/components/LanguageSwitcher.tsx create mode 100644 src/i18n/config.ts create mode 100644 src/i18n/messages.ts create mode 100644 src/i18n/request.ts create mode 100644 src/messages/en.json create mode 100644 src/messages/fr.json create mode 100644 src/middleware.ts diff --git a/next.config.js b/next.config.js index 55d4c9b..9c43305 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,8 @@ /** @type {import('next').NextConfig} */ +const createNextIntlPlugin = require("next-intl/plugin"); + +const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); + const nextConfig = { reactStrictMode: true, webpack: (config) => { @@ -12,4 +16,4 @@ const nextConfig = { }, }; -module.exports = nextConfig; +module.exports = withNextIntl(nextConfig); diff --git a/package.json b/package.json index 7e788f6..d4f555a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@sorosave/sdk": "workspace:*", "@stellar/freighter-api": "^2.0.0", "next": "^14.2.0", + "next-intl": "^3.26.5", "react": "^18.3.0", "react-dom": "^18.3.0" }, diff --git a/src/app/groups/[id]/page.tsx b/src/app/[locale]/groups/[id]/page.tsx similarity index 76% rename from src/app/groups/[id]/page.tsx rename to src/app/[locale]/groups/[id]/page.tsx index 02ab880..7ca247c 100644 --- a/src/app/groups/[id]/page.tsx +++ b/src/app/[locale]/groups/[id]/page.tsx @@ -5,7 +5,8 @@ import { MemberList } from "@/components/MemberList"; import { RoundProgress } from "@/components/RoundProgress"; import { ContributeModal } from "@/components/ContributeModal"; import { useState } from "react"; -import { formatAmount, GroupStatus } from "@sorosave/sdk"; +import { formatAmount, getStatusLabel, GroupStatus } from "@sorosave/sdk"; +import { useTranslations } from "next-intl"; // TODO: Fetch real data from contract const MOCK_GROUP = { @@ -32,9 +33,21 @@ const MOCK_GROUP = { createdAt: 1700000000, }; +const statusMessageKeys: Record = { + Forming: "forming", + Active: "active", + Completed: "completed", + Disputed: "disputed", + Paused: "paused", +}; + export default function GroupDetailPage() { const [showContributeModal, setShowContributeModal] = useState(false); const group = MOCK_GROUP; + const t = useTranslations("groupDetail"); + const common = useTranslations("common"); + const status = useTranslations("status"); + const statusLabel = getStatusLabel(group.status); return ( <> @@ -43,7 +56,9 @@ export default function GroupDetailPage() {

{group.name}

- {formatAmount(group.contributionAmount)} tokens per cycle + {t("tokensPerCycle", { + amount: formatAmount(group.contributionAmount), + })}

@@ -67,7 +82,7 @@ export default function GroupDetailPage() {

- Actions + {t("actions")}

{group.status === GroupStatus.Active && ( @@ -75,12 +90,12 @@ export default function GroupDetailPage() { onClick={() => setShowContributeModal(true)} className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors" > - Contribute + {t("contribute")} )} {group.status === GroupStatus.Forming && ( )}
@@ -88,32 +103,34 @@ export default function GroupDetailPage() {

- Group Info + {t("info")}

-
Status
-
{group.status}
+
{t("status")}
+
+ {status(statusMessageKeys[statusLabel] ?? "unknown")} +
-
Members
+
{t("members")}
{group.members.length}/{group.maxMembers}
-
Cycle
+
{t("cycle")}
- {group.cycleLength / 86400} days + {t("days", { count: group.cycleLength / 86400 })}
-
Pot Size
+
{t("potSize")}
{formatAmount( group.contributionAmount * BigInt(group.members.length) )}{" "} - tokens + {common("tokens")}
diff --git a/src/app/groups/new/page.tsx b/src/app/[locale]/groups/new/page.tsx similarity index 62% rename from src/app/groups/new/page.tsx rename to src/app/[locale]/groups/new/page.tsx index afe0804..4f820e2 100644 --- a/src/app/groups/new/page.tsx +++ b/src/app/[locale]/groups/new/page.tsx @@ -1,13 +1,16 @@ import { Navbar } from "@/components/Navbar"; import { CreateGroupForm } from "@/components/CreateGroupForm"; +import { getMessages } from "@/i18n/messages"; + +export default function NewGroupPage({ params }: { params: { locale: string } }) { + const messages = getMessages(params.locale).newGroup; -export default function NewGroupPage() { return ( <>

- Create a Savings Group + {messages.title}

diff --git a/src/app/groups/page.tsx b/src/app/[locale]/groups/page.tsx similarity index 91% rename from src/app/groups/page.tsx rename to src/app/[locale]/groups/page.tsx index 7592365..40c38ec 100644 --- a/src/app/groups/page.tsx +++ b/src/app/[locale]/groups/page.tsx @@ -3,6 +3,7 @@ import { Navbar } from "@/components/Navbar"; import { GroupCard } from "@/components/GroupCard"; import { SavingsGroup, GroupStatus } from "@sorosave/sdk"; +import { useTranslations } from "next-intl"; // Placeholder data for development — will be replaced with contract queries const PLACEHOLDER_GROUPS: SavingsGroup[] = [ @@ -41,18 +42,19 @@ const PLACEHOLDER_GROUPS: SavingsGroup[] = [ export default function GroupsPage() { // TODO: Replace with actual contract queries const groups = PLACEHOLDER_GROUPS; + const t = useTranslations("groups"); return ( <>
-

Savings Groups

+

{t("title")}

{groups.length === 0 ? (
- No groups found. Create the first one! + {t("empty")}
) : (
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx new file mode 100644 index 0000000..ea5a23f --- /dev/null +++ b/src/app/[locale]/layout.tsx @@ -0,0 +1,32 @@ +import { NextIntlClientProvider } from "next-intl"; +import { setRequestLocale } from "next-intl/server"; +import { notFound } from "next/navigation"; +import { getMessages } from "@/i18n/messages"; +import { isLocale, locales } from "@/i18n/config"; + +export function generateStaticParams() { + return locales.map((locale) => ({ locale })); +} + +export default function LocaleLayout({ + children, + params, +}: { + children: React.ReactNode; + params: { locale: string }; +}) { + if (!isLocale(params.locale)) { + notFound(); + } + + setRequestLocale(params.locale); + + return ( + + {children} + + ); +} diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx new file mode 100644 index 0000000..83a3b36 --- /dev/null +++ b/src/app/[locale]/page.tsx @@ -0,0 +1,111 @@ +import Link from "next/link"; +import { Navbar } from "@/components/Navbar"; +import { getLocale, getMessages } from "@/i18n/messages"; + +export default function Home({ params }: { params: { locale: string } }) { + const locale = getLocale(params.locale); + const messages = getMessages(locale).home; + + return ( + <> + +
+ {/* Hero */} +
+
+
+

+ {messages.hero.title} +

+

+ {messages.hero.description} +

+
+ + {messages.hero.browseGroups} + + + {messages.hero.createGroup} + +
+
+
+
+ + {/* How it works */} +
+
+

+ {messages.how.title} +

+
+ {Object.values(messages.how.steps).map((item) => ( +
+
+ {item.step} +
+

+ {item.title} +

+

{item.description}

+
+ ))} +
+
+
+ + {/* Features */} +
+
+

+ {messages.features.title} +

+
+ {Object.values(messages.features.items).map((feature) => ( +
+

+ {feature.title} +

+

{feature.description}

+
+ ))} +
+
+
+ + {/* Footer */} + +
+ + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 670d0c6..0b81b8b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,150 +1,6 @@ -import Link from "next/link"; -import { Navbar } from "@/components/Navbar"; +import { redirect } from "next/navigation"; +import { defaultLocale } from "@/i18n/config"; -export default function Home() { - return ( - <> - -
- {/* Hero */} -
-
-
-

- Decentralized Group Savings for Everyone -

-

- SoroSave brings the traditional rotating savings model (ajo, - susu, chit fund) to the Stellar blockchain. Trustless, - transparent, and accessible. -

-
- - Browse Groups - - - Create a Group - -
-
-
-
- - {/* How it works */} -
-
-

- How It Works -

-
- {[ - { - step: "1", - title: "Create a Group", - description: - "Set contribution amount, cycle length, and max members. You become the admin.", - }, - { - step: "2", - title: "Members Join", - description: - "Share the group link. Members join until the group is full or the admin starts it.", - }, - { - step: "3", - title: "Contribute Each Cycle", - description: - "Every member contributes the fixed amount each round. Smart contract enforces rules.", - }, - { - step: "4", - title: "Receive the Pot", - description: - "Each round, one member receives the full pot. Continues until everyone has received.", - }, - ].map((item) => ( -
-
- {item.step} -
-

- {item.title} -

-

{item.description}

-
- ))} -
-
-
- - {/* Features */} -
-
-

- Why SoroSave? -

-
- {[ - { - title: "Trustless", - description: - "Smart contracts enforce contributions and payouts. No middleman needed.", - }, - { - title: "Transparent", - description: - "All transactions are on-chain. Every member can verify the group state.", - }, - { - title: "Low Cost", - description: - "Built on Soroban (Stellar). Transaction fees are a fraction of a cent.", - }, - ].map((feature) => ( -
-

- {feature.title} -

-

{feature.description}

-
- ))} -
-
-
- - {/* Footer */} - -
- - ); +export default function RootPage() { + redirect(`/${defaultLocale}`); } diff --git a/src/components/ConnectWallet.tsx b/src/components/ConnectWallet.tsx index f039a0e..eeb1120 100644 --- a/src/components/ConnectWallet.tsx +++ b/src/components/ConnectWallet.tsx @@ -2,10 +2,12 @@ import { useWallet } from "@/app/providers"; import { shortenAddress } from "@sorosave/sdk"; +import { useTranslations } from "next-intl"; export function ConnectWallet() { const { address, isConnected, isFreighterAvailable, connect, disconnect } = useWallet(); + const t = useTranslations("wallet"); if (!isFreighterAvailable) { return ( @@ -15,7 +17,7 @@ export function ConnectWallet() { rel="noopener noreferrer" className="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-300" > - Install Freighter + {t("installFreighter")} ); } @@ -30,7 +32,7 @@ export function ConnectWallet() { onClick={disconnect} className="text-sm text-red-600 hover:text-red-800" > - Disconnect + {t("disconnect")}
); @@ -41,7 +43,7 @@ export function ConnectWallet() { onClick={connect} className="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700 transition-colors" > - Connect Wallet + {t("connectWallet")} ); } diff --git a/src/components/ContributeModal.tsx b/src/components/ContributeModal.tsx index 0d9d539..442dc19 100644 --- a/src/components/ContributeModal.tsx +++ b/src/components/ContributeModal.tsx @@ -5,6 +5,7 @@ import { useWallet } from "@/app/providers"; import { sorosaveClient, NETWORK_PASSPHRASE } from "@/lib/sorosave"; import { formatAmount } from "@sorosave/sdk"; import { signTransaction } from "@/lib/wallet"; +import { useTranslations } from "next-intl"; interface ContributeModalProps { groupId: number; @@ -20,6 +21,8 @@ export function ContributeModal({ onClose, }: ContributeModalProps) { const { address } = useWallet(); + const t = useTranslations("contributeModal"); + const common = useTranslations("common"); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -42,7 +45,7 @@ export function ContributeModal({ console.log("Signed contribution:", signedXdr); onClose(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to contribute"); + setError(err instanceof Error ? err.message : t("errors.failed")); } finally { setLoading(false); } @@ -52,14 +55,14 @@ export function ContributeModal({

- Confirm Contribution + {t("title")}

-

Amount to contribute

+

{t("amountLabel")}

- {formatAmount(contributionAmount)} tokens + {formatAmount(contributionAmount)} {common("tokens")}

@@ -75,14 +78,14 @@ export function ContributeModal({ onClick={onClose} className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50" > - Cancel + {t("cancel")}
diff --git a/src/components/CreateGroupForm.tsx b/src/components/CreateGroupForm.tsx index 0a1a767..9229b87 100644 --- a/src/components/CreateGroupForm.tsx +++ b/src/components/CreateGroupForm.tsx @@ -5,9 +5,11 @@ import { useWallet } from "@/app/providers"; import { sorosaveClient, NETWORK_PASSPHRASE } from "@/lib/sorosave"; import { parseAmount } from "@sorosave/sdk"; import { signTransaction } from "@/lib/wallet"; +import { useTranslations } from "next-intl"; export function CreateGroupForm() { const { address, isConnected } = useWallet(); + const t = useTranslations("createGroup"); const [name, setName] = useState(""); const [tokenAddress, setTokenAddress] = useState(""); const [contributionAmount, setContributionAmount] = useState(""); @@ -43,9 +45,9 @@ export function CreateGroupForm() { // TODO: Submit signed transaction to network console.log("Signed transaction:", signedXdr); - alert("Group created successfully!"); + alert(t("success")); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create group"); + setError(err instanceof Error ? err.message : t("errors.failed")); } finally { setLoading(false); } @@ -54,7 +56,7 @@ export function CreateGroupForm() { if (!isConnected) { return (
- Please connect your wallet to create a group. + {t("notConnected")}
); } @@ -63,7 +65,7 @@ export function CreateGroupForm() {
setName(e.target.value)} required className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder="My Savings Circle" + placeholder={t("namePlaceholder")} />
setTokenAddress(e.target.value)} required className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder="CDLZ..." + placeholder={t("tokenPlaceholder")} />
setContributionAmount(e.target.value)} required className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder="100" + placeholder={t("contributionPlaceholder")} />
- {loading ? "Creating..." : "Create Savings Group"} + {loading ? t("loading") : t("submit")} ); diff --git a/src/components/GroupCard.tsx b/src/components/GroupCard.tsx index 34e0616..0ba5fef 100644 --- a/src/components/GroupCard.tsx +++ b/src/components/GroupCard.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { SavingsGroup, formatAmount, getStatusLabel } from "@sorosave/sdk"; +import { useLocale, useTranslations } from "next-intl"; interface GroupCardProps { group: SavingsGroup; @@ -15,9 +16,23 @@ const statusColors: Record = { Paused: "bg-yellow-100 text-yellow-800", }; +const statusMessageKeys: Record = { + Forming: "forming", + Active: "active", + Completed: "completed", + Disputed: "disputed", + Paused: "paused", +}; + export function GroupCard({ group }: GroupCardProps) { + const locale = useLocale(); + const t = useTranslations("groupCard"); + const common = useTranslations("common"); + const status = useTranslations("status"); + const statusLabel = getStatusLabel(group.status); + return ( - +

{group.name}

@@ -26,25 +41,25 @@ export function GroupCard({ group }: GroupCardProps) { statusColors[group.status] || "bg-gray-100 text-gray-800" }`} > - {getStatusLabel(group.status)} + {status(statusMessageKeys[statusLabel] ?? "unknown")}
- Contribution + {t("contribution")} - {formatAmount(group.contributionAmount)} tokens + {formatAmount(group.contributionAmount)} {common("tokens")}
- Members + {t("members")} {group.members.length} / {group.maxMembers}
- Round + {t("round")} {group.currentRound} / {group.totalRounds || group.maxMembers} diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..8818747 --- /dev/null +++ b/src/components/LanguageSwitcher.tsx @@ -0,0 +1,52 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useLocale, useTranslations } from "next-intl"; +import { isLocale, locales, type Locale } from "@/i18n/config"; + +const localeLabels: Record = { + en: "en", + fr: "fr", +}; + +function getLocalizedPath(pathname: string, nextLocale: Locale) { + const segments = pathname.split("/"); + + if (isLocale(segments[1])) { + segments[1] = nextLocale; + } else { + segments.splice(1, 0, nextLocale); + } + + return segments.join("/") || `/${nextLocale}`; +} + +export function LanguageSwitcher() { + const pathname = usePathname(); + const locale = useLocale(); + const t = useTranslations("language"); + + return ( +
+ {locales.map((nextLocale) => { + const active = nextLocale === locale; + + return ( + + {t(localeLabels[nextLocale])} + + ); + })} +
+ ); +} diff --git a/src/components/MemberList.tsx b/src/components/MemberList.tsx index bd469e1..1cca5d2 100644 --- a/src/components/MemberList.tsx +++ b/src/components/MemberList.tsx @@ -1,6 +1,7 @@ "use client"; import { shortenAddress } from "@sorosave/sdk"; +import { useTranslations } from "next-intl"; interface MemberListProps { members: string[]; @@ -15,9 +16,13 @@ export function MemberList({ payoutOrder, currentRound, }: MemberListProps) { + const t = useTranslations("memberList"); + return (
-

Members

+

+ {t("title")} +

{members.map((member, index) => { const payoutRound = @@ -42,7 +47,7 @@ export function MemberList({ {member === admin && ( - Admin + {t("admin")} )}
@@ -50,17 +55,17 @@ export function MemberList({
{isCurrentRecipient && ( - Current Recipient + {t("currentRecipient")} )} {hasReceived && ( - Received + {t("received")} )} {payoutRound && !hasReceived && !isCurrentRecipient && ( - Round {payoutRound} + {t("round", { round: payoutRound })} )}
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 2d673aa..e7a025f 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,33 +1,44 @@ "use client"; import Link from "next/link"; +import { useLocale, useTranslations } from "next-intl"; import { ConnectWallet } from "./ConnectWallet"; +import { LanguageSwitcher } from "./LanguageSwitcher"; export function Navbar() { + const locale = useLocale(); + const t = useTranslations("nav"); + return ( diff --git a/src/components/RoundProgress.tsx b/src/components/RoundProgress.tsx index 8105152..95f5f80 100644 --- a/src/components/RoundProgress.tsx +++ b/src/components/RoundProgress.tsx @@ -1,5 +1,7 @@ "use client"; +import { useTranslations } from "next-intl"; + interface RoundProgressProps { currentRound: number; totalRounds: number; @@ -13,20 +15,23 @@ export function RoundProgress({ contributionsReceived, totalMembers, }: RoundProgressProps) { + const t = useTranslations("roundProgress"); const roundProgress = totalRounds > 0 ? (currentRound / totalRounds) * 100 : 0; const contributionProgress = totalMembers > 0 ? (contributionsReceived / totalMembers) * 100 : 0; return (
-

Progress

+

+ {t("title")} +

- Overall Progress + {t("overall")} - Round {currentRound} of {totalRounds} + {t("roundOf", { currentRound, totalRounds })}
@@ -39,7 +44,9 @@ export function RoundProgress({
- Current Round Contributions + + {t("currentContributions")} + {contributionsReceived} / {totalMembers} diff --git a/src/i18n/config.ts b/src/i18n/config.ts new file mode 100644 index 0000000..f304863 --- /dev/null +++ b/src/i18n/config.ts @@ -0,0 +1,9 @@ +export const locales = ["en", "fr"] as const; + +export type Locale = (typeof locales)[number]; + +export const defaultLocale: Locale = "en"; + +export function isLocale(value: string): value is Locale { + return locales.includes(value as Locale); +} diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts new file mode 100644 index 0000000..e6f2ab8 --- /dev/null +++ b/src/i18n/messages.ts @@ -0,0 +1,18 @@ +import en from "@/messages/en.json"; +import fr from "@/messages/fr.json"; +import { defaultLocale, isLocale, type Locale } from "./config"; + +const messagesByLocale = { + en, + fr, +}; + +export type Messages = typeof en; + +export function getLocale(locale: string): Locale { + return isLocale(locale) ? locale : defaultLocale; +} + +export function getMessages(locale: string): Messages { + return messagesByLocale[getLocale(locale)]; +} diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 0000000..de4d417 --- /dev/null +++ b/src/i18n/request.ts @@ -0,0 +1,12 @@ +import { getRequestConfig } from "next-intl/server"; +import { defaultLocale } from "./config"; +import { getLocale, getMessages } from "./messages"; + +export default getRequestConfig(async ({ requestLocale }) => { + const safeLocale = getLocale((await requestLocale) ?? defaultLocale); + + return { + locale: safeLocale, + messages: getMessages(safeLocale), + }; +}); diff --git a/src/messages/en.json b/src/messages/en.json new file mode 100644 index 0000000..574842f --- /dev/null +++ b/src/messages/en.json @@ -0,0 +1,152 @@ +{ + "common": { + "tokens": "tokens" + }, + "status": { + "forming": "Forming", + "active": "Active", + "completed": "Completed", + "disputed": "Disputed", + "paused": "Paused", + "unknown": "Unknown" + }, + "nav": { + "groups": "Groups", + "createGroup": "Create Group" + }, + "language": { + "label": "Language", + "en": "English", + "fr": "Français" + }, + "wallet": { + "installFreighter": "Install Freighter", + "disconnect": "Disconnect", + "connectWallet": "Connect Wallet" + }, + "home": { + "hero": { + "title": "Decentralized Group Savings for Everyone", + "description": "SoroSave brings the traditional rotating savings model (ajo, susu, chit fund) to the Stellar blockchain. Trustless, transparent, and accessible.", + "browseGroups": "Browse Groups", + "createGroup": "Create a Group" + }, + "how": { + "title": "How It Works", + "steps": { + "one": { + "step": "1", + "title": "Create a Group", + "description": "Set contribution amount, cycle length, and max members. You become the admin." + }, + "two": { + "step": "2", + "title": "Members Join", + "description": "Share the group link. Members join until the group is full or the admin starts it." + }, + "three": { + "step": "3", + "title": "Contribute Each Cycle", + "description": "Every member contributes the fixed amount each round. Smart contract enforces rules." + }, + "four": { + "step": "4", + "title": "Receive the Pot", + "description": "Each round, one member receives the full pot. Continues until everyone has received." + } + } + }, + "features": { + "title": "Why SoroSave?", + "items": { + "trustless": { + "title": "Trustless", + "description": "Smart contracts enforce contributions and payouts. No middleman needed." + }, + "transparent": { + "title": "Transparent", + "description": "All transactions are on-chain. Every member can verify the group state." + }, + "lowCost": { + "title": "Low Cost", + "description": "Built on Soroban (Stellar). Transaction fees are a fraction of a cent." + } + } + }, + "footer": { + "description": "Open-source decentralized group savings protocol on Soroban.", + "docs": "Docs", + "discord": "Discord" + } + }, + "groups": { + "title": "Savings Groups", + "empty": "No groups found. Create the first one!" + }, + "groupCard": { + "contribution": "Contribution", + "members": "Members", + "round": "Round" + }, + "newGroup": { + "title": "Create a Savings Group" + }, + "createGroup": { + "notConnected": "Please connect your wallet to create a group.", + "nameLabel": "Group Name", + "namePlaceholder": "My Savings Circle", + "tokenLabel": "Token Contract Address", + "tokenPlaceholder": "CDLZ...", + "contributionLabel": "Contribution Amount (per cycle)", + "contributionPlaceholder": "100", + "cycleLengthLabel": "Cycle Length (seconds)", + "maxMembersLabel": "Max Members", + "success": "Group created successfully!", + "loading": "Creating...", + "submit": "Create Savings Group", + "errors": { + "failed": "Failed to create group" + }, + "cycleOptions": { + "hour": "1 Hour", + "day": "1 Day", + "week": "1 Week", + "month": "1 Month" + } + }, + "groupDetail": { + "tokensPerCycle": "{amount} tokens per cycle", + "actions": "Actions", + "contribute": "Contribute", + "joinGroup": "Join Group", + "info": "Group Info", + "status": "Status", + "members": "Members", + "cycle": "Cycle", + "days": "{count} days", + "potSize": "Pot Size" + }, + "contributeModal": { + "title": "Confirm Contribution", + "amountLabel": "Amount to contribute", + "cancel": "Cancel", + "loading": "Confirming...", + "submit": "Contribute", + "errors": { + "failed": "Failed to contribute" + } + }, + "memberList": { + "title": "Members", + "admin": "Admin", + "currentRecipient": "Current Recipient", + "received": "Received", + "round": "Round {round}" + }, + "roundProgress": { + "title": "Progress", + "overall": "Overall Progress", + "roundOf": "Round {currentRound} of {totalRounds}", + "currentContributions": "Current Round Contributions" + } +} diff --git a/src/messages/fr.json b/src/messages/fr.json new file mode 100644 index 0000000..7370b93 --- /dev/null +++ b/src/messages/fr.json @@ -0,0 +1,152 @@ +{ + "common": { + "tokens": "jetons" + }, + "status": { + "forming": "En formation", + "active": "Actif", + "completed": "Terminé", + "disputed": "Contesté", + "paused": "En pause", + "unknown": "Inconnu" + }, + "nav": { + "groups": "Groupes", + "createGroup": "Créer un groupe" + }, + "language": { + "label": "Langue", + "en": "English", + "fr": "Français" + }, + "wallet": { + "installFreighter": "Installer Freighter", + "disconnect": "Déconnecter", + "connectWallet": "Connecter le wallet" + }, + "home": { + "hero": { + "title": "Épargne collective décentralisée pour tous", + "description": "SoroSave apporte le modèle traditionnel d'épargne rotative (ajo, susu, tontine) à la blockchain Stellar. Sans tiers de confiance, transparent et accessible.", + "browseGroups": "Voir les groupes", + "createGroup": "Créer un groupe" + }, + "how": { + "title": "Comment ça marche", + "steps": { + "one": { + "step": "1", + "title": "Créez un groupe", + "description": "Définissez le montant de contribution, la durée du cycle et le nombre maximal de membres. Vous devenez l'administrateur." + }, + "two": { + "step": "2", + "title": "Les membres rejoignent", + "description": "Partagez le lien du groupe. Les membres rejoignent jusqu'à ce que le groupe soit complet ou que l'administrateur le démarre." + }, + "three": { + "step": "3", + "title": "Contribuez à chaque cycle", + "description": "Chaque membre verse le montant fixe à chaque tour. Le smart contract applique les règles." + }, + "four": { + "step": "4", + "title": "Recevez la cagnotte", + "description": "À chaque tour, un membre reçoit toute la cagnotte. Le cycle continue jusqu'à ce que chacun ait reçu sa part." + } + } + }, + "features": { + "title": "Pourquoi SoroSave ?", + "items": { + "trustless": { + "title": "Sans tiers de confiance", + "description": "Les smart contracts appliquent les contributions et les paiements. Aucun intermédiaire n'est nécessaire." + }, + "transparent": { + "title": "Transparent", + "description": "Toutes les transactions sont on-chain. Chaque membre peut vérifier l'état du groupe." + }, + "lowCost": { + "title": "Faible coût", + "description": "Construit sur Soroban (Stellar). Les frais de transaction sont inférieurs à un centime." + } + } + }, + "footer": { + "description": "Protocole open source d'épargne collective décentralisée sur Soroban.", + "docs": "Docs", + "discord": "Discord" + } + }, + "groups": { + "title": "Groupes d'épargne", + "empty": "Aucun groupe trouvé. Créez le premier !" + }, + "groupCard": { + "contribution": "Contribution", + "members": "Membres", + "round": "Tour" + }, + "newGroup": { + "title": "Créer un groupe d'épargne" + }, + "createGroup": { + "notConnected": "Connectez votre wallet pour créer un groupe.", + "nameLabel": "Nom du groupe", + "namePlaceholder": "Mon cercle d'épargne", + "tokenLabel": "Adresse du contrat du jeton", + "tokenPlaceholder": "CDLZ...", + "contributionLabel": "Montant de contribution (par cycle)", + "contributionPlaceholder": "100", + "cycleLengthLabel": "Durée du cycle (secondes)", + "maxMembersLabel": "Nombre maximal de membres", + "success": "Groupe créé avec succès !", + "loading": "Création...", + "submit": "Créer le groupe d'épargne", + "errors": { + "failed": "Échec de la création du groupe" + }, + "cycleOptions": { + "hour": "1 heure", + "day": "1 jour", + "week": "1 semaine", + "month": "1 mois" + } + }, + "groupDetail": { + "tokensPerCycle": "{amount} jetons par cycle", + "actions": "Actions", + "contribute": "Contribuer", + "joinGroup": "Rejoindre le groupe", + "info": "Infos du groupe", + "status": "Statut", + "members": "Membres", + "cycle": "Cycle", + "days": "{count} jours", + "potSize": "Taille de la cagnotte" + }, + "contributeModal": { + "title": "Confirmer la contribution", + "amountLabel": "Montant à contribuer", + "cancel": "Annuler", + "loading": "Confirmation...", + "submit": "Contribuer", + "errors": { + "failed": "Échec de la contribution" + } + }, + "memberList": { + "title": "Membres", + "admin": "Admin", + "currentRecipient": "Bénéficiaire actuel", + "received": "Reçu", + "round": "Tour {round}" + }, + "roundProgress": { + "title": "Progression", + "overall": "Progression globale", + "roundOf": "Tour {currentRound} sur {totalRounds}", + "currentContributions": "Contributions du tour actuel" + } +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..5200eb6 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,12 @@ +import createMiddleware from "next-intl/middleware"; +import { defaultLocale, locales } from "@/i18n/config"; + +export default createMiddleware({ + defaultLocale, + locales, + localePrefix: "always", +}); + +export const config = { + matcher: ["/((?!api|_next|.*\\..*).*)"], +};