diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 00000000..0e1ad756 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,23 @@ +import { NextIntlClientProvider } from "next-intl"; +import { getMessages } from "next-intl/server"; +import { notFound } from "next/navigation"; + +const locales = ["en", "es"]; + +export default async function LocaleLayout({ + children, + params: { locale }, +}: { + children: React.ReactNode; + params: { locale: string }; +}) { + if (!locales.includes(locale)) notFound(); + + const messages = await getMessages(); + + return ( + + {children} + + ); +} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx new file mode 100644 index 00000000..7628e184 --- /dev/null +++ b/app/[locale]/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { PayEasyHero } from "@/components/ui/payeasy-hero"; +import { PayEasyLogo } from "@/components/ui/payeasy-logo"; +import Features from "@/components/landing/Features"; +import HowItWorks from "@/components/landing/HowItWorks"; +import Stellar from "@/components/landing/Stellar"; +import CTA from "@/components/landing/CTA"; +import Footer from "@/components/landing/Footer"; + +export default function LocalePage() { + const t = useTranslations(); + const router = useRouter(); + + return ( +
+ } + navigation={[ + { label: t("nav.features"), href: "#features" }, + { label: t("nav.howItWorks"), href: "#how-it-works" }, + { label: t("nav.stellar"), href: "#stellar" }, + ]} + ctaButton={{ + label: t("nav.connectWallet"), + onClick: () => router.push("/connect"), + }} + title={t("hero.title")} + subtitle={t("hero.subtitle")} + primaryAction={{ + label: t("hero.getStarted"), + onClick: () => router.push("/connect"), + }} + secondaryAction={{ + label: t("hero.viewOnGithub"), + onClick: () => + window.open("https://github.com/Ogstevyn/payeasy", "_blank"), + }} + disclaimer={t("hero.disclaimer")} + socialProof={{ + avatars: [ + "https://i.pravatar.cc/150?img=11", + "https://i.pravatar.cc/150?img=12", + "https://i.pravatar.cc/150?img=13", + "https://i.pravatar.cc/150?img=14", + ], + text: t("hero.socialProof"), + }} + stats={[ + { value: "~$0.00001", label: t("hero.stats.transactionFee") }, + { value: "~5 sec", label: t("hero.stats.settlementTime") }, + { value: "100%", label: t("hero.stats.transparency") }, + ]} + programs={[ + { + image: + "https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=400&h=500&fit=crop", + category: t("hero.programs.findRoommates"), + title: t("hero.programs.findRoommatesDesc"), + }, + { + image: + "https://images.unsplash.com/photo-1554224155-6726b3ff858f?w=400&h=500&fit=crop", + category: t("hero.programs.smartEscrow"), + title: t("hero.programs.smartEscrowDesc"), + }, + { + image: + "https://images.unsplash.com/photo-1551836022-d5d88e9218df?w=400&h=500&fit=crop", + category: t("hero.programs.instantPayments"), + title: t("hero.programs.instantPaymentsDesc"), + }, + { + image: + "https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=500&fit=crop", + category: t("hero.programs.moveIn"), + title: t("hero.programs.moveInDesc"), + }, + { + image: + "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=400&h=500&fit=crop", + category: t("hero.programs.community"), + title: t("hero.programs.communityDesc"), + }, + ]} + /> + +
+ +
+
+ +
+
+ +
+
+ +
+ ); +} diff --git a/components/landing/Navbar.tsx b/components/landing/Navbar.tsx index f2fd15f4..3826c586 100644 --- a/components/landing/Navbar.tsx +++ b/components/landing/Navbar.tsx @@ -5,6 +5,7 @@ import { Menu, X, LogIn, UserPlus, LogOut, User } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import ConnectWalletButton from "@/components/wallet/ConnectWalletButton"; +import { LanguageSelector } from "@/components/ui/language-selector"; import { useActiveSection } from "@/hooks/useActiveSection"; import { ThemeToggle } from "@/components/ui/theme-toggle"; import { useEmailAuth } from "@/context/EmailAuthContext"; @@ -72,6 +73,18 @@ export default function Navbar() { {/* CTA Buttons */}
+ + + Sign In + + router.prefetch("/connect")} + > + + Connect Wallet + {user ? ( <>
@@ -137,6 +150,21 @@ export default function Navbar() { ))}
+
+ +
+ + Sign In + + router.prefetch("/connect")} + > + + Connect Wallet + +
{user ? ( <>
diff --git a/components/ui/glossary-term.tsx b/components/ui/glossary-term.tsx new file mode 100644 index 00000000..505feb00 --- /dev/null +++ b/components/ui/glossary-term.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useRef, useState } from "react"; +import { usePathname } from "next/navigation"; +import { getDefinition, type GlossaryLocale } from "@/lib/glossary"; + +interface GlossaryTermProps { + term: string; + children: React.ReactNode; + locale?: GlossaryLocale; +} + +export function GlossaryTerm({ term, children, locale }: GlossaryTermProps) { + const pathname = usePathname(); + const [isVisible, setIsVisible] = useState(false); + const timerRef = useRef | null>(null); + + const detectedLocale: GlossaryLocale = + locale ?? (pathname.startsWith("/es") ? "es" : "en"); + const definition = getDefinition(term, detectedLocale); + + if (!definition) return <>{children}; + + const show = () => { + if (timerRef.current) clearTimeout(timerRef.current); + setIsVisible(true); + }; + + const hide = () => { + timerRef.current = setTimeout(() => setIsVisible(false), 100); + }; + + const tooltipId = `glossary-${term.replace(/\s+/g, "-")}`; + + return ( + + + {children} + + {isVisible && ( + + {term} + {definition} + + )} + + ); +} diff --git a/components/ui/language-selector.tsx b/components/ui/language-selector.tsx new file mode 100644 index 00000000..83bcd34a --- /dev/null +++ b/components/ui/language-selector.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; + +const languages = [ + { code: "en", name: "English", flag: "🇬🇧" }, + { code: "es", name: "Español", flag: "🇪🇸" }, +] as const; + +type LanguageCode = (typeof languages)[number]["code"]; + +export function LanguageSelector() { + const [isOpen, setIsOpen] = useState(false); + const pathname = usePathname(); + const router = useRouter(); + const ref = useRef(null); + + const currentLocale: LanguageCode = pathname.startsWith("/es") ? "es" : "en"; + const current = languages.find((l) => l.code === currentLocale) ?? languages[0]; + + useEffect(() => { + const saved = localStorage.getItem("preferred-locale") as LanguageCode | null; + if (saved && saved !== currentLocale && pathname === "/") { + router.push(saved === "es" ? "/es" : "/"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (ref.current && !ref.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleSelect = (code: LanguageCode) => { + localStorage.setItem("preferred-locale", code); + router.push(code === "es" ? "/es" : "/"); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( +
    + {languages.map((lang) => ( +
  • handleSelect(lang.code)} + className="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer text-dark-400 hover:text-white hover:bg-white/5 transition-colors" + > + + {lang.name} + {lang.code === currentLocale && ( + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/i18n.ts b/i18n.ts new file mode 100644 index 00000000..6a9e30c4 --- /dev/null +++ b/i18n.ts @@ -0,0 +1,13 @@ +import { getRequestConfig } from "next-intl/server"; +import { notFound } from "next/navigation"; + +export const locales = ["en", "es"] as const; +export type Locale = (typeof locales)[number]; + +export default getRequestConfig(async ({ locale }) => { + if (!locales.includes(locale as Locale)) notFound(); + + return { + messages: (await import(`./messages/${locale}.json`)).default, + }; +}); diff --git a/lib/glossary.ts b/lib/glossary.ts new file mode 100644 index 00000000..f6fcfa59 --- /dev/null +++ b/lib/glossary.ts @@ -0,0 +1,46 @@ +export type GlossaryLocale = "en" | "es"; + +export interface GlossaryEntry { + en: string; + es: string; +} + +export const glossary: Record = { + escrow: { + en: "A financial arrangement where a neutral third party holds funds until agreed conditions are met.", + es: "Un acuerdo financiero donde un tercero neutral retiene fondos hasta que se cumplan las condiciones acordadas.", + }, + XLM: { + en: "Stellar Lumens — the native cryptocurrency of the Stellar network used to pay transaction fees.", + es: "Stellar Lumens: la criptomoneda nativa de la red Stellar utilizada para pagar las comisiones de transacción.", + }, + Soroban: { + en: "Stellar's smart contract platform enabling decentralised applications with low fees and fast execution.", + es: "La plataforma de contratos inteligentes de Stellar que permite aplicaciones descentralizadas con comisiones bajas y ejecución rápida.", + }, + Freighter: { + en: "A browser wallet extension for the Stellar network used to sign and submit transactions.", + es: "Una extensión de billetera para el navegador de la red Stellar utilizada para firmar y enviar transacciones.", + }, + "gas fee": { + en: "A small fee paid to process a transaction on a blockchain network.", + es: "Una pequeña comisión que se paga para procesar una transacción en una red blockchain.", + }, + "smart contract": { + en: "Self-executing code stored on a blockchain that automatically enforces the terms of an agreement.", + es: "Código autoejecutado almacenado en una blockchain que hace cumplir automáticamente los términos de un acuerdo.", + }, + testnet: { + en: "A test version of the blockchain where developers experiment without using real funds.", + es: "Una versión de prueba de la blockchain donde los desarrolladores experimentan sin usar fondos reales.", + }, +}; + +export function getDefinition( + term: string, + locale: GlossaryLocale = "en" +): string | null { + const entry = glossary[term]; + if (!entry) return null; + return entry[locale] ?? entry.en; +} diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 00000000..5969f8a6 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,66 @@ +{ + "nav": { + "features": "Features", + "howItWorks": "How It Works", + "stellar": "Stellar", + "signIn": "Sign In", + "connectWallet": "Connect Wallet" + }, + "hero": { + "title": "Split Rent. Trust the Chain.", + "subtitle": "Find roommates, split rent, and pay securely through Stellar blockchain escrow. Transparent, trustless, and effortless.", + "getStarted": "Get Started", + "viewOnGithub": "View on GitHub", + "disclaimer": "Powered by Stellar Blockchain", + "socialProof": "Trusted by roommates worldwide", + "stats": { + "transactionFee": "Transaction Fee", + "settlementTime": "Settlement Time", + "transparency": "On-Chain Transparency" + }, + "programs": { + "findRoommates": "FIND ROOMMATES", + "findRoommatesDesc": "Browse verified listings & profiles", + "smartEscrow": "SMART ESCROW", + "smartEscrowDesc": "Trustless rent collection on-chain", + "instantPayments": "INSTANT PAYMENTS", + "instantPaymentsDesc": "Settle in seconds, not days", + "moveIn": "MOVE IN", + "moveInDesc": "Secure your new home with confidence", + "community": "COMMUNITY", + "communityDesc": "Connect with trusted roommates" + } + }, + "errors": { + "walletNotConnected": "Wallet not connected", + "insufficientBalance": "Insufficient balance", + "transactionFailed": "Transaction failed", + "networkError": "Network error. Please try again.", + "invalidAddress": "Invalid wallet address" + }, + "forms": { + "amount": "Amount", + "recipient": "Recipient", + "memo": "Memo", + "submit": "Submit", + "cancel": "Cancel", + "confirm": "Confirm" + }, + "escrow": { + "create": "Create Escrow", + "deposit": "Deposit", + "release": "Release", + "refund": "Refund", + "status": { + "pending": "Pending", + "active": "Active", + "completed": "Completed", + "disputed": "Disputed" + } + }, + "language": { + "en": "English", + "es": "Español", + "select": "Select language" + } +} diff --git a/messages/es.json b/messages/es.json new file mode 100644 index 00000000..74c059a7 --- /dev/null +++ b/messages/es.json @@ -0,0 +1,66 @@ +{ + "nav": { + "features": "Funcionalidades", + "howItWorks": "Cómo Funciona", + "stellar": "Stellar", + "signIn": "Iniciar sesión", + "connectWallet": "Conectar billetera" + }, + "hero": { + "title": "Divida el alquiler. Confíe en la cadena.", + "subtitle": "Encuentre compañeros de cuarto, divida el alquiler y pague de forma segura a través del depósito en garantía de Stellar. Transparente, sin intermediarios y sin esfuerzo.", + "getStarted": "Comenzar", + "viewOnGithub": "Ver en GitHub", + "disclaimer": "Impulsado por Stellar Blockchain", + "socialProof": "Con la confianza de compañeros de cuarto en todo el mundo", + "stats": { + "transactionFee": "Comisión por transacción", + "settlementTime": "Tiempo de liquidación", + "transparency": "Transparencia en cadena" + }, + "programs": { + "findRoommates": "ENCONTRAR COMPAÑEROS", + "findRoommatesDesc": "Explore perfiles y anuncios verificados", + "smartEscrow": "DEPÓSITO INTELIGENTE", + "smartEscrowDesc": "Cobro de alquiler en cadena sin intermediarios", + "instantPayments": "PAGOS INSTANTÁNEOS", + "instantPaymentsDesc": "Liquide en segundos, no en días", + "moveIn": "MUDARSE", + "moveInDesc": "Asegure su nuevo hogar con confianza", + "community": "COMUNIDAD", + "communityDesc": "Conéctese con compañeros de cuarto de confianza" + } + }, + "errors": { + "walletNotConnected": "Billetera no conectada", + "insufficientBalance": "Saldo insuficiente", + "transactionFailed": "Transacción fallida", + "networkError": "Error de red. Por favor, inténtelo de nuevo.", + "invalidAddress": "Dirección de billetera no válida" + }, + "forms": { + "amount": "Monto", + "recipient": "Destinatario", + "memo": "Nota", + "submit": "Enviar", + "cancel": "Cancelar", + "confirm": "Confirmar" + }, + "escrow": { + "create": "Crear depósito en garantía", + "deposit": "Depositar", + "release": "Liberar", + "refund": "Reembolsar", + "status": { + "pending": "Pendiente", + "active": "Activo", + "completed": "Completado", + "disputed": "En disputa" + } + }, + "language": { + "en": "English", + "es": "Español", + "select": "Seleccionar idioma" + } +} diff --git a/middleware.ts b/middleware.ts index 7ade34ad..588e38c6 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,3 +1,14 @@ +import createMiddleware from "next-intl/middleware"; + +export default createMiddleware({ + locales: ["en", "es"], + defaultLocale: "en", + localeDetection: true, + localePrefix: "as-needed", +}); + +export const config = { + matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"], import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; diff --git a/next.config.js b/next.config.js index dc6c445b..4edcaede 100644 --- a/next.config.js +++ b/next.config.js @@ -8,6 +8,8 @@ try { // Keep builds working until the dependency is installed locally. } +const createNextIntlPlugin = require("next-intl/plugin"); +const withNextIntl = createNextIntlPlugin("./i18n.ts"); let withSentryConfig = (config) => config; try { @@ -85,4 +87,5 @@ const nextConfig = { }, }; +module.exports = withBundleAnalyzer(withNextIntl(nextConfig)); module.exports = withSentryConfig(withBundleAnalyzer(nextConfig)); diff --git a/package.json b/package.json index a3e64054..e2c51b7e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@sentry/nextjs": "^10.55.0", "@stellar/freighter-api": "^6.0.1", + "next-intl": "^3.15.0", "@stellar/stellar-sdk": "^14.6.1", "@tanstack/react-query": "^5.100.14", "@tanstack/react-virtual": "^3.13.26",