Initial commit
This commit is contained in:
75
app/api/rates/route.ts
Normal file
75
app/api/rates/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchUnifiedRates } from "@/lib/api/normalize";
|
||||
import type { RatesResponse } from "@/lib/rates";
|
||||
|
||||
const CACHE_TTL_MS = 300_000;
|
||||
const CACHE_CONTROL_VALUE = "s-maxage=300, stale-while-revalidate=1800";
|
||||
|
||||
let cachedRates: RatesResponse | null = null;
|
||||
let cacheTimestamp = 0;
|
||||
let inFlightRequest: Promise<RatesResponse> | null = null;
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
async function getRatesWithCache(): Promise<RatesResponse> {
|
||||
const now = Date.now();
|
||||
const hasFreshCache = cachedRates && now - cacheTimestamp < CACHE_TTL_MS;
|
||||
|
||||
if (hasFreshCache && cachedRates) {
|
||||
return cachedRates;
|
||||
}
|
||||
|
||||
if (inFlightRequest) {
|
||||
return inFlightRequest;
|
||||
}
|
||||
|
||||
inFlightRequest = (async () => {
|
||||
const freshRates = await fetchUnifiedRates();
|
||||
cachedRates = freshRates;
|
||||
cacheTimestamp = Date.now();
|
||||
return freshRates;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await inFlightRequest;
|
||||
} finally {
|
||||
inFlightRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await getRatesWithCache();
|
||||
|
||||
return NextResponse.json(data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": CACHE_CONTROL_VALUE
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (cachedRates) {
|
||||
return NextResponse.json(cachedRates, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": CACHE_CONTROL_VALUE,
|
||||
"X-Cache-Fallback": "stale-on-error"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unexpected error while loading rates";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message
|
||||
},
|
||||
{
|
||||
status: 500
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
78
app/globals.css
Normal file
78
app/globals.css
Normal file
@@ -0,0 +1,78 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: 222 47% 8%;
|
||||
--foreground: 213 31% 96%;
|
||||
--card: 220 34% 11%;
|
||||
--card-foreground: 210 38% 96%;
|
||||
--popover: 221 36% 12%;
|
||||
--popover-foreground: 210 38% 97%;
|
||||
--primary: 193 92% 58%;
|
||||
--primary-foreground: 221 39% 10%;
|
||||
--secondary: 217 20% 18%;
|
||||
--secondary-foreground: 210 37% 95%;
|
||||
--muted: 218 18% 18%;
|
||||
--muted-foreground: 217 17% 72%;
|
||||
--accent: 202 88% 16%;
|
||||
--accent-foreground: 198 100% 90%;
|
||||
--border: 217 24% 24%;
|
||||
--input: 217 24% 24%;
|
||||
--ring: 192 89% 60%;
|
||||
--radius: 0.9rem;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: "Manrope", "Avenir Next", "Segoe UI", sans-serif;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
isolation: isolate;
|
||||
overflow-x: hidden;
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: -15vh -15vw;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(
|
||||
68% 58% at 50% -10%,
|
||||
hsl(193 95% 58% / 0.24) 0%,
|
||||
hsl(193 95% 58% / 0.13) 38%,
|
||||
transparent 76%
|
||||
),
|
||||
radial-gradient(52% 64% at 102% 42%, hsl(162 90% 50% / 0.11) 0%, transparent 74%),
|
||||
radial-gradient(52% 64% at -2% 78%, hsl(210 92% 56% / 0.1) 0%, transparent 74%),
|
||||
linear-gradient(180deg, hsl(222 45% 7%) 0%, hsl(223 48% 6%) 100%);
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.font-heading {
|
||||
font-family: "Space Grotesk", "Manrope", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-heading;
|
||||
}
|
||||
}
|
||||
24
app/layout.tsx
Normal file
24
app/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import "./globals.css";
|
||||
import "currency-flags/dist/currency-flags.min.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "NexCurrency | Modern Currency & Crypto Converter",
|
||||
description:
|
||||
"Convert fiat and crypto assets instantly with live rates, smart formatting, and a premium modern interface."
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
65
app/page.tsx
Normal file
65
app/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { ConverterCard } from "@/components/converter/converter-card";
|
||||
import { Hero } from "@/components/sections/hero";
|
||||
import { InsightsSection } from "@/components/sections/insights-section";
|
||||
|
||||
const DEFAULT_FROM = "USD";
|
||||
const DEFAULT_TO = "EUR";
|
||||
|
||||
export default function HomePage() {
|
||||
const [selectedFromCode, setSelectedFromCode] = useState(DEFAULT_FROM);
|
||||
const [selectedToCode, setSelectedToCode] = useState(DEFAULT_TO);
|
||||
|
||||
const handleSelectPopularPair = useCallback(
|
||||
(fromCode: string, toCode: string) => {
|
||||
setSelectedFromCode(fromCode);
|
||||
setSelectedToCode(toCode);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePairChange = useCallback((fromCode: string, toCode: string) => {
|
||||
setSelectedFromCode(fromCode);
|
||||
setSelectedToCode(toCode);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="relative isolate min-h-[100svh] overflow-hidden pb-20">
|
||||
<div className="container relative z-10">
|
||||
<Hero />
|
||||
<section className="mx-auto mt-10 max-w-5xl animate-in fade-in-0 slide-in-from-bottom-2 duration-700">
|
||||
<ConverterCard
|
||||
forcedFromCode={selectedFromCode}
|
||||
forcedToCode={selectedToCode}
|
||||
onPairChange={handlePairChange}
|
||||
/>
|
||||
<InsightsSection
|
||||
selectedFromCode={selectedFromCode}
|
||||
selectedToCode={selectedToCode}
|
||||
onSelectPopularPair={handleSelectPopularPair}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="container mt-12 flex flex-col items-center gap-2 text-center text-xs text-muted-foreground">
|
||||
<p>
|
||||
Market data is provided by Frankfurter and CoinGecko. Rates are
|
||||
refreshed automatically.
|
||||
</p>
|
||||
{/*
|
||||
<a
|
||||
href="https://github.com/zvspany/nextcurrency"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full border border-border/70 bg-background/50 px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:border-cyan-400/40 hover:bg-cyan-400/10 hover:text-cyan-100"
|
||||
>
|
||||
Repository
|
||||
</a>
|
||||
*/}
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user