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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,24 @@ jobs:
go-version-file: apps/api/go.mod
cache: true

- name: Check migration collisions
run: |
COLLISIONS=$(ls migrations/*.sql \
| sed -E 's/\.(up|down)\.sql$//' \
| sort -u \
| sed -E 's/.*\/([0-9]+)_.*/\1/' \
| sort | uniq -d)
if [ -n "$COLLISIONS" ]; then
echo "FAIL: duplicate migration prefixes found:"
for prefix in $COLLISIONS; do
echo "Prefix $prefix:"
ls migrations/${prefix}_*.sql
done
exit 1
else
echo "OK: No migration prefix collisions."
fi

- name: Build
run: go build ./...

Expand Down
Binary file modified apps/website/public/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/website/src/app/docs/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return {
title: "Documentation",
description: "Technical documentation for the Nester protocol.",
alternates: {
canonical: "/docs",
},
};
}

Expand Down
11 changes: 2 additions & 9 deletions apps/website/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Space_Grotesk, Inter, Cormorant } from "next/font/google";
import { CookieConsent } from "@/components/cookie-consent";
import { Analytics } from "@/components/analytics";
import { SmoothScroll } from "@/components/smooth-scroll";
import "./globals.css";

Expand All @@ -20,17 +21,13 @@ export const metadata: Metadata = {
authors: [{ name: "Nester Protocol" }],
creator: "Nester Protocol",
publisher: "Nester Protocol",
alternates: {
canonical: "/",
},
icons: {
icon: "/logo.png",
apple: "/logo.png",
},
openGraph: {
type: "website",
locale: "en_US",
url: "https://nester.finance",
siteName: "Nester",
title: "Nester | Decentralized Savings & Liquidity",
description: "Optimize crypto yield and settle to fiat instantly through a decentralized liquidity network built for emerging markets.",
Expand Down Expand Up @@ -73,11 +70,6 @@ export default function RootLayout({
return (
<html lang="en" className="scroll-smooth">
<head>
<script
defer
data-domain="nester.finance"
src="https://plausible.io/js/script.js"
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
Expand All @@ -90,6 +82,7 @@ export default function RootLayout({
<SmoothScroll>
{children}
<CookieConsent />
<Analytics />
</SmoothScroll>
</body>
</html>
Expand Down
7 changes: 7 additions & 0 deletions apps/website/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Metadata } from "next";
import { Navbar } from "@/components/navbar";
import { Hero } from "@/components/hero";
import { ImageCarousel } from "@/components/image-carousel";
Expand All @@ -10,6 +11,12 @@ import { Footer } from "@/components/footer";
// import { FeaturesFloat } from "@/components/features-float";
// import { Architecture } from "@/components/architecture";

export const metadata: Metadata = {
alternates: {
canonical: "/",
},
};

const jsonLd = {
"@context": "https://schema.org",
"@graph": [
Expand Down
47 changes: 47 additions & 0 deletions apps/website/src/components/analytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client";

import { useEffect, useState } from "react";
import Script from "next/script";

export function Analytics() {
const [hasAnalyticsConsent, setHasAnalyticsConsent] = useState(false);

useEffect(() => {
const checkConsent = () => {
const cookies = document.cookie.split(";").map((c) => c.trim());
const consentCookie = cookies.find((c) => c.startsWith("nester-cookie-consent="));
if (consentCookie) {
try {
const val = decodeURIComponent(consentCookie.split("=")[1]);
const parsed = JSON.parse(val);
if (parsed.analytics) {
setHasAnalyticsConsent(true);
} else {
setHasAnalyticsConsent(false);
}
} catch (e) {

Check warning on line 22 in apps/website/src/components/analytics.tsx

View workflow job for this annotation

GitHub Actions / Website (Next.js)

'e' is defined but never used
// Invalid cookie, ignore
}
} else {
setHasAnalyticsConsent(false);
}
};

// Check on mount
checkConsent();

// Listen for updates from the cookie consent banner
window.addEventListener("cookie-consent-updated", checkConsent);
return () => window.removeEventListener("cookie-consent-updated", checkConsent);
}, []);

if (!hasAnalyticsConsent) return null;

return (
<Script
strategy="afterInteractive"
data-domain="nester.finance"
src="https://plausible.io/js/script.js"
/>
);
}
216 changes: 166 additions & 50 deletions apps/website/src/components/cookie-consent.tsx
Original file line number Diff line number Diff line change
@@ -1,66 +1,182 @@
"use client"
"use client";

import * as React from "react"
import { motion, AnimatePresence } from "framer-motion"
import { Button } from "@/components/ui/button"
import * as React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "@/components/ui/button";

interface CookieState {
necessary: boolean;
analytics: boolean;
}

export function CookieConsent() {
const [isVisible, setIsVisible] = React.useState(false)
const [isVisible, setIsVisible] = React.useState(false);
const [showPreferences, setShowPreferences] = React.useState(false);

const [preferences, setPreferences] = React.useState<CookieState>({
necessary: true,
analytics: false,
});

React.useEffect(() => {
// Check if user has already consented
const consented = localStorage.getItem("nester-cookie-consent")
if (!consented) {
// Show after a small delay
const timer = setTimeout(() => setIsVisible(true), 1000)
return () => clearTimeout(timer)
const getCookie = () => {
const cookies = document.cookie.split(";").map((c) => c.trim());
const consentCookie = cookies.find((c) => c.startsWith("nester-cookie-consent="));
if (consentCookie) {
try {
const val = decodeURIComponent(consentCookie.split("=")[1]);
return JSON.parse(val) as CookieState;
} catch (e) {

Check warning on line 29 in apps/website/src/components/cookie-consent.tsx

View workflow job for this annotation

GitHub Actions / Website (Next.js)

'e' is defined but never used
return null;
}
}
return null;
};

const saved = getCookie();
if (!saved) {
const timer = setTimeout(() => setIsVisible(true), 1000);
return () => clearTimeout(timer);
} else {
setPreferences(saved);
}
}, [])
}, []);

const handleAccept = () => {
localStorage.setItem("nester-cookie-consent", "true")
setIsVisible(false)
}
React.useEffect(() => {
const handleOpen = () => {
setIsVisible(true);
setShowPreferences(true);
};
window.addEventListener("open-cookie-consent", handleOpen);
return () => window.removeEventListener("open-cookie-consent", handleOpen);
}, []);

const saveAndClose = (newState: CookieState) => {
const value = encodeURIComponent(JSON.stringify(newState));
// 1-year expiry
document.cookie = `nester-cookie-consent=${value}; max-age=31536000; path=/`;
setPreferences(newState);
setIsVisible(false);
setShowPreferences(false);
window.dispatchEvent(new Event("cookie-consent-updated"));
};

const handleDecline = () => {
localStorage.setItem("nester-cookie-consent", "false")
setIsVisible(false)
}
const handleAcceptAll = () => saveAndClose({ necessary: true, analytics: true });
const handleDenyAll = () => saveAndClose({ necessary: true, analytics: false });
const handleSavePreferences = () => saveAndClose(preferences);

return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.3 }}
className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-6 sm:bottom-6 z-[100] w-auto sm:w-full sm:max-w-[420px] p-5 sm:p-6 bg-white rounded-[20px] sm:rounded-[24px] shadow-2xl border border-border/10"
>
<div className="flex flex-col gap-6">
<p className="text-[15px] text-[#111827] leading-relaxed font-sans">
We use cookies to enhance your user experience, provide personalised content and analyse traffic.{" "}
<a href="#" className="underline decoration-1 underline-offset-2 hover:text-nester-blue transition-colors">Cookie Policy</a>
</p>
<div role="dialog" aria-modal="true" aria-labelledby="cookie-banner-title">
{/* Backdrop for preferences view only */}
{showPreferences && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[90] bg-black/40 backdrop-blur-sm"
onClick={() => setShowPreferences(false)}
/>
)}

<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.3 }}
className={`fixed bottom-4 left-4 right-4 z-[100] bg-white rounded-[20px] shadow-2xl border border-border/10 overflow-hidden ${
showPreferences
? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bottom-auto right-auto w-[90%] max-w-md max-h-[90vh] overflow-y-auto m-0 rounded-2xl"
: "sm:left-auto sm:right-6 sm:bottom-6 sm:w-full sm:max-w-[420px]"
}`}
>
{!showPreferences ? (
<div className="p-5 sm:p-6 flex flex-col gap-6">
<p id="cookie-banner-title" className="text-[15px] text-[#111827] leading-relaxed font-sans">
We use cookies to enhance your user experience, provide personalised content and analyse traffic.{" "}
<a href="#" className="underline decoration-1 underline-offset-2 hover:text-nester-blue transition-colors">Cookie Policy</a>
</p>

<div className="flex items-center gap-3">
<Button
onClick={handleAccept}
className="bg-[#111827] hover:bg-black text-white rounded-full px-6 h-11 font-medium text-sm transition-transform active:scale-95 shadow-none"
>
Accept All
</Button>
<Button
onClick={handleDecline}
variant="outline"
className="bg-white border-[#E5E7EB] hover:bg-gray-50 text-[#111827] rounded-full px-6 h-11 font-medium text-sm transition-transform active:scale-95"
>
Deny All
</Button>
</div>
</div>
</motion.div>
<div className="flex flex-col sm:flex-row items-center gap-3">
<div className="flex gap-2 w-full sm:w-auto">
<Button
onClick={handleAcceptAll}
className="flex-1 sm:flex-none bg-[#111827] hover:bg-black text-white rounded-full px-6 h-11 font-medium text-sm transition-transform active:scale-95 shadow-none"
>
Accept All
</Button>
<Button
onClick={handleDenyAll}
variant="outline"
className="flex-1 sm:flex-none bg-white border-[#E5E7EB] hover:bg-gray-50 text-[#111827] rounded-full px-6 h-11 font-medium text-sm transition-transform active:scale-95"
>
Deny All
</Button>
</div>
<button
onClick={() => setShowPreferences(true)}
className="text-sm font-medium text-muted-foreground hover:text-foreground underline decoration-1 underline-offset-2 transition-colors mt-2 sm:mt-0 ml-auto"
>
Manage
</button>
</div>
</div>
) : (
<div className="flex flex-col h-full">
<div className="p-6 border-b border-border/10">
<h2 id="cookie-banner-title" className="text-xl font-bold font-heading text-foreground mb-2">Cookie Preferences</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
Manage your cookie preferences. Necessary cookies cannot be disabled as they are required for the website to function properly.
</p>
</div>
<div className="p-6 space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-semibold text-foreground">Necessary Cookies</h3>
<p className="text-sm text-muted-foreground mt-1">Required for core functionality like security and network management.</p>
</div>
<div className="relative inline-flex items-center cursor-not-allowed">
<div className="w-11 h-6 bg-emerald-500 rounded-full opacity-60"></div>
<div className="absolute left-[22px] top-1 w-4 h-4 bg-white rounded-full shadow-sm"></div>
</div>
</div>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-semibold text-foreground">Analytics Cookies</h3>
<p className="text-sm text-muted-foreground mt-1">Help us improve the website by understanding how you interact with it.</p>
</div>
<button
type="button"
role="switch"
aria-checked={preferences.analytics}
onClick={() => setPreferences(p => ({ ...p, analytics: !p.analytics }))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${preferences.analytics ? 'bg-emerald-500' : 'bg-input'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-background shadow-sm transition-transform ${preferences.analytics ? 'translate-x-[22px]' : 'translate-x-1'}`} />
</button>
</div>
</div>
<div className="p-6 bg-muted/30 border-t border-border/10 flex flex-col sm:flex-row gap-3">
<Button
onClick={handleSavePreferences}
className="w-full sm:flex-1 bg-[#111827] hover:bg-black text-white rounded-full h-11 font-medium"
>
Save Preferences
</Button>
<Button
onClick={handleAcceptAll}
variant="outline"
className="w-full sm:flex-1 rounded-full h-11 font-medium"
>
Accept All
</Button>
</div>
</div>
)}
</motion.div>
</div>
)}
</AnimatePresence>
)
);
}
7 changes: 7 additions & 0 deletions apps/website/src/components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ export function Footer() {
</div>

<div className="flex items-center gap-4">
<button
onClick={() => window.dispatchEvent(new Event("open-cookie-consent"))}
className="text-[11px] font-mono text-black/20 hover:text-black/50 transition-colors duration-200"
>
Preferences
</button>
<span className="w-px h-3 bg-black/[0.12]" />
<Link
href="#"
className="text-[11px] font-mono text-black/20 hover:text-black/50 transition-colors duration-200"
Expand Down
Loading