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() {
- + {""}