From f7553df05df696b07eda4ee4ff4d5c4c66412ca6 Mon Sep 17 00:00:00 2001 From: counterweight Date: Thu, 25 Dec 2025 21:50:34 +0100 Subject: [PATCH] Phase 1: Infrastructure setup - Install next-intl and create basic i18n structure - Install next-intl package - Create LanguageProvider hook with localStorage persistence - Create IntlProvider component for next-intl integration - Create Providers wrapper component - Update layout.tsx to include providers and set default lang to 'es' - Create initial translation files (common.json) for es, en, ca - Fix pre-existing TypeScript errors in various pages All tests passing, build successful. --- frontend/app/admin/price-history/page.tsx | 6 +- frontend/app/admin/trades/page.tsx | 5 +- frontend/app/components/IntlProvider.tsx | 34 + frontend/app/components/Providers.tsx | 17 + frontend/app/hooks/useLanguage.tsx | 54 ++ frontend/app/hooks/useTranslation.ts | 8 + frontend/app/invites/page.tsx | 9 +- frontend/app/layout.tsx | 7 +- frontend/app/trades/[id]/page.tsx | 12 +- frontend/app/trades/page.tsx | 13 +- frontend/locales/ca/common.json | 15 + frontend/locales/en/common.json | 15 + frontend/locales/es/common.json | 15 + frontend/package-lock.json | 751 +++++++++++++++++++++- frontend/package.json | 1 + 15 files changed, 940 insertions(+), 22 deletions(-) create mode 100644 frontend/app/components/IntlProvider.tsx create mode 100644 frontend/app/components/Providers.tsx create mode 100644 frontend/app/hooks/useLanguage.tsx create mode 100644 frontend/app/hooks/useTranslation.ts create mode 100644 frontend/locales/ca/common.json create mode 100644 frontend/locales/en/common.json create mode 100644 frontend/locales/es/common.json diff --git a/frontend/app/admin/price-history/page.tsx b/frontend/app/admin/price-history/page.tsx index dc7309c..d3fcdd3 100644 --- a/frontend/app/admin/price-history/page.tsx +++ b/frontend/app/admin/price-history/page.tsx @@ -63,7 +63,7 @@ export default function AdminPriceHistoryPage() {

Bitcoin Price History

- {records.length} records + {records?.length ?? 0} records @@ -98,7 +98,7 @@ export default function AdminPriceHistoryPage() { )} - {!error && !isLoadingData && records.length === 0 && ( + {!error && !isLoadingData && (records?.length ?? 0) === 0 && ( No price records yet. Click "Fetch Now" to get the current price. @@ -107,7 +107,7 @@ export default function AdminPriceHistoryPage() { )} {!error && !isLoadingData && - records.map((record) => ( + (records ?? []).map((record) => ( {record.source} {record.pair} diff --git a/frontend/app/admin/trades/page.tsx b/frontend/app/admin/trades/page.tsx index bfc19b7..e735a84 100644 --- a/frontend/app/admin/trades/page.tsx +++ b/frontend/app/admin/trades/page.tsx @@ -5,6 +5,7 @@ import { Permission } from "../../auth-context"; import { adminApi } from "../../api"; import { Header } from "../../components/Header"; import { SatsDisplay } from "../../components/SatsDisplay"; +import { StatusBadge } from "../../components/StatusBadge"; import { EmptyState } from "../../components/EmptyState"; import { ConfirmationButton } from "../../components/ConfirmationButton"; import { useRequireAuth } from "../../hooks/useRequireAuth"; @@ -275,7 +276,9 @@ export default function AdminTradesPage() {
- + + {""} +
{/* Actions */} diff --git a/frontend/app/components/IntlProvider.tsx b/frontend/app/components/IntlProvider.tsx new file mode 100644 index 0000000..e573941 --- /dev/null +++ b/frontend/app/components/IntlProvider.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { NextIntlClientProvider } from "next-intl"; +import { ReactNode, useMemo } from "react"; +import { useLanguage } from "../hooks/useLanguage"; + +// Import all locale messages +import esCommon from "../../locales/es/common.json"; +import enCommon from "../../locales/en/common.json"; +import caCommon from "../../locales/ca/common.json"; + +const messages = { + es: { common: esCommon }, + en: { common: enCommon }, + ca: { common: caCommon }, +}; + +interface IntlProviderProps { + children: ReactNode; +} + +export function IntlProvider({ children }: IntlProviderProps) { + const { locale } = useLanguage(); + + const localeMessages = useMemo(() => { + return messages[locale] || messages.es; + }, [locale]); + + return ( + + {children} + + ); +} diff --git a/frontend/app/components/Providers.tsx b/frontend/app/components/Providers.tsx new file mode 100644 index 0000000..4b37b2f --- /dev/null +++ b/frontend/app/components/Providers.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { ReactNode } from "react"; +import { LanguageProvider } from "../hooks/useLanguage"; +import { IntlProvider } from "./IntlProvider"; + +interface ProvidersProps { + children: ReactNode; +} + +export function Providers({ children }: ProvidersProps) { + return ( + + {children} + + ); +} diff --git a/frontend/app/hooks/useLanguage.tsx b/frontend/app/hooks/useLanguage.tsx new file mode 100644 index 0000000..bc312d5 --- /dev/null +++ b/frontend/app/hooks/useLanguage.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState, useEffect, createContext, useContext, ReactNode } from "react"; + +export type Locale = "es" | "en" | "ca"; + +const LOCALE_STORAGE_KEY = "arbret-locale"; +const DEFAULT_LOCALE: Locale = "es"; + +interface LanguageContextType { + locale: Locale; + setLocale: (locale: Locale) => void; +} + +const LanguageContext = createContext(undefined); + +export function LanguageProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(DEFAULT_LOCALE); + const [isHydrated, setIsHydrated] = useState(false); + + // Load locale from localStorage on mount + useEffect(() => { + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); + if (stored && (stored === "es" || stored === "en" || stored === "ca")) { + setLocaleState(stored as Locale); + } + setIsHydrated(true); + }, []); + + // Update HTML lang attribute when locale changes + useEffect(() => { + if (isHydrated) { + document.documentElement.lang = locale; + } + }, [locale, isHydrated]); + + const setLocale = (newLocale: Locale) => { + setLocaleState(newLocale); + localStorage.setItem(LOCALE_STORAGE_KEY, newLocale); + document.documentElement.lang = newLocale; + }; + + return ( + {children} + ); +} + +export function useLanguage() { + const context = useContext(LanguageContext); + if (context === undefined) { + throw new Error("useLanguage must be used within a LanguageProvider"); + } + return context; +} diff --git a/frontend/app/hooks/useTranslation.ts b/frontend/app/hooks/useTranslation.ts new file mode 100644 index 0000000..114f4e5 --- /dev/null +++ b/frontend/app/hooks/useTranslation.ts @@ -0,0 +1,8 @@ +"use client"; + +import { useTranslations as useNextIntlTranslations } from "next-intl"; + +export function useTranslation(namespace?: string) { + const t = useNextIntlTranslations(namespace); + return t; +} diff --git a/frontend/app/invites/page.tsx b/frontend/app/invites/page.tsx index 952fbc3..11d8d17 100644 --- a/frontend/app/invites/page.tsx +++ b/frontend/app/invites/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { invitesApi } from "../api"; import { PageLayout } from "../components/PageLayout"; import { StatusBadge } from "../components/StatusBadge"; +import { EmptyState } from "../components/EmptyState"; import { useRequireAuth } from "../hooks/useRequireAuth"; import { useAsyncData } from "../hooks/useAsyncData"; import { components } from "../generated/api"; @@ -49,9 +50,9 @@ export default function InvitesPage() { }; const { READY, SPENT, REVOKED } = constants.inviteStatuses; - const readyInvites = invites.filter((i) => i.status === READY); - const spentInvites = invites.filter((i) => i.status === SPENT); - const revokedInvites = invites.filter((i) => i.status === REVOKED); + const readyInvites = (invites ?? []).filter((i) => i.status === READY); + const spentInvites = (invites ?? []).filter((i) => i.status === SPENT); + const revokedInvites = (invites ?? []).filter((i) => i.status === REVOKED); return ( - {invites.length === 0 ? ( + {(invites?.length ?? 0) === 0 ? ( + @@ -37,7 +38,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) `} - {children} + + {children} + ); diff --git a/frontend/app/trades/[id]/page.tsx b/frontend/app/trades/[id]/page.tsx index bd6cef7..da875f0 100644 --- a/frontend/app/trades/[id]/page.tsx +++ b/frontend/app/trades/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { CSSProperties } from "react"; +import { CSSProperties, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { Permission } from "../../auth-context"; import { tradesApi } from "../../api"; @@ -23,6 +23,7 @@ export default function TradeDetailPage() { const router = useRouter(); const params = useParams(); const publicId = params?.id as string | undefined; + const [cancelError, setCancelError] = useState(null); const { user, isLoading, isAuthorized } = useRequireAuth({ requiredPermission: Permission.VIEW_OWN_EXCHANGES, @@ -78,6 +79,10 @@ export default function TradeDetailPage() { ); } + if (!trade) { + return null; + } + const isBuy = trade.direction === "buy"; return ( @@ -97,7 +102,7 @@ export default function TradeDetailPage() {
Status: - + {""}
Time: @@ -194,6 +199,7 @@ export default function TradeDetailPage() {
+ {cancelError &&
{cancelError}
} {trade.status === "booked" && (
- + + {""} +
@@ -224,7 +227,7 @@ export default function TradesPage() {
- + {""}