From 1f15b732fb205b9d91d512992ef2c0651ea70f9b Mon Sep 17 00:00:00 2001 From: Adeolu01 Date: Sun, 26 Jul 2026 13:12:21 +0100 Subject: [PATCH] Add global dark mode footer with accordion and newsletter subscription Introduce a reusable GlobalFooter with Infrastructure/Resources/Connect link sections backed by a footerService/useFooter query, a validated email subscription form with pending/success/error states, and a legal links + copyright bar. Sections collapse into an accessible accordion on mobile and render as a grid on desktop, styled for dark mode. Wire GlobalFooter into the root layout and remove the now-redundant inline footer markup from app/page.tsx. --- app/layout.tsx | 2 + app/page.tsx | 50 ------ components/shared/GlobalFooter.tsx | 234 +++++++++++++++++++++++++++++ hooks/useFooter.ts | 59 ++++++++ services/footerService.ts | 36 +++++ 5 files changed, 331 insertions(+), 50 deletions(-) create mode 100644 components/shared/GlobalFooter.tsx create mode 100644 hooks/useFooter.ts create mode 100644 services/footerService.ts diff --git a/app/layout.tsx b/app/layout.tsx index 3174a53..843cfb2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,7 @@ import TopLoader from '@/components/ui/TopLoader'; import CommandPalette from '@/components/ui/CommandPalette'; import ToastProvider from '@/components/providers/ToastProvider'; import ModalProvider from '@/components/providers/ModalProvider'; +import { GlobalFooter } from '@/components/shared/GlobalFooter'; import { themeService } from '@/services/themeService'; export const metadata = { @@ -50,6 +51,7 @@ export default function RootLayout({ {children} + diff --git a/app/page.tsx b/app/page.tsx index 5c939cd..19675f6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -195,56 +195,6 @@ export default function Home() { -
-
-
-

SwiftChain

-

- Blockchain-powered logistics with secure escrow payments. -

-
- -
-

Platform

-
    -
  • - Deliveries -
  • -
  • - Drivers -
  • -
  • - Escrow -
  • -
  • - Pricing -
  • -
-
- -
-

Company

-
    -
  • - About -
  • -
  • - Docs -
  • -
  • - Contact -
  • -
  • - Privacy -
  • -
-
-
- -

- © {new Date().getFullYear()} SwiftChain. All rights reserved. -

-
); } diff --git a/components/shared/GlobalFooter.tsx b/components/shared/GlobalFooter.tsx new file mode 100644 index 0000000..dfe9c0d --- /dev/null +++ b/components/shared/GlobalFooter.tsx @@ -0,0 +1,234 @@ +'use client'; + +import { useId, useState, type FormEvent } from 'react'; +import { AtSign, ChevronDown, Code2, MessageCircle } from 'lucide-react'; +import { useFooter, useNewsletterSubscribe } from '@/hooks/useFooter'; +import type { FooterSection } from '@/services/footerService'; + +const FALLBACK_SECTIONS: FooterSection[] = [ + { + id: 'infrastructure', + title: 'Infrastructure', + links: [ + { id: 'escrow-engine', label: 'Escrow Engine', href: '/infrastructure/escrow' }, + { id: 'fleet-network', label: 'Fleet Network', href: '/infrastructure/fleet' }, + { id: 'stellar-settlement', label: 'Stellar Settlement', href: '/infrastructure/stellar' }, + ], + }, + { + id: 'resources', + title: 'Resources', + links: [ + { id: 'documentation', label: 'Documentation', href: '/docs' }, + { id: 'api-reference', label: 'API Reference', href: '/docs/api' }, + { id: 'faq', label: 'FAQ', href: '/faq' }, + { id: 'support', label: 'Support', href: '/support' }, + ], + }, + { + id: 'connect', + title: 'Connect', + links: [ + { id: 'twitter', label: 'Twitter/X', href: 'https://twitter.com/swiftchain' }, + { id: 'github', label: 'GitHub', href: 'https://github.com/swiftchain' }, + { id: 'discord', label: 'Discord', href: 'https://discord.gg/swiftchain' }, + { id: 'contact', label: 'Contact', href: '/contact' }, + ], + }, +]; + +const LEGAL_LINKS = [ + { id: 'privacy', label: 'Privacy Policy', href: '/legal/privacy' }, + { id: 'terms', label: 'Terms of Service', href: '/legal/terms' }, + { id: 'cookies', label: 'Cookie Policy', href: '/legal/cookies' }, +]; + +const CONNECT_ICONS: Record = { + twitter: AtSign, + github: Code2, + discord: MessageCircle, +}; + +function isValidEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +} + +interface FooterAccordionSectionProps { + section: FooterSection; + isOpen: boolean; + onToggle: () => void; +} + +function FooterAccordionSection({ + section, + isOpen, + onToggle, +}: FooterAccordionSectionProps) { + const panelId = `footer-panel-${section.id}`; + + return ( +
+ + +
+ ); +} + +export function GlobalFooter() { + const { content, isLoading } = useFooter(); + const { subscribe, isPending, isSuccess, isError, reset } = + useNewsletterSubscribe(); + + const [email, setEmail] = useState(''); + const [validationError, setValidationError] = useState(null); + const [openSectionId, setOpenSectionId] = useState(null); + const emailInputId = useId(); + + const sections = content?.sections ?? FALLBACK_SECTIONS; + + const handleToggleSection = (sectionId: string) => { + setOpenSectionId((current) => (current === sectionId ? null : sectionId)); + }; + + const handleSubscribe = async (event: FormEvent) => { + event.preventDefault(); + reset(); + + if (!isValidEmail(email)) { + setValidationError('Enter a valid email address.'); + return; + } + + setValidationError(null); + + try { + await subscribe(email); + setEmail(''); + } catch { + // handled via isError from the mutation + } + }; + + return ( +
+
+
+ {isLoading && !content + ? sections.map((section) => ( +
+

{section.title}

+
+
+
+
+
+
+ )) + : null} + {sections.map((section) => ( + handleToggleSection(section.id)} + /> + ))} +
+ +
+

Stay in the loop

+

+ Subscribe for product updates and logistics infrastructure news. +

+ +
void handleSubscribe(event)} + className="mt-4 flex max-w-md flex-col gap-2 sm:flex-row" + noValidate + > + + setEmail(event.target.value)} + placeholder="you@example.com" + autoComplete="email" + className="w-full rounded-lg border border-gray-700 bg-gray-900 px-4 py-2.5 text-sm text-white placeholder:text-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + +
+ +
+ {validationError ? ( +

{validationError}

+ ) : isError ? ( +

+ Something went wrong. Please try again. +

+ ) : isSuccess ? ( +

You're subscribed. Thank you!

+ ) : null} +
+
+ +
+ +

© {new Date().getFullYear()} SwiftChain. All rights reserved.

+
+
+
+ ); +} + +export default GlobalFooter; diff --git a/hooks/useFooter.ts b/hooks/useFooter.ts new file mode 100644 index 0000000..1db6dcd --- /dev/null +++ b/hooks/useFooter.ts @@ -0,0 +1,59 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + footerService, + type FooterContent, +} from '@/services/footerService'; + +export const FOOTER_QUERY_KEY = ['footer'] as const; + +export interface UseFooterReturn { + content: FooterContent | undefined; + isLoading: boolean; + isError: boolean; + error: Error | null; +} + +/** + * useFooter — fetches global footer content (link sections) from the backend API. + */ +export function useFooter(): UseFooterReturn { + const { data, isLoading, isError, error } = useQuery({ + queryKey: FOOTER_QUERY_KEY, + queryFn: () => footerService.getFooterContent(), + }); + + return { + content: data, + isLoading, + isError, + error: error ?? null, + }; +} + +export interface UseNewsletterSubscribeReturn { + subscribe: (_email: string) => Promise<{ success: boolean }>; + isPending: boolean; + isSuccess: boolean; + isError: boolean; + error: Error | null; + reset: () => void; +} + +/** + * useNewsletterSubscribe — submits an email address to the newsletter + * subscription endpoint and exposes pending/success/error state for the form. + */ +export function useNewsletterSubscribe(): UseNewsletterSubscribeReturn { + const mutation = useMutation<{ success: boolean }, Error, string>({ + mutationFn: (email: string) => footerService.subscribeToNewsletter(email), + }); + + return { + subscribe: (email: string) => mutation.mutateAsync(email), + isPending: mutation.isPending, + isSuccess: mutation.isSuccess, + isError: mutation.isError, + error: mutation.error ?? null, + reset: mutation.reset, + }; +} diff --git a/services/footerService.ts b/services/footerService.ts new file mode 100644 index 0000000..86ad984 --- /dev/null +++ b/services/footerService.ts @@ -0,0 +1,36 @@ +import axios from 'axios'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +export interface FooterLink { + id: string; + label: string; + href: string; +} + +export interface FooterSection { + id: string; + title: string; + links: FooterLink[]; +} + +export interface FooterContent { + sections: FooterSection[]; +} + +export const footerService = { + async getFooterContent(): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/api/footer`, + ); + return data; + }, + + async subscribeToNewsletter(email: string): Promise<{ success: boolean }> { + const { data } = await axios.post<{ success: boolean }>( + `${API_BASE_URL}/api/footer/subscribe`, + { email }, + ); + return data; + }, +};