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..8788278 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,14 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test:i18n": "node scripts/validate-messages.mjs" }, "dependencies": { "@sorosave/sdk": "workspace:*", "@stellar/freighter-api": "^2.0.0", "next": "^14.2.0", + "next-intl": "^4.12.0", "react": "^18.3.0", "react-dom": "^18.3.0" }, diff --git a/scripts/validate-messages.mjs b/scripts/validate-messages.mjs new file mode 100644 index 0000000..1076ea7 --- /dev/null +++ b/scripts/validate-messages.mjs @@ -0,0 +1,59 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const locales = ["en", "fr"]; +const messagesDir = join(process.cwd(), "src", "messages"); + +function flattenKeys(value, prefix = "") { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return [prefix]; + } + + return Object.entries(value).flatMap(([key, child]) => + flattenKeys(child, prefix ? `${prefix}.${key}` : key), + ); +} + +const localeKeys = new Map(); + +for (const locale of locales) { + const path = join(messagesDir, `${locale}.json`); + + if (!existsSync(path)) { + throw new Error(`Missing message file: ${path}`); + } + + const messages = JSON.parse(readFileSync(path, "utf8")); + const keys = flattenKeys(messages).filter(Boolean).sort(); + + if (keys.length === 0) { + throw new Error(`Message file has no keys: ${path}`); + } + + localeKeys.set(locale, keys); +} + +const [baseLocale] = locales; +const baseKeys = localeKeys.get(baseLocale); + +for (const locale of locales.slice(1)) { + const keys = localeKeys.get(locale); + const missing = baseKeys.filter((key) => !keys.includes(key)); + const extra = keys.filter((key) => !baseKeys.includes(key)); + + if (missing.length || extra.length) { + throw new Error( + [ + `Message keys do not match for ${locale}.`, + missing.length ? `Missing: ${missing.join(", ")}` : "", + extra.length ? `Extra: ${extra.join(", ")}` : "", + ] + .filter(Boolean) + .join("\n"), + ); + } +} + +console.log( + `Validated ${baseKeys.length} message keys across ${locales.length} locales.`, +); diff --git a/src/app/groups/[id]/page.tsx b/src/app/[locale]/groups/[id]/page.tsx similarity index 75% rename from src/app/groups/[id]/page.tsx rename to src/app/[locale]/groups/[id]/page.tsx index 02ab880..406e1b0 100644 --- a/src/app/groups/[id]/page.tsx +++ b/src/app/[locale]/groups/[id]/page.tsx @@ -5,6 +5,7 @@ import { MemberList } from "@/components/MemberList"; import { RoundProgress } from "@/components/RoundProgress"; import { ContributeModal } from "@/components/ContributeModal"; import { useState } from "react"; +import { useTranslations } from "next-intl"; import { formatAmount, GroupStatus } from "@sorosave/sdk"; // TODO: Fetch real data from contract @@ -33,6 +34,15 @@ const MOCK_GROUP = { }; export default function GroupDetailPage() { + const t = useTranslations("GroupDetail"); + const statusT = useTranslations("GroupStatus"); + const statusLabels: Record = { + Forming: statusT("Forming"), + Active: statusT("Active"), + Completed: statusT("Completed"), + Disputed: statusT("Disputed"), + Paused: statusT("Paused"), + }; const [showContributeModal, setShowContributeModal] = useState(false); const group = MOCK_GROUP; @@ -43,7 +53,9 @@ export default function GroupDetailPage() {

{group.name}

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

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

- Actions + {t("actions")}

{group.status === GroupStatus.Active && ( @@ -75,12 +87,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 +100,35 @@ export default function GroupDetailPage() {

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

-
Status
-
{group.status}
+
{t("status")}
+
+ {statusLabels[String(group.status)] ?? String(group.status)} +
-
Members
+
{t("members")}
{group.members.length}/{group.maxMembers}
-
Cycle
+
{t("cycle")}
- {group.cycleLength / 86400} days + {t("days", { days: group.cycleLength / 86400 })}
-
Pot Size
+
{t("potSize")}
- {formatAmount( - group.contributionAmount * BigInt(group.members.length) - )}{" "} - tokens + {t("tokens", { + amount: formatAmount( + group.contributionAmount * BigInt(group.members.length), + ), + })}
diff --git a/src/app/groups/new/page.tsx b/src/app/[locale]/groups/new/page.tsx similarity index 65% rename from src/app/groups/new/page.tsx rename to src/app/[locale]/groups/new/page.tsx index afe0804..f85ed4a 100644 --- a/src/app/groups/new/page.tsx +++ b/src/app/[locale]/groups/new/page.tsx @@ -1,14 +1,15 @@ import { Navbar } from "@/components/Navbar"; import { CreateGroupForm } from "@/components/CreateGroupForm"; +import { useTranslations } from "next-intl"; export default function NewGroupPage() { + const t = useTranslations("NewGroup"); + return ( <>
-

- Create a Savings Group -

+

{t("title")}

diff --git a/src/app/groups/page.tsx b/src/app/[locale]/groups/page.tsx similarity index 88% rename from src/app/groups/page.tsx rename to src/app/[locale]/groups/page.tsx index 7592365..075c60f 100644 --- a/src/app/groups/page.tsx +++ b/src/app/[locale]/groups/page.tsx @@ -2,6 +2,7 @@ import { Navbar } from "@/components/Navbar"; import { GroupCard } from "@/components/GroupCard"; +import { useTranslations } from "next-intl"; import { SavingsGroup, GroupStatus } from "@sorosave/sdk"; // Placeholder data for development — will be replaced with contract queries @@ -39,6 +40,7 @@ const PLACEHOLDER_GROUPS: SavingsGroup[] = [ ]; export default function GroupsPage() { + const t = useTranslations("Groups"); // TODO: Replace with actual contract queries const groups = PLACEHOLDER_GROUPS; @@ -47,13 +49,11 @@ export default function GroupsPage() {
-

Savings Groups

+

{t("title")}

{groups.length === 0 ? ( -
- No groups found. Create the first one! -
+
{t("empty")}
) : (
{groups.map((group) => ( diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx new file mode 100644 index 0000000..b1f8a21 --- /dev/null +++ b/src/app/[locale]/layout.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { NextIntlClientProvider } from "next-intl"; +import { getMessages, getTranslations } from "next-intl/server"; +import { Providers } from "../providers"; +import { Locale, routing } from "@/i18n/routing"; +import "../globals.css"; + +function isLocale(locale: string): locale is Locale { + return routing.locales.includes(locale as Locale); +} + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +export async function generateMetadata({ + params, +}: { + params: { locale: string }; +}): Promise { + const locale = isLocale(params.locale) + ? params.locale + : routing.defaultLocale; + const t = await getTranslations({ locale, namespace: "Metadata" }); + + return { + title: t("title"), + description: t("description"), + }; +} + +export default async function LocaleLayout({ + children, + params, +}: { + children: React.ReactNode; + params: { locale: string }; +}) { + if (!isLocale(params.locale)) { + notFound(); + } + + const messages = await getMessages(); + + return ( + + + + {children} + + + + ); +} diff --git a/src/app/page.tsx b/src/app/[locale]/page.tsx similarity index 59% rename from src/app/page.tsx rename to src/app/[locale]/page.tsx index 670d0c6..6209e77 100644 --- a/src/app/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,7 +1,46 @@ -import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; import { Navbar } from "@/components/Navbar"; export default function Home() { + const t = useTranslations("Home"); + const steps = [ + { + step: "1", + title: t("steps.create.title"), + description: t("steps.create.description"), + }, + { + step: "2", + title: t("steps.join.title"), + description: t("steps.join.description"), + }, + { + step: "3", + title: t("steps.contribute.title"), + description: t("steps.contribute.description"), + }, + { + step: "4", + title: t("steps.payout.title"), + description: t("steps.payout.description"), + }, + ]; + const features = [ + { + title: t("features.trustless.title"), + description: t("features.trustless.description"), + }, + { + title: t("features.transparent.title"), + description: t("features.transparent.description"), + }, + { + title: t("features.lowCost.title"), + description: t("features.lowCost.description"), + }, + ]; + return ( <> @@ -11,25 +50,23 @@ export default function Home() {

- Decentralized Group Savings for Everyone + {t("heroTitle")}

- SoroSave brings the traditional rotating savings model (ajo, - susu, chit fund) to the Stellar blockchain. Trustless, - transparent, and accessible. + {t("heroDescription")}

- Browse Groups + {t("browseGroups")} - Create a Group + {t("createGroup")}
@@ -40,35 +77,10 @@ export default function Home() {

- How It Works + {t("howItWorks")}

- {[ - { - 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) => ( + {steps.map((item) => (
{item.step} @@ -87,26 +99,10 @@ export default function Home() {

- Why SoroSave? + {t("why")}

- {[ - { - 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) => ( + {features.map((feature) => (

SoroSave

-

- Open-source decentralized group savings protocol on Soroban. -

+

{t("footerDescription")}

diff --git a/src/app/layout.tsx b/src/app/layout.tsx deleted file mode 100644 index 17200f3..0000000 --- a/src/app/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Metadata } from "next"; -import { Providers } from "./providers"; -import "./globals.css"; - -export const metadata: Metadata = { - title: "SoroSave — Decentralized Group Savings", - description: - "A decentralized rotating savings protocol built on Soroban. Create or join savings groups, contribute each cycle, and receive the pot when it's your turn.", -}; - -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - - {children} - - - ); -} diff --git a/src/components/ConnectWallet.tsx b/src/components/ConnectWallet.tsx index f039a0e..b6299a6 100644 --- a/src/components/ConnectWallet.tsx +++ b/src/components/ConnectWallet.tsx @@ -1,9 +1,11 @@ "use client"; import { useWallet } from "@/app/providers"; +import { useTranslations } from "next-intl"; import { shortenAddress } from "@sorosave/sdk"; export function ConnectWallet() { + const t = useTranslations("Wallet"); const { address, isConnected, isFreighterAvailable, connect, disconnect } = useWallet(); @@ -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("connect")} ); } diff --git a/src/components/ContributeModal.tsx b/src/components/ContributeModal.tsx index 0d9d539..0e6b1d8 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; @@ -19,6 +20,7 @@ export function ContributeModal({ isOpen, onClose, }: ContributeModalProps) { + const t = useTranslations("ContributeModal"); const { address } = useWallet(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -33,16 +35,13 @@ export function ContributeModal({ try { const tx = await sorosaveClient.contribute(address, groupId, address); - const signedXdr = await signTransaction( - tx.toXDR(), - NETWORK_PASSPHRASE - ); + const signedXdr = await signTransaction(tx.toXDR(), NETWORK_PASSPHRASE); // TODO: Submit signed transaction console.log("Signed contribution:", signedXdr); onClose(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to contribute"); + setError(err instanceof Error ? err.message : t("fallbackError")); } finally { setLoading(false); } @@ -52,14 +51,14 @@ export function ContributeModal({

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

-

Amount to contribute

+

{t("amountLabel")}

- {formatAmount(contributionAmount)} tokens + {t("tokens", { amount: formatAmount(contributionAmount) })}

@@ -75,14 +74,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..b291544 100644 --- a/src/components/CreateGroupForm.tsx +++ b/src/components/CreateGroupForm.tsx @@ -5,8 +5,10 @@ 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 t = useTranslations("CreateGroupForm"); const { address, isConnected } = useWallet(); const [name, setName] = useState(""); const [tokenAddress, setTokenAddress] = useState(""); @@ -33,19 +35,16 @@ export function CreateGroupForm() { cycleLength: parseInt(cycleLength), maxMembers: parseInt(maxMembers), }, - address + address, ); - const signedXdr = await signTransaction( - tx.toXDR(), - NETWORK_PASSPHRASE - ); + const signedXdr = await signTransaction(tx.toXDR(), NETWORK_PASSPHRASE); // 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("fallbackError")); } finally { setLoading(false); } @@ -54,7 +53,7 @@ export function CreateGroupForm() { if (!isConnected) { return (
- Please connect your wallet to create a group. + {t("connectPrompt")}
); } @@ -63,7 +62,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("groupNamePlaceholder")} />
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("tokenAddressPlaceholder")} />
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("contributionAmountPlaceholder")} />
- {loading ? "Creating..." : "Create Savings Group"} + {loading ? t("creating") : t("submit")} ); diff --git a/src/components/GroupCard.tsx b/src/components/GroupCard.tsx index 34e0616..29236e8 100644 --- a/src/components/GroupCard.tsx +++ b/src/components/GroupCard.tsx @@ -1,7 +1,8 @@ "use client"; -import Link from "next/link"; -import { SavingsGroup, formatAmount, getStatusLabel } from "@sorosave/sdk"; +import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; +import { SavingsGroup, formatAmount } from "@sorosave/sdk"; interface GroupCardProps { group: SavingsGroup; @@ -16,6 +17,17 @@ const statusColors: Record = { }; export function GroupCard({ group }: GroupCardProps) { + const t = useTranslations("GroupCard"); + const statusT = useTranslations("GroupStatus"); + const statusLabels: Record = { + Forming: statusT("Forming"), + Active: statusT("Active"), + Completed: statusT("Completed"), + Disputed: statusT("Disputed"), + Paused: statusT("Paused"), + }; + const status = String(group.status); + return (
@@ -26,25 +38,25 @@ export function GroupCard({ group }: GroupCardProps) { statusColors[group.status] || "bg-gray-100 text-gray-800" }`} > - {getStatusLabel(group.status)} + {statusLabels[status] ?? status}
- Contribution + {t("contribution")} - {formatAmount(group.contributionAmount)} tokens + {t("tokens", { amount: formatAmount(group.contributionAmount) })}
- 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..d78a546 --- /dev/null +++ b/src/components/LanguageSwitcher.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useTransition } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { usePathname, useRouter } from "@/i18n/navigation"; +import { Locale, routing } from "@/i18n/routing"; + +export function LanguageSwitcher() { + const t = useTranslations("LanguageSwitcher"); + const locale = useLocale() as Locale; + const pathname = usePathname(); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + const handleLocaleChange = (nextLocale: Locale) => { + startTransition(() => { + router.replace(pathname, { locale: nextLocale }); + }); + }; + + return ( + + ); +} diff --git a/src/components/MemberList.tsx b/src/components/MemberList.tsx index bd469e1..4af37a7 100644 --- a/src/components/MemberList.tsx +++ b/src/components/MemberList.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { shortenAddress } from "@sorosave/sdk"; interface MemberListProps { @@ -15,13 +16,14 @@ export function MemberList({ payoutOrder, currentRound, }: MemberListProps) { + const t = useTranslations("MemberList"); + return (
-

Members

+

{t("title")}

{members.map((member, index) => { - const payoutRound = - payoutOrder.indexOf(member) + 1 || null; + const payoutRound = payoutOrder.indexOf(member) + 1 || null; const hasReceived = payoutRound !== null && payoutRound < currentRound; const isCurrentRecipient = @@ -42,7 +44,7 @@ export function MemberList({ {member === admin && ( - Admin + {t("admin")} )}
@@ -50,17 +52,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..761efb7 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,9 +1,13 @@ "use client"; -import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; import { ConnectWallet } from "./ConnectWallet"; +import { LanguageSwitcher } from "./LanguageSwitcher"; export function Navbar() { + const t = useTranslations("Navbar"); + return (
- +
+ + +
diff --git a/src/components/RoundProgress.tsx b/src/components/RoundProgress.tsx index 8105152..be866ab 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,22 @@ export function RoundProgress({ contributionsReceived, totalMembers, }: RoundProgressProps) { - const roundProgress = totalRounds > 0 ? (currentRound / totalRounds) * 100 : 0; + 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("roundOfTotal", { currentRound, totalRounds })}
@@ -39,9 +43,12 @@ export function RoundProgress({
- Current Round Contributions + {t("currentContributions")} - {contributionsReceived} / {totalMembers} + {t("contributionCount", { + received: contributionsReceived, + total: totalMembers, + })}
diff --git a/src/i18n/navigation.ts b/src/i18n/navigation.ts new file mode 100644 index 0000000..2c786c5 --- /dev/null +++ b/src/i18n/navigation.ts @@ -0,0 +1,5 @@ +import { createNavigation } from "next-intl/navigation"; +import { routing } from "./routing"; + +export const { Link, redirect, usePathname, useRouter, getPathname } = + createNavigation(routing); diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 0000000..71d2366 --- /dev/null +++ b/src/i18n/request.ts @@ -0,0 +1,18 @@ +import { getRequestConfig } from "next-intl/server"; +import { Locale, routing } from "./routing"; + +function isLocale(locale: string | undefined): locale is Locale { + return routing.locales.includes(locale as Locale); +} + +export default getRequestConfig(async ({ requestLocale }) => { + const requestedLocale = await requestLocale; + const locale = isLocale(requestedLocale) + ? requestedLocale + : routing.defaultLocale; + + return { + locale, + messages: (await import(`../messages/${locale}.json`)).default, + }; +}); diff --git a/src/i18n/routing.ts b/src/i18n/routing.ts new file mode 100644 index 0000000..be582bd --- /dev/null +++ b/src/i18n/routing.ts @@ -0,0 +1,9 @@ +import { defineRouting } from "next-intl/routing"; + +export const routing = defineRouting({ + locales: ["en", "fr"], + defaultLocale: "en", + localePrefix: "as-needed", +}); + +export type Locale = (typeof routing.locales)[number]; diff --git a/src/messages/en.json b/src/messages/en.json new file mode 100644 index 0000000..2a518e9 --- /dev/null +++ b/src/messages/en.json @@ -0,0 +1,142 @@ +{ + "Metadata": { + "title": "SoroSave - Decentralized Group Savings", + "description": "A decentralized rotating savings protocol built on Soroban. Create or join savings groups, contribute each cycle, and receive the pot when it is your turn." + }, + "Navbar": { + "groups": "Groups", + "createGroup": "Create Group", + "language": "Language" + }, + "LanguageSwitcher": { + "label": "Language", + "en": "English", + "fr": "French" + }, + "Wallet": { + "installFreighter": "Install Freighter", + "disconnect": "Disconnect", + "connect": "Connect Wallet" + }, + "Home": { + "heroTitle": "Decentralized Group Savings for Everyone", + "heroDescription": "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", + "howItWorks": "How It Works", + "steps": { + "create": { + "title": "Create a Group", + "description": "Set contribution amount, cycle length, and max members. You become the admin." + }, + "join": { + "title": "Members Join", + "description": "Share the group link. Members join until the group is full or the admin starts it." + }, + "contribute": { + "title": "Contribute Each Cycle", + "description": "Every member contributes the fixed amount each round. Smart contract enforces rules." + }, + "payout": { + "title": "Receive the Pot", + "description": "Each round, one member receives the full pot. Continues until everyone has received." + } + }, + "why": "Why SoroSave?", + "features": { + "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." + } + }, + "footerDescription": "Open-source decentralized group savings protocol on Soroban.", + "github": "GitHub", + "docs": "Docs", + "discord": "Discord" + }, + "Groups": { + "title": "Savings Groups", + "empty": "No groups found. Create the first one!" + }, + "GroupCard": { + "contribution": "Contribution", + "members": "Members", + "round": "Round", + "tokens": "{amount} tokens" + }, + "GroupStatus": { + "Forming": "Forming", + "Active": "Active", + "Completed": "Completed", + "Disputed": "Disputed", + "Paused": "Paused" + }, + "GroupDetail": { + "tokensPerCycle": "{amount} tokens per cycle", + "actions": "Actions", + "contribute": "Contribute", + "joinGroup": "Join Group", + "groupInfo": "Group Info", + "status": "Status", + "members": "Members", + "cycle": "Cycle", + "days": "{days} days", + "potSize": "Pot Size", + "tokens": "{amount} tokens" + }, + "NewGroup": { + "title": "Create a Savings Group" + }, + "CreateGroupForm": { + "connectPrompt": "Please connect your wallet to create a group.", + "groupName": "Group Name", + "groupNamePlaceholder": "My Savings Circle", + "tokenAddress": "Token Contract Address", + "tokenAddressPlaceholder": "CDLZ...", + "contributionAmount": "Contribution Amount (per cycle)", + "contributionAmountPlaceholder": "100", + "cycleLength": "Cycle Length (seconds)", + "maxMembers": "Max Members", + "durations": { + "hour": "1 Hour", + "day": "1 Day", + "week": "1 Week", + "month": "1 Month" + }, + "success": "Group created successfully!", + "fallbackError": "Failed to create group", + "creating": "Creating...", + "submit": "Create Savings Group" + }, + "ContributeModal": { + "title": "Confirm Contribution", + "amountLabel": "Amount to contribute", + "tokens": "{amount} tokens", + "fallbackError": "Failed to contribute", + "cancel": "Cancel", + "confirming": "Confirming...", + "contribute": "Contribute" + }, + "MemberList": { + "title": "Members", + "admin": "Admin", + "currentRecipient": "Current Recipient", + "received": "Received", + "round": "Round {round}" + }, + "RoundProgress": { + "title": "Progress", + "overall": "Overall Progress", + "roundOfTotal": "Round {currentRound} of {totalRounds}", + "currentContributions": "Current Round Contributions", + "contributionCount": "{received} / {total}" + } +} diff --git a/src/messages/fr.json b/src/messages/fr.json new file mode 100644 index 0000000..435456a --- /dev/null +++ b/src/messages/fr.json @@ -0,0 +1,142 @@ +{ + "Metadata": { + "title": "SoroSave - Epargne de groupe decentralisee", + "description": "Un protocole d'epargne rotative decentralise construit sur Soroban. Creez ou rejoignez des groupes d'epargne, contribuez a chaque cycle et recevez la cagnotte lorsque vient votre tour." + }, + "Navbar": { + "groups": "Groupes", + "createGroup": "Creer un groupe", + "language": "Langue" + }, + "LanguageSwitcher": { + "label": "Langue", + "en": "Anglais", + "fr": "Francais" + }, + "Wallet": { + "installFreighter": "Installer Freighter", + "disconnect": "Deconnecter", + "connect": "Connecter le portefeuille" + }, + "Home": { + "heroTitle": "Epargne de groupe decentralisee pour tous", + "heroDescription": "SoroSave apporte le modele traditionnel d'epargne rotative (ajo, susu, tontine) a la blockchain Stellar. Sans confiance, transparent et accessible.", + "browseGroups": "Parcourir les groupes", + "createGroup": "Creer un groupe", + "howItWorks": "Comment ca marche", + "steps": { + "create": { + "title": "Creez un groupe", + "description": "Definissez le montant de contribution, la duree du cycle et le nombre maximum de membres. Vous devenez l'administrateur." + }, + "join": { + "title": "Les membres rejoignent", + "description": "Partagez le lien du groupe. Les membres rejoignent jusqu'a ce que le groupe soit complet ou que l'administrateur le demarre." + }, + "contribute": { + "title": "Contribuez a chaque cycle", + "description": "Chaque membre contribue le montant fixe a chaque tour. Le contrat intelligent applique les regles." + }, + "payout": { + "title": "Recevez la cagnotte", + "description": "A chaque tour, un membre recoit toute la cagnotte. Le cycle continue jusqu'a ce que tout le monde ait recu." + } + }, + "why": "Pourquoi SoroSave ?", + "features": { + "trustless": { + "title": "Sans intermediaire", + "description": "Les contrats intelligents appliquent les contributions et les versements. Aucun tiers n'est necessaire." + }, + "transparent": { + "title": "Transparent", + "description": "Toutes les transactions sont en chaine. Chaque membre peut verifier l'etat du groupe." + }, + "lowCost": { + "title": "Faible cout", + "description": "Construit sur Soroban (Stellar). Les frais de transaction representent une fraction de centime." + } + }, + "footerDescription": "Protocole open source d'epargne de groupe decentralisee sur Soroban.", + "github": "GitHub", + "docs": "Docs", + "discord": "Discord" + }, + "Groups": { + "title": "Groupes d'epargne", + "empty": "Aucun groupe trouve. Creez le premier !" + }, + "GroupCard": { + "contribution": "Contribution", + "members": "Membres", + "round": "Tour", + "tokens": "{amount} jetons" + }, + "GroupStatus": { + "Forming": "En formation", + "Active": "Actif", + "Completed": "Termine", + "Disputed": "Conteste", + "Paused": "En pause" + }, + "GroupDetail": { + "tokensPerCycle": "{amount} jetons par cycle", + "actions": "Actions", + "contribute": "Contribuer", + "joinGroup": "Rejoindre le groupe", + "groupInfo": "Infos du groupe", + "status": "Statut", + "members": "Membres", + "cycle": "Cycle", + "days": "{days} jours", + "potSize": "Montant de la cagnotte", + "tokens": "{amount} jetons" + }, + "NewGroup": { + "title": "Creer un groupe d'epargne" + }, + "CreateGroupForm": { + "connectPrompt": "Veuillez connecter votre portefeuille pour creer un groupe.", + "groupName": "Nom du groupe", + "groupNamePlaceholder": "Mon cercle d'epargne", + "tokenAddress": "Adresse du contrat du jeton", + "tokenAddressPlaceholder": "CDLZ...", + "contributionAmount": "Montant de contribution (par cycle)", + "contributionAmountPlaceholder": "100", + "cycleLength": "Duree du cycle (secondes)", + "maxMembers": "Membres maximum", + "durations": { + "hour": "1 heure", + "day": "1 jour", + "week": "1 semaine", + "month": "1 mois" + }, + "success": "Groupe cree avec succes !", + "fallbackError": "Echec de creation du groupe", + "creating": "Creation...", + "submit": "Creer le groupe d'epargne" + }, + "ContributeModal": { + "title": "Confirmer la contribution", + "amountLabel": "Montant a contribuer", + "tokens": "{amount} jetons", + "fallbackError": "Echec de la contribution", + "cancel": "Annuler", + "confirming": "Confirmation...", + "contribute": "Contribuer" + }, + "MemberList": { + "title": "Membres", + "admin": "Admin", + "currentRecipient": "Beneficiaire actuel", + "received": "Recu", + "round": "Tour {round}" + }, + "RoundProgress": { + "title": "Progression", + "overall": "Progression globale", + "roundOfTotal": "Tour {currentRound} sur {totalRounds}", + "currentContributions": "Contributions du tour actuel", + "contributionCount": "{received} / {total}" + } +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..fcf488a --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,8 @@ +import createMiddleware from "next-intl/middleware"; +import { routing } from "./i18n/routing"; + +export default createMiddleware(routing); + +export const config = { + matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"], +};