Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -12,4 +16,4 @@ const nextConfig = {
},
};

module.exports = nextConfig;
module.exports = withNextIntl(nextConfig);
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
59 changes: 59 additions & 0 deletions scripts/validate-messages.mjs
Original file line number Diff line number Diff line change
@@ -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.`,
);
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -33,6 +34,15 @@ const MOCK_GROUP = {
};

export default function GroupDetailPage() {
const t = useTranslations("GroupDetail");
const statusT = useTranslations("GroupStatus");
const statusLabels: Record<string, string> = {
Forming: statusT("Forming"),
Active: statusT("Active"),
Completed: statusT("Completed"),
Disputed: statusT("Disputed"),
Paused: statusT("Paused"),
};
const [showContributeModal, setShowContributeModal] = useState(false);
const group = MOCK_GROUP;

Expand All @@ -43,7 +53,9 @@ export default function GroupDetailPage() {
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">{group.name}</h1>
<p className="text-gray-600 mt-1">
{formatAmount(group.contributionAmount)} tokens per cycle
{t("tokensPerCycle", {
amount: formatAmount(group.contributionAmount),
})}
</p>
</div>

Expand All @@ -67,53 +79,56 @@ export default function GroupDetailPage() {
<div className="space-y-4">
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Actions
{t("actions")}
</h3>
<div className="space-y-3">
{group.status === GroupStatus.Active && (
<button
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")}
</button>
)}
{group.status === GroupStatus.Forming && (
<button className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors">
Join Group
{t("joinGroup")}
</button>
)}
</div>
</div>

<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-sm font-semibold text-gray-900 mb-3">
Group Info
{t("groupInfo")}
</h3>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-500">Status</dt>
<dd className="font-medium text-gray-900">{group.status}</dd>
<dt className="text-gray-500">{t("status")}</dt>
<dd className="font-medium text-gray-900">
{statusLabels[String(group.status)] ?? String(group.status)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Members</dt>
<dt className="text-gray-500">{t("members")}</dt>
<dd className="font-medium text-gray-900">
{group.members.length}/{group.maxMembers}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Cycle</dt>
<dt className="text-gray-500">{t("cycle")}</dt>
<dd className="font-medium text-gray-900">
{group.cycleLength / 86400} days
{t("days", { days: group.cycleLength / 86400 })}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Pot Size</dt>
<dt className="text-gray-500">{t("potSize")}</dt>
<dd className="font-medium text-gray-900">
{formatAmount(
group.contributionAmount * BigInt(group.members.length)
)}{" "}
tokens
{t("tokens", {
amount: formatAmount(
group.contributionAmount * BigInt(group.members.length),
),
})}
</dd>
</div>
</dl>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="text-2xl font-bold text-gray-900 mb-8">
Create a Savings Group
</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t("title")}</h1>
<CreateGroupForm />
</main>
</>
Expand Down
8 changes: 4 additions & 4 deletions src/app/groups/page.tsx → src/app/[locale]/groups/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand All @@ -47,13 +49,11 @@ export default function GroupsPage() {
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">Savings Groups</h1>
<h1 className="text-2xl font-bold text-gray-900">{t("title")}</h1>
</div>

{groups.length === 0 ? (
<div className="text-center py-12 text-gray-500">
No groups found. Create the first one!
</div>
<div className="text-center py-12 text-gray-500">{t("empty")}</div>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{groups.map((group) => (
Expand Down
55 changes: 55 additions & 0 deletions src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<html lang={params.locale}>
<body className="min-h-screen bg-gray-50">
<NextIntlClientProvider locale={params.locale} messages={messages}>
<Providers>{children}</Providers>
</NextIntlClientProvider>
</body>
</html>
);
}
Loading