From 7a77da5ef229435207034c57052810eede8bf5df Mon Sep 17 00:00:00 2001 From: cryptodane-leduke Date: Mon, 11 May 2026 18:19:45 +0200 Subject: [PATCH 1/6] Delete frontend/src/app/page.tsx Move frontend pages under locale route --- frontend/src/app/page.tsx | 150 -------------------------------------- 1 file changed, 150 deletions(-) delete mode 100644 frontend/src/app/page.tsx diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx deleted file mode 100644 index 670d0c6..0000000 --- a/frontend/src/app/page.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import Link from "next/link"; -import { Navbar } from "@/components/Navbar"; - -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 */} - -
- - ); -} From e8247df7748f60a2247cde5af8d61b687b7c0b59 Mon Sep 17 00:00:00 2001 From: cryptodane-leduke Date: Mon, 11 May 2026 18:20:21 +0200 Subject: [PATCH 2/6] Delete frontend/src/app/layout.tsx Move frontend pages under locale route --- frontend/src/app/layout.tsx | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 frontend/src/app/layout.tsx diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx deleted file mode 100644 index 17200f3..0000000 --- a/frontend/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} - - - ); -} From 91a5d3c663d2030fb43445ae2bdfb8ed0c0c0e65 Mon Sep 17 00:00:00 2001 From: cryptodane-leduke Date: Mon, 11 May 2026 18:20:36 +0200 Subject: [PATCH 3/6] Delete frontend/src/app/groups/page.tsx Move frontend pages under locale route --- frontend/src/app/groups/page.tsx | 67 -------------------------------- 1 file changed, 67 deletions(-) delete mode 100644 frontend/src/app/groups/page.tsx diff --git a/frontend/src/app/groups/page.tsx b/frontend/src/app/groups/page.tsx deleted file mode 100644 index 7592365..0000000 --- a/frontend/src/app/groups/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; - -import { Navbar } from "@/components/Navbar"; -import { GroupCard } from "@/components/GroupCard"; -import { SavingsGroup, GroupStatus } from "@sorosave/sdk"; - -// Placeholder data for development — will be replaced with contract queries -const PLACEHOLDER_GROUPS: SavingsGroup[] = [ - { - id: 1, - name: "Lagos Savings Circle", - admin: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", - token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", - contributionAmount: 1000000000n, - cycleLength: 604800, - maxMembers: 5, - members: ["GABCD...", "GEFGH...", "GIJKL..."], - payoutOrder: [], - currentRound: 0, - totalRounds: 0, - status: GroupStatus.Forming, - createdAt: 1700000000, - }, - { - id: 2, - name: "DeFi Builders Fund", - admin: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", - token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", - contributionAmount: 5000000000n, - cycleLength: 2592000, - maxMembers: 10, - members: ["GABCD...", "GEFGH...", "GIJKL...", "GMNOP...", "GQRST..."], - payoutOrder: ["GABCD...", "GEFGH...", "GIJKL...", "GMNOP...", "GQRST..."], - currentRound: 2, - totalRounds: 5, - status: GroupStatus.Active, - createdAt: 1699000000, - }, -]; - -export default function GroupsPage() { - // TODO: Replace with actual contract queries - const groups = PLACEHOLDER_GROUPS; - - return ( - <> - -
-
-

Savings Groups

-
- - {groups.length === 0 ? ( -
- No groups found. Create the first one! -
- ) : ( -
- {groups.map((group) => ( - - ))} -
- )} -
- - ); -} From 8fae92e193d66cc97ccc3fc8aa82c5c739bd8420 Mon Sep 17 00:00:00 2001 From: cryptodane-leduke Date: Mon, 11 May 2026 18:20:52 +0200 Subject: [PATCH 4/6] Delete frontend/src/app/groups/new/page.tsx Move frontend pages under locale route --- frontend/src/app/groups/new/page.tsx | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 frontend/src/app/groups/new/page.tsx diff --git a/frontend/src/app/groups/new/page.tsx b/frontend/src/app/groups/new/page.tsx deleted file mode 100644 index afe0804..0000000 --- a/frontend/src/app/groups/new/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Navbar } from "@/components/Navbar"; -import { CreateGroupForm } from "@/components/CreateGroupForm"; - -export default function NewGroupPage() { - return ( - <> - -
-

- Create a Savings Group -

- -
- - ); -} From d937a082ccb25225ea32536790f7b66525129d87 Mon Sep 17 00:00:00 2001 From: cryptodane-leduke Date: Mon, 11 May 2026 18:21:44 +0200 Subject: [PATCH 5/6] Delete frontend/src/app/groups/[id]/page.tsx Move frontend pages under locale route --- frontend/src/app/groups/[id]/page.tsx | 133 -------------------------- 1 file changed, 133 deletions(-) delete mode 100644 frontend/src/app/groups/[id]/page.tsx diff --git a/frontend/src/app/groups/[id]/page.tsx b/frontend/src/app/groups/[id]/page.tsx deleted file mode 100644 index 02ab880..0000000 --- a/frontend/src/app/groups/[id]/page.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import { Navbar } from "@/components/Navbar"; -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"; - -// TODO: Fetch real data from contract -const MOCK_GROUP = { - id: 1, - name: "Lagos Savings Circle", - admin: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", - token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", - contributionAmount: 1000000000n, - cycleLength: 604800, - maxMembers: 5, - members: [ - "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", - "GEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJ", - "GIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMN", - ], - payoutOrder: [ - "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", - "GEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJ", - "GIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMN", - ], - currentRound: 1, - totalRounds: 3, - status: GroupStatus.Active, - createdAt: 1700000000, -}; - -export default function GroupDetailPage() { - const [showContributeModal, setShowContributeModal] = useState(false); - const group = MOCK_GROUP; - - return ( - <> - -
-
-

{group.name}

-

- {formatAmount(group.contributionAmount)} tokens per cycle -

-
- -
-
- - - -
- -
-
-

- Actions -

-
- {group.status === GroupStatus.Active && ( - - )} - {group.status === GroupStatus.Forming && ( - - )} -
-
- -
-

- Group Info -

-
-
-
Status
-
{group.status}
-
-
-
Members
-
- {group.members.length}/{group.maxMembers} -
-
-
-
Cycle
-
- {group.cycleLength / 86400} days -
-
-
-
Pot Size
-
- {formatAmount( - group.contributionAmount * BigInt(group.members.length) - )}{" "} - tokens -
-
-
-
-
-
-
- - setShowContributeModal(false)} - /> - - ); -} From 7f42cf98d61b9bccd43d1baf81629818404c1371 Mon Sep 17 00:00:00 2001 From: cryptodane-leduke Date: Mon, 11 May 2026 18:26:33 +0200 Subject: [PATCH 6/6] Add frontend internationalization support --- frontend/next.config.js | 6 +- frontend/package.json | 1 + .../src/app/[locale]/groups/[id]/page.tsx | 144 ++++++++++++++++ frontend/src/app/[locale]/groups/new/page.tsx | 19 +++ frontend/src/app/[locale]/groups/page.tsx | 67 ++++++++ frontend/src/app/[locale]/layout.tsx | 49 ++++++ frontend/src/app/[locale]/page.tsx | 115 +++++++++++++ frontend/src/components/ConnectWallet.tsx | 8 +- frontend/src/components/ContributeModal.tsx | 14 +- frontend/src/components/CreateGroupForm.tsx | 32 ++-- frontend/src/components/GroupCard.tsx | 26 ++- frontend/src/components/LanguageSwitcher.tsx | 33 ++++ frontend/src/components/MemberList.tsx | 15 +- frontend/src/components/Navbar.tsx | 15 +- frontend/src/components/RoundProgress.tsx | 13 +- frontend/src/i18n/navigation.ts | 5 + frontend/src/i18n/request.ts | 15 ++ frontend/src/i18n/routing.ts | 9 + frontend/src/messages/en.json | 154 ++++++++++++++++++ frontend/src/messages/fr.json | 154 ++++++++++++++++++ frontend/src/middleware.ts | 8 + 21 files changed, 856 insertions(+), 46 deletions(-) create mode 100644 frontend/src/app/[locale]/groups/[id]/page.tsx create mode 100644 frontend/src/app/[locale]/groups/new/page.tsx create mode 100644 frontend/src/app/[locale]/groups/page.tsx create mode 100644 frontend/src/app/[locale]/layout.tsx create mode 100644 frontend/src/app/[locale]/page.tsx create mode 100644 frontend/src/components/LanguageSwitcher.tsx create mode 100644 frontend/src/i18n/navigation.ts create mode 100644 frontend/src/i18n/request.ts create mode 100644 frontend/src/i18n/routing.ts create mode 100644 frontend/src/messages/en.json create mode 100644 frontend/src/messages/fr.json create mode 100644 frontend/src/middleware.ts diff --git a/frontend/next.config.js b/frontend/next.config.js index 55d4c9b..cae6a76 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,3 +1,7 @@ +const createNextIntlPlugin = require("next-intl/plugin"); + +const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, @@ -12,4 +16,4 @@ const nextConfig = { }, }; -module.exports = nextConfig; +module.exports = withNextIntl(nextConfig); diff --git a/frontend/package.json b/frontend/package.json index 7e788f6..b419444 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@sorosave/sdk": "workspace:*", "@stellar/freighter-api": "^2.0.0", "next": "^14.2.0", + "next-intl": "^4.11.2", "react": "^18.3.0", "react-dom": "^18.3.0" }, diff --git a/frontend/src/app/[locale]/groups/[id]/page.tsx b/frontend/src/app/[locale]/groups/[id]/page.tsx new file mode 100644 index 0000000..6c20bda --- /dev/null +++ b/frontend/src/app/[locale]/groups/[id]/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Navbar } from "@/components/Navbar"; +import { MemberList } from "@/components/MemberList"; +import { RoundProgress } from "@/components/RoundProgress"; +import { ContributeModal } from "@/components/ContributeModal"; +import { formatAmount, GroupStatus } from "@sorosave/sdk"; + +// TODO: Fetch real data from contract +const MOCK_GROUP = { + id: 1, + name: "Lagos Savings Circle", + admin: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contributionAmount: 1000000000n, + cycleLength: 604800, + maxMembers: 5, + members: [ + "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", + "GEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJ", + "GIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMN", + ], + payoutOrder: [ + "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", + "GEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJ", + "GIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMN", + ], + currentRound: 1, + totalRounds: 3, + status: GroupStatus.Active, + createdAt: 1700000000, +}; + +export default function GroupDetailPage() { + const t = useTranslations("GroupDetailPage"); + const [showContributeModal, setShowContributeModal] = useState(false); + const group = MOCK_GROUP; + const statusLabels: Record = { + Forming: t("statusLabels.forming"), + Active: t("statusLabels.active"), + Completed: t("statusLabels.completed"), + Disputed: t("statusLabels.disputed"), + Paused: t("statusLabels.paused"), + }; + + return ( + <> + +
+
+

{group.name}

+

+ {formatAmount(group.contributionAmount)} {t("tokensPerCycle")} +

+
+ +
+
+ + + +
+ +
+
+

+ {t("actions")} +

+
+ {group.status === GroupStatus.Active && ( + + )} + {group.status === GroupStatus.Forming && ( + + )} +
+
+ +
+

+ {t("groupInfo")} +

+
+
+
{t("status")}
+
+ {statusLabels[String(group.status)] || String(group.status)} +
+
+
+
{t("members")}
+
+ {group.members.length}/{group.maxMembers} +
+
+
+
{t("cycle")}
+
+ {group.cycleLength / 86400} {t("days")} +
+
+
+
{t("potSize")}
+
+ {formatAmount( + group.contributionAmount * BigInt(group.members.length) + )}{" "} + {t("tokens")} +
+
+
+
+
+
+
+ + setShowContributeModal(false)} + /> + + ); +} diff --git a/frontend/src/app/[locale]/groups/new/page.tsx b/frontend/src/app/[locale]/groups/new/page.tsx new file mode 100644 index 0000000..a12626f --- /dev/null +++ b/frontend/src/app/[locale]/groups/new/page.tsx @@ -0,0 +1,19 @@ +import { useTranslations } from "next-intl"; +import { Navbar } from "@/components/Navbar"; +import { CreateGroupForm } from "@/components/CreateGroupForm"; + +export default function NewGroupPage() { + const t = useTranslations("NewGroupPage"); + + return ( + <> + +
+

+ {t("title")} +

+ +
+ + ); +} diff --git a/frontend/src/app/[locale]/groups/page.tsx b/frontend/src/app/[locale]/groups/page.tsx new file mode 100644 index 0000000..ff1e193 --- /dev/null +++ b/frontend/src/app/[locale]/groups/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Navbar } from "@/components/Navbar"; +import { GroupCard } from "@/components/GroupCard"; +import { SavingsGroup, GroupStatus } from "@sorosave/sdk"; + +// Placeholder data for development - will be replaced with contract queries +const PLACEHOLDER_GROUPS: SavingsGroup[] = [ + { + id: 1, + name: "Lagos Savings Circle", + admin: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contributionAmount: 1000000000n, + cycleLength: 604800, + maxMembers: 5, + members: ["GABCD...", "GEFGH...", "GIJKL..."], + payoutOrder: [], + currentRound: 0, + totalRounds: 0, + status: GroupStatus.Forming, + createdAt: 1700000000, + }, + { + id: 2, + name: "DeFi Builders Fund", + admin: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contributionAmount: 5000000000n, + cycleLength: 2592000, + maxMembers: 10, + members: ["GABCD...", "GEFGH...", "GIJKL...", "GMNOP...", "GQRST..."], + payoutOrder: ["GABCD...", "GEFGH...", "GIJKL...", "GMNOP...", "GQRST..."], + currentRound: 2, + totalRounds: 5, + status: GroupStatus.Active, + createdAt: 1699000000, + }, +]; + +export default function GroupsPage() { + const t = useTranslations("GroupsPage"); + // TODO: Replace with actual contract queries + const groups = PLACEHOLDER_GROUPS; + + return ( + <> + +
+
+

{t("title")}

+
+ + {groups.length === 0 ? ( +
{t("empty")}
+ ) : ( +
+ {groups.map((group) => ( + + ))} +
+ )} +
+ + ); +} diff --git a/frontend/src/app/[locale]/layout.tsx b/frontend/src/app/[locale]/layout.tsx new file mode 100644 index 0000000..9c5b96f --- /dev/null +++ b/frontend/src/app/[locale]/layout.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import { hasLocale, NextIntlClientProvider } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; +import { Providers } from "../providers"; +import { routing, type Locale } from "@/i18n/routing"; +import "../globals.css"; + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +export async function generateMetadata({ + params, +}: { + params: { locale: Locale }; +}): Promise { + const t = await getTranslations({ + locale: params.locale, + namespace: "Metadata", + }); + + return { + title: t("title"), + description: t("description"), + }; +} + +export default function RootLayout({ + children, + params, +}: { + children: React.ReactNode; + params: { locale: Locale }; +}) { + if (!hasLocale(routing.locales, params.locale)) { + notFound(); + } + + return ( + + + + {children} + + + + ); +} diff --git a/frontend/src/app/[locale]/page.tsx b/frontend/src/app/[locale]/page.tsx new file mode 100644 index 0000000..f2a8a16 --- /dev/null +++ b/frontend/src/app/[locale]/page.tsx @@ -0,0 +1,115 @@ +import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; +import { Navbar } from "@/components/Navbar"; + +const stepKeys = ["create", "join", "contribute", "receive"] as const; +const featureKeys = ["trustless", "transparent", "lowCost"] as const; + +export default function Home() { + const t = useTranslations("HomePage"); + + return ( + <> + +
+ {/* Hero */} +
+
+
+

+ {t("hero.title")} +

+

+ {t("hero.description")} +

+
+ + {t("hero.browseGroups")} + + + {t("hero.createGroup")} + +
+
+
+
+ + {/* How it works */} +
+
+

+ {t("how.title")} +

+
+ {stepKeys.map((stepKey, index) => ( +
+
+ {index + 1} +
+

+ {t(`how.steps.${stepKey}.title`)} +

+

+ {t(`how.steps.${stepKey}.description`)} +

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

+ {t("features.title")} +

+
+ {featureKeys.map((feature) => ( +
+

+ {t(`features.items.${feature}.title`)} +

+

+ {t(`features.items.${feature}.description`)} +

+
+ ))} +
+
+
+ + {/* Footer */} + +
+ + ); +} diff --git a/frontend/src/components/ConnectWallet.tsx b/frontend/src/components/ConnectWallet.tsx index f039a0e..61b96b2 100644 --- a/frontend/src/components/ConnectWallet.tsx +++ b/frontend/src/components/ConnectWallet.tsx @@ -1,9 +1,11 @@ "use client"; +import { useTranslations } from "next-intl"; import { useWallet } from "@/app/providers"; 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("connectWallet")} ); } diff --git a/frontend/src/components/ContributeModal.tsx b/frontend/src/components/ContributeModal.tsx index 0d9d539..98be6ec 100644 --- a/frontend/src/components/ContributeModal.tsx +++ b/frontend/src/components/ContributeModal.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { useWallet } from "@/app/providers"; import { sorosaveClient, NETWORK_PASSPHRASE } from "@/lib/sorosave"; @@ -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); @@ -42,7 +44,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("error")); } finally { setLoading(false); } @@ -52,14 +54,14 @@ export function ContributeModal({

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

-

Amount to contribute

+

{t("amount")}

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

@@ -75,14 +77,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/frontend/src/components/CreateGroupForm.tsx b/frontend/src/components/CreateGroupForm.tsx index 0a1a767..7016351 100644 --- a/frontend/src/components/CreateGroupForm.tsx +++ b/frontend/src/components/CreateGroupForm.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { useWallet } from "@/app/providers"; import { sorosaveClient, NETWORK_PASSPHRASE } from "@/lib/sorosave"; @@ -7,6 +8,7 @@ import { parseAmount } from "@sorosave/sdk"; import { signTransaction } from "@/lib/wallet"; export function CreateGroupForm() { + const t = useTranslations("CreateGroupForm"); const { address, isConnected } = useWallet(); const [name, setName] = useState(""); const [tokenAddress, setTokenAddress] = 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("error")); } finally { setLoading(false); } @@ -54,7 +56,7 @@ export function CreateGroupForm() { if (!isConnected) { return (
- Please connect your wallet to create a group. + {t("walletRequired")}
); } @@ -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("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("tokenPlaceholder")} />
- {loading ? "Creating..." : "Create Savings Group"} + {loading ? t("creating") : t("submit")} ); diff --git a/frontend/src/components/GroupCard.tsx b/frontend/src/components/GroupCard.tsx index 34e0616..19d1d0b 100644 --- a/frontend/src/components/GroupCard.tsx +++ b/frontend/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,15 @@ const statusColors: Record = { }; export function GroupCard({ group }: GroupCardProps) { + const t = useTranslations("GroupCard"); + const statusLabels: Record = { + Forming: t("status.forming"), + Active: t("status.active"), + Completed: t("status.completed"), + Disputed: t("status.disputed"), + Paused: t("status.paused"), + }; + return (
@@ -23,28 +33,28 @@ export function GroupCard({ group }: GroupCardProps) {

{group.name}

- {getStatusLabel(group.status)} + {statusLabels[String(group.status)] || String(group.status)}
- Contribution + {t("contribution")} - {formatAmount(group.contributionAmount)} tokens + {formatAmount(group.contributionAmount)} {t("tokens")}
- Members + {t("members")} {group.members.length} / {group.maxMembers}
- Round + {t("round")} {group.currentRound} / {group.totalRounds || group.maxMembers} diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..fe60f67 --- /dev/null +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useLocale, useTranslations } from "next-intl"; +import { routing, type Locale } from "@/i18n/routing"; +import { usePathname, useRouter } from "@/i18n/navigation"; + +export function LanguageSwitcher() { + const t = useTranslations("LocaleSwitcher"); + const locale = useLocale() as Locale; + const pathname = usePathname(); + const router = useRouter(); + + return ( + + ); +} diff --git a/frontend/src/components/MemberList.tsx b/frontend/src/components/MemberList.tsx index bd469e1..3508904 100644 --- a/frontend/src/components/MemberList.tsx +++ b/frontend/src/components/MemberList.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { shortenAddress } from "@sorosave/sdk"; interface MemberListProps { @@ -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")} {payoutRound} )}
diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 2d673aa..569bef3 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/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/frontend/src/components/RoundProgress.tsx b/frontend/src/components/RoundProgress.tsx index 8105152..146b4be 100644 --- a/frontend/src/components/RoundProgress.tsx +++ b/frontend/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,7 @@ export function RoundProgress({
- Current Round Contributions + {t("currentContributions")} {contributionsReceived} / {totalMembers} diff --git a/frontend/src/i18n/navigation.ts b/frontend/src/i18n/navigation.ts new file mode 100644 index 0000000..2c786c5 --- /dev/null +++ b/frontend/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/frontend/src/i18n/request.ts b/frontend/src/i18n/request.ts new file mode 100644 index 0000000..7157f14 --- /dev/null +++ b/frontend/src/i18n/request.ts @@ -0,0 +1,15 @@ +import { hasLocale } from "next-intl"; +import { getRequestConfig } from "next-intl/server"; +import { routing } from "./routing"; + +export default getRequestConfig(async ({ requestLocale }) => { + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; + + return { + locale, + messages: (await import(`../messages/${locale}.json`)).default, + }; +}); diff --git a/frontend/src/i18n/routing.ts b/frontend/src/i18n/routing.ts new file mode 100644 index 0000000..be582bd --- /dev/null +++ b/frontend/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/frontend/src/messages/en.json b/frontend/src/messages/en.json new file mode 100644 index 0000000..05e6095 --- /dev/null +++ b/frontend/src/messages/en.json @@ -0,0 +1,154 @@ +{ + "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." + }, + "Navbar": { + "groups": "Groups", + "createGroup": "Create Group" + }, + "Wallet": { + "installFreighter": "Install Freighter", + "disconnect": "Disconnect", + "connectWallet": "Connect Wallet" + }, + "LocaleSwitcher": { + "label": "Language", + "locales": { + "en": "English", + "fr": "French" + } + }, + "HomePage": { + "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": { + "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." + }, + "receive": { + "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" + } + }, + "GroupsPage": { + "title": "Savings Groups", + "empty": "No groups found. Create the first one!" + }, + "NewGroupPage": { + "title": "Create a Savings Group" + }, + "GroupCard": { + "contribution": "Contribution", + "members": "Members", + "round": "Round", + "tokens": "tokens", + "status": { + "forming": "Accepting Members", + "active": "Active", + "completed": "Completed", + "disputed": "Under Dispute", + "paused": "Paused" + } + }, + "CreateGroupForm": { + "walletRequired": "Please connect your wallet to create a group.", + "groupName": "Group Name", + "groupNamePlaceholder": "My Savings Circle", + "tokenContractAddress": "Token Contract Address", + "tokenPlaceholder": "CDLZ...", + "contributionAmount": "Contribution Amount (per cycle)", + "cycleLength": "Cycle Length (seconds)", + "maxMembers": "Max Members", + "duration": { + "hour": "1 Hour", + "day": "1 Day", + "week": "1 Week", + "month": "1 Month" + }, + "success": "Group created successfully!", + "error": "Failed to create group", + "creating": "Creating...", + "submit": "Create Savings Group" + }, + "GroupDetailPage": { + "tokensPerCycle": "tokens per cycle", + "actions": "Actions", + "contribute": "Contribute", + "joinGroup": "Join Group", + "groupInfo": "Group Info", + "status": "Status", + "members": "Members", + "cycle": "Cycle", + "potSize": "Pot Size", + "days": "days", + "tokens": "tokens", + "statusLabels": { + "forming": "Accepting Members", + "active": "Active", + "completed": "Completed", + "disputed": "Under Dispute", + "paused": "Paused" + } + }, + "MemberList": { + "title": "Members", + "admin": "Admin", + "currentRecipient": "Current Recipient", + "received": "Received", + "round": "Round" + }, + "RoundProgress": { + "title": "Progress", + "overall": "Overall Progress", + "roundOf": "Round {currentRound} of {totalRounds}", + "currentContributions": "Current Round Contributions" + }, + "ContributeModal": { + "title": "Confirm Contribution", + "amount": "Amount to contribute", + "tokens": "tokens", + "error": "Failed to contribute", + "cancel": "Cancel", + "confirming": "Confirming...", + "contribute": "Contribute" + } +} diff --git a/frontend/src/messages/fr.json b/frontend/src/messages/fr.json new file mode 100644 index 0000000..981d6f9 --- /dev/null +++ b/frontend/src/messages/fr.json @@ -0,0 +1,154 @@ +{ + "Metadata": { + "title": "SoroSave - Epargne collective decentralisee", + "description": "Un protocole d'epargne rotative decentralisee construit sur Soroban. Creez ou rejoignez des groupes d'epargne, contribuez a chaque cycle et recevez la cagnotte quand vient votre tour." + }, + "Navbar": { + "groups": "Groupes", + "createGroup": "Creer un groupe" + }, + "Wallet": { + "installFreighter": "Installer Freighter", + "disconnect": "Deconnecter", + "connectWallet": "Connecter le wallet" + }, + "LocaleSwitcher": { + "label": "Langue", + "locales": { + "en": "Anglais", + "fr": "Francais" + } + }, + "HomePage": { + "hero": { + "title": "Epargne collective decentralisee pour tous", + "description": "SoroSave apporte le modele traditionnel d'epargne rotative (ajo, susu, tontine) a la blockchain Stellar. Sans confiance aveugle, transparent et accessible.", + "browseGroups": "Voir les groupes", + "createGroup": "Creer un groupe" + }, + "how": { + "title": "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 lance." + }, + "contribute": { + "title": "Contribuez a chaque cycle", + "description": "Chaque membre verse le montant fixe a chaque tour. Le smart contract applique les regles." + }, + "receive": { + "title": "Recevez la cagnotte", + "description": "A chaque tour, un membre recoit toute la cagnotte. Le processus continue jusqu'a ce que tout le monde l'ait recue." + } + } + }, + "features": { + "title": "Pourquoi SoroSave ?", + "items": { + "trustless": { + "title": "Sans intermediaire", + "description": "Les smart contracts appliquent les contributions et les paiements. Aucun intermediaire n'est necessaire." + }, + "transparent": { + "title": "Transparent", + "description": "Toutes les transactions sont on-chain. Chaque membre peut verifier l'etat du groupe." + }, + "lowCost": { + "title": "Faible cout", + "description": "Construit sur Soroban (Stellar). Les frais de transaction ne representent qu'une fraction de centime." + } + } + }, + "footer": { + "description": "Protocole open source d'epargne collective decentralisee sur Soroban.", + "docs": "Docs" + } + }, + "GroupsPage": { + "title": "Groupes d'epargne", + "empty": "Aucun groupe trouve. Creez le premier !" + }, + "NewGroupPage": { + "title": "Creer un groupe d'epargne" + }, + "GroupCard": { + "contribution": "Contribution", + "members": "Membres", + "round": "Tour", + "tokens": "tokens", + "status": { + "forming": "Accepte des membres", + "active": "Actif", + "completed": "Termine", + "disputed": "En litige", + "paused": "En pause" + } + }, + "CreateGroupForm": { + "walletRequired": "Veuillez connecter votre wallet pour creer un groupe.", + "groupName": "Nom du groupe", + "groupNamePlaceholder": "Mon cercle d'epargne", + "tokenContractAddress": "Adresse du contrat token", + "tokenPlaceholder": "CDLZ...", + "contributionAmount": "Montant de contribution (par cycle)", + "cycleLength": "Duree du cycle (secondes)", + "maxMembers": "Nombre max de membres", + "duration": { + "hour": "1 heure", + "day": "1 jour", + "week": "1 semaine", + "month": "1 mois" + }, + "success": "Groupe cree avec succes !", + "error": "Echec de la creation du groupe", + "creating": "Creation...", + "submit": "Creer le groupe d'epargne" + }, + "GroupDetailPage": { + "tokensPerCycle": "tokens par cycle", + "actions": "Actions", + "contribute": "Contribuer", + "joinGroup": "Rejoindre le groupe", + "groupInfo": "Infos du groupe", + "status": "Statut", + "members": "Membres", + "cycle": "Cycle", + "potSize": "Taille de la cagnotte", + "days": "jours", + "tokens": "tokens", + "statusLabels": { + "forming": "Accepte des membres", + "active": "Actif", + "completed": "Termine", + "disputed": "En litige", + "paused": "En pause" + } + }, + "MemberList": { + "title": "Membres", + "admin": "Admin", + "currentRecipient": "Beneficiaire actuel", + "received": "Recu", + "round": "Tour" + }, + "RoundProgress": { + "title": "Progression", + "overall": "Progression globale", + "roundOf": "Tour {currentRound} sur {totalRounds}", + "currentContributions": "Contributions du tour actuel" + }, + "ContributeModal": { + "title": "Confirmer la contribution", + "amount": "Montant a contribuer", + "tokens": "tokens", + "error": "Echec de la contribution", + "cancel": "Annuler", + "confirming": "Confirmation...", + "contribute": "Contribuer" + } +} diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts new file mode 100644 index 0000000..fcf488a --- /dev/null +++ b/frontend/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|.*\\..*).*)"], +};