Skip to content
Open
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web-app/public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web-app/public/icons/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web-app/public/icons/favicon-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web-app/public/icons/favicon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web-app/public/icons/icon-1024.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web-app/public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web-app/public/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web-app/public/icons/maskable-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
98 changes: 98 additions & 0 deletions apps/web-app/public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const CACHE_VERSION = "neko-pwa-v1";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const PAGE_CACHE = `${CACHE_VERSION}-pages`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const OFFLINE_URL = "/offline";
const PRECACHE_URLS = [
OFFLINE_URL,
"/dashboard",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/apple-touch-icon.png",
"/icons/favicon-32.png",
"/Neko.svg",
];

self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});

self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => !key.startsWith(CACHE_VERSION))
.map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});

self.addEventListener("fetch", (event) => {
const { request } = event;

if (request.method !== "GET") {
return;
}

const url = new URL(request.url);

if (url.origin !== self.location.origin) {
return;
}

if (request.mode === "navigate") {
event.respondWith(handleNavigationRequest(request));
return;
}

if (
request.destination === "style" ||
request.destination === "script" ||
request.destination === "worker" ||
request.destination === "font" ||
request.destination === "image"
) {
event.respondWith(staleWhileRevalidate(request, RUNTIME_CACHE));
}
});

async function handleNavigationRequest(request) {
const cache = await caches.open(PAGE_CACHE);

try {
const response = await fetch(request);
cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
if (cached) {
return cached;
}

const shellCache = await caches.open(SHELL_CACHE);
const offlineResponse = await shellCache.match(OFFLINE_URL);
return offlineResponse || Response.error();
}
}

async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);

const networkFetch = fetch(request)
.then((response) => {
cache.put(request, response.clone());
return response;
})
.catch(() => cached);

return cached || networkFetch;
}
9 changes: 6 additions & 3 deletions apps/web-app/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ReactNode } from "react";
import { Sidebar } from "@/components/navigation/Sidebar";
import { MobileHeader } from "@/components/navigation/MobileHeader";
import { WalletAutoConnect } from "@/components/WalletAutoConnect";
import { OfflineStatusBanner } from "@/components/pwa/OfflineStatusBanner";

export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
Expand All @@ -10,11 +11,13 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
<Sidebar />
<MobileHeader />

{}
<main
className="pt-16 lg:pt-0 lg:ml-[270px] flex min-h-screen min-w-0 flex-1 flex-col items-center overflow-x-hidden text-white w-full"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
className="mobile-app-main pt-16 lg:pt-0 lg:ml-[270px] flex min-h-screen min-w-0 flex-1 flex-col items-center overflow-x-hidden text-white w-full"
style={{
paddingBottom: "max(1rem, env(safe-area-inset-bottom))",
}}
>
<OfflineStatusBanner />
{children}
</main>
</div>
Expand Down
26 changes: 26 additions & 0 deletions apps/web-app/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,32 @@ body {
overflow-y: auto;
}

@media (max-width: 1023px) {
.mobile-app-header {
padding-top: 1rem;
}

.mobile-app-menu {
top: 4.25rem;
}

.mobile-app-main {
padding-top: 4rem;
}

body[data-standalone="true"] .mobile-app-header {
padding-top: max(1rem, env(safe-area-inset-top));
}

body[data-standalone="true"] .mobile-app-menu {
top: calc(env(safe-area-inset-top) + 3.5rem);
}

body[data-standalone="true"] .mobile-app-main {
padding-top: max(4rem, calc(env(safe-area-inset-top) + 3rem));
}
}

@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
Expand Down
37 changes: 32 additions & 5 deletions apps/web-app/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,43 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import "@stellar/design-system/build/styles.min.css";
import "./globals.css";
import { Providers } from "./providers";

export const metadata: Metadata = {
title: "Neko Protocol",
description: "All-in-one platform for RWAs on Stellar",
viewport: {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
applicationName: "Neko Protocol",
manifest: "/manifest.webmanifest",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "Neko",
},
formatDetection: {
telephone: false,
},
icons: {
icon: [
{ url: "/icons/favicon-16.png", sizes: "16x16", type: "image/png" },
{ url: "/icons/favicon-32.png", sizes: "32x32", type: "image/png" },
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
],
apple: [
{
url: "/icons/apple-touch-icon.png",
sizes: "180x180",
type: "image/png",
},
],
},
};

export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#121212",
};

export default function RootLayout({
Expand Down
33 changes: 33 additions & 0 deletions apps/web-app/src/app/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
return {
name: "Neko Protocol",
short_name: "Neko",
description: "All-in-one platform for RWAs on Stellar",
start_url: "/dashboard",
scope: "/",
display: "standalone",
background_color: "#121212",
theme_color: "#121212",
orientation: "portrait",
icons: [
{
src: "/icons/icon-192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icons/icon-512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "/icons/maskable-icon.png",
sizes: "1024x1024",
type: "image/png",
purpose: "maskable",
},
],
};
}
24 changes: 24 additions & 0 deletions apps/web-app/src/app/offline/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Link from "next/link";

export default function OfflinePage() {
return (
<main className="flex min-h-screen items-center justify-center bg-[#121212] px-6 text-white">
<div className="w-full max-w-md rounded-[28px] border border-white/10 bg-white/5 p-8 text-center shadow-2xl backdrop-blur">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-[#229EDF]/20 text-2xl font-semibold text-[#7dd3fc]">
N
</div>
<h1 className="text-2xl font-semibold">Sin acceso a internet</h1>
<p className="mt-3 text-sm leading-6 text-white/70">
Neko cargo el ultimo shell disponible. Revisa tu conexion para
continuar con datos en tiempo real y acciones onchain.
</p>
<Link
href="/dashboard"
className="mt-6 inline-flex rounded-full bg-[#229EDF] px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#1b8fcb]"
>
Ir al dashboard
</Link>
</div>
</main>
);
}
51 changes: 48 additions & 3 deletions apps/web-app/src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
QueryClient,
QueryClientProvider,
dehydrate,
hydrate,
} from "@tanstack/react-query";
import { ToastProvider } from "@/providers/ToastProvider";
import { ReactNode, useState } from "react";
import { ReactNode, useEffect, useState } from "react";
import { NetworkStatusProvider } from "@/components/pwa/NetworkStatusProvider";
import { PwaRegistration } from "@/components/pwa/PwaRegistration";

export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
Expand All @@ -18,9 +25,47 @@ export function Providers({ children }: { children: ReactNode }) {
})
);

useEffect(() => {
if (typeof window === "undefined") return;

const snapshot = window.localStorage.getItem("neko-react-query-cache");
if (snapshot) {
try {
hydrate(queryClient, JSON.parse(snapshot));
} catch {
window.localStorage.removeItem("neko-react-query-cache");
}
}

const unsubscribe = queryClient.getQueryCache().subscribe(() => {
try {
const dehydratedState = dehydrate(queryClient, {
shouldDehydrateQuery: (query) =>
query.state.status === "success" &&
Array.isArray(query.queryKey) &&
!query.queryKey.some(
(part) => typeof part === "string" && part.startsWith("mutation:")
),
});

window.localStorage.setItem(
"neko-react-query-cache",
JSON.stringify(dehydratedState)
);
} catch {
// Ignore storage quota or serialization errors.
}
});

return unsubscribe;
}, [queryClient]);

return (
<QueryClientProvider client={queryClient}>
<ToastProvider>{children}</ToastProvider>
<NetworkStatusProvider>
<PwaRegistration />
<ToastProvider>{children}</ToastProvider>
</NetworkStatusProvider>
</QueryClientProvider>
);
}
13 changes: 9 additions & 4 deletions apps/web-app/src/components/navigation/MobileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const IS_TESTNET = NETWORK === "TESTNET";
const ADMIN_ADDRESS = process.env.NEXT_PUBLIC_LENDING_ADMIN_ADDRESS ?? "";

export function MobileHeader() {
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [walletDropdownOpen, setWalletDropdownOpen] = useState(false);
const pathname = usePathname();
Expand All @@ -26,8 +27,12 @@ export function MobileHeader() {
const menuRef = useRef<HTMLDivElement>(null);
const walletButtonRef = useRef<HTMLDivElement>(null);

const isConnected = isStellarConnected;
const activeAddress = stellarAddress ?? "";
useEffect(() => {
setMounted(true);
}, []);

const isConnected = mounted ? isStellarConnected : false;
const activeAddress = mounted ? (stellarAddress ?? "") : "";

const navItems = useMemo(() => {
return NAV_ITEMS.filter((item) => {
Expand Down Expand Up @@ -75,7 +80,7 @@ export function MobileHeader() {

return (
<>
<header className="lg:hidden fixed top-0 left-0 right-0 z-50 flex flex-col border-b border-white/5 bg-[#121212] pt-6">
<header className="mobile-app-header lg:hidden fixed top-0 left-0 right-0 z-50 flex flex-col border-b border-white/5 bg-[#121212] pt-6">
<div className="flex h-14 min-h-14 justify-between items-center pr-1.5 sm:px-4">
<div className="min-w-0 items-center justify-start">
<Link
Expand Down Expand Up @@ -176,7 +181,7 @@ export function MobileHeader() {
<div
ref={menuRef}
className={cn(
"lg:hidden fixed left-0 right-0 top-20 z-50 bg-[#121212] border-b border-white/5 overflow-hidden",
"mobile-app-menu lg:hidden fixed left-0 right-0 top-20 z-50 bg-[#121212] border-b border-white/5 overflow-hidden",
"transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]",
isOpen
? "translate-y-0 opacity-100"
Expand Down
4 changes: 1 addition & 3 deletions apps/web-app/src/components/navigation/NavItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ export function NavItem({ label, href, icon: Icon, isActive }: NavItemProps) {
href={href}
className={cn(
"flex items-center gap-3 px-4 py-3 text-base font-medium transition-colors duration-150",
isActive
? "text-white"
: "text-white/35 hover:text-white/65"
isActive ? "text-white" : "text-white/35 hover:text-white/65"
)}
style={
isActive
Expand Down
Loading