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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ NEXT_PUBLIC_GOOGLE_VERIFICATION_CODE=
# See docs/notice-banner.md
NEXT_PUBLIC_NOTICE_BANNER_TEXT=

# Maintenance Notice Modal
# Set to any truthy value (e.g. "1") to show the maintenance notice overlay.
# SCHEDULE is the bold date/time string shown in the notice body.
# Changing the schedule auto-resets dismissals so users see the new notice.
NEXT_PUBLIC_MAINTENANCE_NOTICE_ENABLED=true
NEXT_PUBLIC_MAINTENANCE_SCHEDULE=Friday, February 13th, from 7:00 PM to 11:00 PM WAT

# Brevo Email Marketing
# Get from: Brevo Dashboard → Settings → API Keys
BREVO_API_KEY=
Expand Down
4 changes: 2 additions & 2 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence,
# @chibie @jeremy0x be requested for
# @chibie @onahprosper @5ran6 be requested for
# review when someone opens a pull request.
* @chibie @jeremy0x
* @chibie @onahprosper @5ran6
10 changes: 8 additions & 2 deletions app/components/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,28 @@ import {
PWAInstall,
NoticeBanner,
} from "./index";
import { MaintenanceNoticeModal, MaintenanceBanner } from "./MaintenanceNoticeModal";

export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<Providers>
<div className="min-h-full min-w-full bg-white transition-colors dark:bg-neutral-900">
<div className="relative">
<Navbar />
{config.noticeBannerText && (
<NoticeBanner textLines={config.noticeBannerText.split("|")} />
{config.maintenanceEnabled ? (
<MaintenanceBanner />
) : (
config.noticeBannerText && (
<NoticeBanner textLines={config.noticeBannerText.split("|")} />
)
)}
</div>
<LayoutWrapper footer={<Footer />}>
<MainContent>{children}</MainContent>
</LayoutWrapper>

<PWAInstall />
<MaintenanceNoticeModal />
</div>
{/* Brevo Chat Widget */}
{/^[a-f0-9]{24}$/i.test(config.brevoConversationsId) && config.brevoConversationsGroupId && (
Expand Down
239 changes: 239 additions & 0 deletions app/components/MaintenanceNoticeModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"use client";

import { useState, useEffect, useCallback } from "react";
import Image from "next/image";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
import { motion, AnimatePresence } from "framer-motion";

export interface MaintenanceNoticeConfig {
/** Master toggle – when false the modal never renders. */
enabled: boolean;
title: string;
titleEmphasis: string;
body: string;
footnote?: string;
buttonText: string;
noticeKey: string;
}


const SCHEDULE =
process.env.NEXT_PUBLIC_MAINTENANCE_SCHEDULE || "";

const ENABLED = !!process.env.NEXT_PUBLIC_MAINTENANCE_NOTICE_ENABLED;

/** Simple deterministic hash for the noticeKey so each schedule gets its own. */
function simpleHash(str: string): string {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
}
return Math.abs(h).toString(36);
}

export const DEFAULT_MAINTENANCE_CONFIG: MaintenanceNoticeConfig = {
enabled: ENABLED && SCHEDULE.length > 0,
title: "We are improving",
titleEmphasis: "your experience.",
body: `A scheduled maintenance will take place on **${SCHEDULE}** to help improve your experience. Some services may be temporarily unavailable during this time.`,
footnote:
"We appreciate your patience while we make these improvements.",
buttonText: "Got it",
noticeKey: `maintenance-${simpleHash(SCHEDULE)}`,
};


const STORAGE_PREFIX = "noblocks_notice_dismissed_";

function isDismissed(key: string): boolean {
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(`${STORAGE_PREFIX}${key}`) === "1";
} catch {
return false;
}
}

function markDismissed(key: string) {
try {
localStorage.setItem(`${STORAGE_PREFIX}${key}`, "1");
} catch {
// Storage may be unavailable (private browsing etc.)
}
}

/** Render a string where **bold** segments become <strong>. */
function RichText({ text, className }: { text: string; className?: string }) {
const parts = text.split(/(\*\*[^*]+\*\*)/g);
return (
<p className={className}>
{parts.map((part, i) =>
part.startsWith("**") && part.endsWith("**") ? (
<strong key={i} className="font-bold">
{part.slice(2, -2)}
</strong>
) : (
<span key={i}>{part}</span>
),
)}
</p>
);
}

// ---------------------------------------------------------------------------
// Persistent top-bar banner (does NOT dismiss — visible as long as enabled)
// ---------------------------------------------------------------------------

export function MaintenanceBanner({
config = DEFAULT_MAINTENANCE_CONFIG,
}: {
config?: MaintenanceNoticeConfig;
}) {
if (!config.enabled) return null;

return (
<motion.div
className="fixed left-0 right-0 top-16 z-30 mt-1 flex min-h-14 w-full items-center bg-[#0860F0] px-0"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
<div className="relative flex w-full items-center sm:pr-8">
{/* Mobile illustration */}
<div className="absolute left-0 top-0 z-0 sm:hidden">
<Image
src="/images/banner-illustration-mobile.svg"
alt=""
width={37}
height={104}
priority
className="h-full w-auto"
/>
</div>
{/* Desktop illustration */}
<div className="z-10 hidden flex-shrink-0 sm:static sm:mr-4 sm:block">
<Image
src="/images/banner-illustration.svg"
alt=""
width={74}
height={64}
priority
/>
</div>

{/* Copy */}
<div className="relative z-10 flex flex-grow items-center justify-between gap-3 px-4 py-3 pl-12 sm:pl-0">
<RichText
text={`Scheduled maintenance on **${SCHEDULE}**. Some services may be temporarily unavailable.`}
className="text-xs font-medium leading-snug text-white/90 sm:text-sm"
/>
</div>
</div>
</motion.div>
);
}

// ---------------------------------------------------------------------------
// Dismissible modal
// ---------------------------------------------------------------------------

interface MaintenanceNoticeModalProps {
config?: MaintenanceNoticeConfig;
}

export function MaintenanceNoticeModal({
config = DEFAULT_MAINTENANCE_CONFIG,
}: MaintenanceNoticeModalProps) {
const [isOpen, setIsOpen] = useState(false);

useEffect(() => {
if (!config.enabled) return;
if (isDismissed(config.noticeKey)) return;
// Small delay so it doesn't flash on initial page load
const timer = setTimeout(() => setIsOpen(true), 400);
return () => clearTimeout(timer);
}, [config.enabled, config.noticeKey]);

const dismiss = useCallback(() => {
markDismissed(config.noticeKey);
setIsOpen(false);
}, [config.noticeKey]);

if (!config.enabled) return null;

return (
<AnimatePresence>
{isOpen && (
<Dialog
open={isOpen}
onClose={dismiss}
className="relative z-[70]"
>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
aria-hidden="true"
/>

<div className="fixed inset-0 flex w-screen items-end sm:items-center sm:justify-center sm:p-4">
<motion.div
initial={{ y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: "100%", opacity: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
className="w-full sm:max-w-lg"
>
<DialogPanel>
<div className="rounded-t-[28px] bg-[#0860F0] px-8 sm:px-6 pb-8 pt-8 sm:rounded-3xl">
<div className="mb-6 flex items-center -space-x-2">
<Image
src="/images/tokens.svg"
alt="tokens"
width={129.97}
height={115.2}
loading="lazy"
className="object-cover"
/>
</div>

<DialogTitle className="mb-4 text-[38px] font-semibold font-inter leading-[37px] text-white sm:text-[38px]">
{config.title}
<br />
<em className="font-serif text-[34px] sm:text-[38px]">{config.titleEmphasis}</em>
</DialogTitle>

<RichText
text={config.body}
className="text-sm leading-relaxed text-white mt-2"
/>

{config.footnote && (
<p className="mt-4 text-sm leading-relaxed text-white">
{config.footnote}
</p>
)}

<button
type="button"
onClick={dismiss}
className="mt-6 min-h-12 w-full rounded-xl bg-white px-4 py-3 text-sm font-semibold text-neutral-900 transition-all hover:bg-white/90 active:scale-[0.98]"
>
{config.buttonText}
</button>
</div>
</DialogPanel>
</motion.div>
</div>
</Dialog>
)}
</AnimatePresence>
);
}
4 changes: 4 additions & 0 deletions app/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,8 @@ export { FundWalletForm } from "./FundWalletForm";
export { TransferForm } from "./TransferForm";

export { default as NoticeBanner } from "./NoticeBanner";
export {
MaintenanceNoticeModal,
MaintenanceBanner,
} from "./MaintenanceNoticeModal";
export { default as AppLayout } from "./AppLayout";
1 change: 1 addition & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@import url('https://fonts.googleapis.com');
@tailwind base;
@tailwind components;
@tailwind utilities;
Expand Down
5 changes: 5 additions & 0 deletions app/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const config: Config = {
brevoConversationsGroupId: process.env.NEXT_PUBLIC_BREVO_CONVERSATIONS_GROUP_ID || "",
blockfestEndDate:
process.env.NEXT_PUBLIC_BLOCKFEST_END_DATE || "2025-10-11T23:59:00+01:00",
maintenanceEnabled:
!!process.env.NEXT_PUBLIC_MAINTENANCE_NOTICE_ENABLED &&
!!(process.env.NEXT_PUBLIC_MAINTENANCE_SCHEDULE || "").trim(),
maintenanceSchedule:
process.env.NEXT_PUBLIC_MAINTENANCE_SCHEDULE || "",
};

export default config;
Expand Down
2 changes: 2 additions & 0 deletions app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ export type Config = {
brevoConversationsId: string; // Brevo chat widget ID
brevoConversationsGroupId?: string; // Brevo chat widget group ID for routing
blockfestEndDate: string; // BlockFest campaign end date
maintenanceEnabled: boolean; // Maintenance notice modal + banner toggle
maintenanceSchedule: string; // e.g. "Friday, February 13th, from 7:00 PM to 11:00 PM WAT"
};

export type Network = {
Expand Down
43 changes: 43 additions & 0 deletions public/images/tokens.svg
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 tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ const config: Config = {
animation: {
"rocket-shake": "rocket-shake 0.7s infinite",
},
fontFamily: {
'inter': ['"Inter"', 'system-ui', 'arial', 'sans-serif'],
},
},
},
plugins: [],
Expand Down