Skip to content
Merged
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
23 changes: 23 additions & 0 deletions app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
);
}
104 changes: 104 additions & 0 deletions app/[locale]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main id="main-content" aria-label="Landing Page">
<PayEasyHero
logo={<PayEasyLogo size={34} />}
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"),
},
]}
/>

<div id="features" />
<Features />
<div className="section-divider" />
<div id="how-it-works" />
<HowItWorks />
<div className="section-divider" />
<div id="stellar" />
<Stellar />
<div className="section-divider" />
<div id="cta" />
<CTA />
<Footer />
</main>
);
}
28 changes: 28 additions & 0 deletions components/landing/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -72,6 +73,18 @@ export default function Navbar() {

{/* CTA Buttons */}
<div className="hidden md:flex items-center gap-3">
<LanguageSelector />
<a href="#" className="btn-secondary !py-2.5 !px-5 !text-sm !rounded-lg">
Sign In
</a>
<a
href="#"
className="btn-primary !py-2.5 !px-5 !text-sm !rounded-lg"
onMouseEnter={() => router.prefetch("/connect")}
>
<Wallet size={16} />
Connect Wallet
</a>
{user ? (
<>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10">
Expand Down Expand Up @@ -137,6 +150,21 @@ export default function Navbar() {
</a>
))}
<div className="h-px bg-white/10 my-2" />
<div className="flex justify-center">
<LanguageSelector />
</div>
<a href="#" className="btn-secondary !justify-center">
Sign In
</a>
<a
href="#"
className="btn-primary !justify-center"
onMouseEnter={() => router.prefetch("/connect")}
>
<Wallet size={16} />
Connect Wallet
</a>
<div className="flex justify-center">
{user ? (
<>
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/5 border border-white/10">
Expand Down
69 changes: 69 additions & 0 deletions components/ui/glossary-term.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout> | 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 (
<span className="relative inline-block">
<span
className="border-b border-dashed border-brand-400 cursor-help"
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
tabIndex={0}
role="button"
aria-describedby={tooltipId}
>
{children}
</span>
{isVisible && (
<span
id={tooltipId}
role="tooltip"
onMouseEnter={show}
onMouseLeave={hide}
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-64 px-3 py-2 rounded-lg glass text-xs text-white z-50 pointer-events-auto"
style={{ whiteSpace: "normal" }}
>
<strong className="block mb-1 text-brand-400">{term}</strong>
{definition}
<span
className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent"
style={{ borderTopColor: "rgba(255,255,255,0.08)" }}
aria-hidden="true"
/>
</span>
)}
</span>
);
}
115 changes: 115 additions & 0 deletions components/ui/language-selector.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1.5 text-dark-400 hover:text-white transition-colors text-sm font-medium"
aria-label="Select language"
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span aria-hidden="true">{current.flag}</span>
<span className="hidden sm:inline">{current.name}</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
aria-hidden="true"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>

{isOpen && (
<ul
role="listbox"
aria-label="Available languages"
className="absolute right-0 top-full mt-2 glass rounded-xl py-1 min-w-[140px] z-50 shadow-lg"
>
{languages.map((lang) => (
<li
key={lang.code}
role="option"
aria-selected={lang.code === currentLocale}
onClick={() => 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"
>
<span aria-hidden="true">{lang.flag}</span>
<span>{lang.name}</span>
{lang.code === currentLocale && (
<svg
className="ml-auto"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
aria-hidden="true"
>
<path
d="M2 6L5 9L10 3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</li>
))}
</ul>
)}
</div>
);
}
13 changes: 13 additions & 0 deletions i18n.ts
Original file line number Diff line number Diff line change
@@ -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,
};
});
Loading
Loading