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/groups/[id]/page.tsx b/frontend/src/app/[locale]/groups/[id]/page.tsx similarity index 79% rename from frontend/src/app/groups/[id]/page.tsx rename to frontend/src/app/[locale]/groups/[id]/page.tsx index 02ab880..6c20bda 100644 --- a/frontend/src/app/groups/[id]/page.tsx +++ b/frontend/src/app/[locale]/groups/[id]/page.tsx @@ -1,10 +1,11 @@ "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 { useState } from "react"; import { formatAmount, GroupStatus } from "@sorosave/sdk"; // TODO: Fetch real data from contract @@ -33,8 +34,16 @@ const MOCK_GROUP = { }; 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 ( <> @@ -43,7 +52,7 @@ export default function GroupDetailPage() {

{group.name}

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

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

- Actions + {t("actions")}

{group.status === GroupStatus.Active && ( @@ -75,12 +84,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 +97,34 @@ 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 + {group.cycleLength / 86400} {t("days")}
-
Pot Size
+
{t("potSize")}
{formatAmount( group.contributionAmount * BigInt(group.members.length) )}{" "} - tokens + {t("tokens")}
diff --git a/frontend/src/app/groups/new/page.tsx b/frontend/src/app/[locale]/groups/new/page.tsx similarity index 77% rename from frontend/src/app/groups/new/page.tsx rename to frontend/src/app/[locale]/groups/new/page.tsx index afe0804..a12626f 100644 --- a/frontend/src/app/groups/new/page.tsx +++ b/frontend/src/app/[locale]/groups/new/page.tsx @@ -1,13 +1,16 @@ +import { useTranslations } from "next-intl"; import { Navbar } from "@/components/Navbar"; import { CreateGroupForm } from "@/components/CreateGroupForm"; export default function NewGroupPage() { + const t = useTranslations("NewGroupPage"); + return ( <>

- Create a Savings Group + {t("title")}

diff --git a/frontend/src/app/groups/page.tsx b/frontend/src/app/[locale]/groups/page.tsx similarity index 84% rename from frontend/src/app/groups/page.tsx rename to frontend/src/app/[locale]/groups/page.tsx index 7592365..ff1e193 100644 --- a/frontend/src/app/groups/page.tsx +++ b/frontend/src/app/[locale]/groups/page.tsx @@ -1,10 +1,11 @@ "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 +// Placeholder data for development - will be replaced with contract queries const PLACEHOLDER_GROUPS: SavingsGroup[] = [ { id: 1, @@ -39,6 +40,7 @@ const PLACEHOLDER_GROUPS: SavingsGroup[] = [ ]; export default function GroupsPage() { + const t = useTranslations("GroupsPage"); // 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/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/page.tsx b/frontend/src/app/[locale]/page.tsx similarity index 54% rename from frontend/src/app/page.tsx rename to frontend/src/app/[locale]/page.tsx index 670d0c6..f2a8a16 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/[locale]/page.tsx @@ -1,7 +1,13 @@ -import Link from "next/link"; +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 ( <> @@ -11,25 +17,23 @@ export default function Home() {

- Decentralized Group Savings for Everyone + {t("hero.title")}

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

- Browse Groups + {t("hero.browseGroups")} - Create a Group + {t("hero.createGroup")}
@@ -40,43 +44,20 @@ export default function Home() {

- How It Works + {t("how.title")}

- {[ - { - 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) => ( -
+ {stepKeys.map((stepKey, index) => ( +
- {item.step} + {index + 1}

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

-

{item.description}

+

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

))}
@@ -87,34 +68,20 @@ export default function Home() {

- Why SoroSave? + {t("features.title")}

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

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

-

{feature.description}

+

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

))}
@@ -125,9 +92,7 @@ export default function Home() {