Skip to content

feat: implement Footer component #98

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 7, 2025
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
158 changes: 158 additions & 0 deletions app/demo/footer/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"use client";

import * as React from "react";
import { Footer, FooterWithBuildInfo } from "@/components/footer";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

export default function FooterDemo() {
return (
<div className="min-h-screen">
<div className="container mx-auto py-8">
<h1 className="mb-8 text-3xl font-bold">Footer Component Demo</h1>

<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="basic">Basic Footer</TabsTrigger>
<TabsTrigger value="build">With Build Info</TabsTrigger>
<TabsTrigger value="styled">Custom Styling</TabsTrigger>
</TabsList>

<TabsContent value="basic" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Basic Footer</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4 text-sm">
The default footer with branding and navigation links.
</p>
<div className="rounded-lg border">
<Footer />
</div>
</CardContent>
</Card>
</TabsContent>

<TabsContent value="build" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Footer with Build Information</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4 text-sm">
Footer displaying build ID for version tracking.
</p>
<div className="space-y-4">
<div className="rounded-lg border">
<Footer buildId="v1.2.3-abc123" />
</div>
<div className="rounded-lg border">
<FooterWithBuildInfo />
</div>
</div>
</CardContent>
</Card>
</TabsContent>

<TabsContent value="styled" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Custom Styled Footer</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4 text-sm">
Footer with custom background and styling.
</p>
<div className="space-y-4">
<div className="rounded-lg border">
<Footer
className="bg-primary/5 border-t-primary/20"
buildId="custom-123"
/>
</div>
<div className="rounded-lg border">
<Footer
className="from-primary/5 to-secondary/5 bg-gradient-to-r"
buildId="gradient-456"
/>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>

<Card className="mt-8">
<CardHeader>
<CardTitle>Responsive Behavior</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4 text-sm">
The footer adapts to different screen sizes. Try resizing your
browser window to see the responsive layout in action.
</p>
<div className="space-y-2 text-sm">
<p>
• <strong>Desktop (≥768px):</strong> Horizontal layout with
left-aligned branding and right-aligned navigation
</p>
<p>
• <strong>Mobile (&lt;768px):</strong> Stacked layout with
centered content
</p>
</div>
</CardContent>
</Card>

<Card className="mt-8">
<CardHeader>
<CardTitle>Usage Example</CardTitle>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-x-auto rounded-lg p-4 text-sm">
{`// Basic footer
<Footer />

// Footer with build ID
<Footer buildId="v1.2.3" />

// Footer with environment-based build ID
<FooterWithBuildInfo />

// Footer with custom styling
<Footer
className="bg-primary/5"
buildId="custom-build"
/>`}
</pre>
</CardContent>
</Card>

<Card className="mt-8">
<CardHeader>
<CardTitle>Features</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p>
✓ Responsive layout (horizontal on desktop, stacked on mobile)
</p>
<p>✓ Branding with Ghost icon and company name</p>
<p>✓ Copyright notice with current year</p>
<p>✓ Navigation links (GitHub, Privacy, Terms)</p>
<p>✓ Optional build/version display</p>
<p>✓ Proper semantic HTML structure</p>
<p>✓ Accessible navigation with ARIA labels</p>
<p>✓ Theme-aware styling</p>
<p>✓ External links open in new tab with security attributes</p>
</CardContent>
</Card>
</div>

{/* Example of footer at page bottom */}
<div className="mt-auto">
<Footer buildId="demo-build" />
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Header } from "@/components/header";
import { FooterWithBuildInfo } from "@/components/footer";
import { ErrorBoundary } from "@/components/error-boundary";
import { Toaster } from "sonner";
import "./globals.css";
Expand Down Expand Up @@ -43,6 +44,7 @@ export default function RootLayout({
<main id="main-content" className="flex-1">
<ErrorBoundary>{children}</ErrorBoundary>
</main>
<FooterWithBuildInfo />
</ErrorBoundary>
<Toaster />
</ThemeProvider>
Expand Down
130 changes: 130 additions & 0 deletions components/footer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { Footer, FooterWithBuildInfo } from "./footer";

// Mock next/link
vi.mock("next/link", () => ({
default: ({
children,
...props
}: React.PropsWithChildren<
React.AnchorHTMLAttributes<HTMLAnchorElement>
>) => <a {...props}>{children}</a>,
}));

describe("Footer", () => {
it("renders branding elements", () => {
render(<Footer />);

// Check for logo/text
expect(screen.getByText("GhostPaste")).toBeInTheDocument();

// Check for copyright with current year
const currentYear = new Date().getFullYear();
expect(
screen.getByText(
`© ${currentYear} GhostPaste. Zero-knowledge encrypted code sharing.`
)
).toBeInTheDocument();
});

it("renders navigation links with correct attributes", () => {
render(<Footer />);

// Check GitHub link
const githubLink = screen.getByRole("link", { name: "GitHub" });
expect(githubLink).toHaveAttribute(
"href",
"https://github.com/nullcoder/ghostpaste"
);
expect(githubLink).toHaveAttribute("target", "_blank");
expect(githubLink).toHaveAttribute("rel", "noopener noreferrer");

// Check Privacy link
const privacyLink = screen.getByRole("link", { name: "Privacy" });
expect(privacyLink).toHaveAttribute("href", "/privacy");
expect(privacyLink).not.toHaveAttribute("target");

// Check Terms link
const termsLink = screen.getByRole("link", { name: "Terms" });
expect(termsLink).toHaveAttribute("href", "/terms");
expect(termsLink).not.toHaveAttribute("target");
});

it("renders build ID when provided", () => {
render(<Footer buildId="abc123" />);
expect(screen.getByText("Build abc123")).toBeInTheDocument();
});

it("does not render build info when buildId is not provided", () => {
render(<Footer />);
expect(screen.queryByText(/Build/)).not.toBeInTheDocument();
});

it("applies custom className", () => {
const { container } = render(<Footer className="custom-footer" />);
expect(container.querySelector("footer")).toHaveClass("custom-footer");
});

it("has proper semantic structure", () => {
render(<Footer />);

// Check for footer element
expect(screen.getByRole("contentinfo")).toBeInTheDocument();

// Check for navigation element
expect(screen.getByRole("navigation")).toHaveAttribute(
"aria-label",
"Footer navigation"
);
});

it("uses responsive classes for layout", () => {
const { container } = render(<Footer />);

// Check for responsive flex layout
const flexContainer = container.querySelector(
".flex.flex-col.md\\:flex-row"
);
expect(flexContainer).toBeInTheDocument();

// Check for responsive text alignment
const brandingSection = container.querySelector(
".text-center.md\\:text-left"
);
expect(brandingSection).toBeInTheDocument();
});
});

describe("FooterWithBuildInfo", () => {
const originalEnv = process.env;

beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = originalEnv;
});

it("uses NEXT_PUBLIC_BUILD_ID when available", () => {
process.env.NEXT_PUBLIC_BUILD_ID = "custom-build-123";
render(<FooterWithBuildInfo />);
expect(screen.getByText("Build custom-build-123")).toBeInTheDocument();
});

it("uses VERCEL_GIT_COMMIT_SHA when NEXT_PUBLIC_BUILD_ID is not available", () => {
delete process.env.NEXT_PUBLIC_BUILD_ID;
process.env.VERCEL_GIT_COMMIT_SHA = "abcdef1234567890";
render(<FooterWithBuildInfo />);
expect(screen.getByText("Build abcdef1")).toBeInTheDocument();
});

it("does not render build info when no env vars are available", () => {
delete process.env.NEXT_PUBLIC_BUILD_ID;
delete process.env.VERCEL_GIT_COMMIT_SHA;
render(<FooterWithBuildInfo />);
expect(screen.queryByText(/Build/)).not.toBeInTheDocument();
});
});
92 changes: 92 additions & 0 deletions components/footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from "react";
import Link from "next/link";
import { Container } from "@/components/ui/container";
import { cn } from "@/lib/utils";
import { Ghost } from "lucide-react";

export interface FooterProps {
/**
* Additional CSS classes
*/
className?: string;
/**
* Build ID to display (optional)
*/
buildId?: string;
}

/**
* Footer component with branding, copyright, and navigation links
*/
export function Footer({ className, buildId }: FooterProps) {
const currentYear = new Date().getFullYear();

return (
<footer
className={cn("bg-background/50 border-t backdrop-blur-sm", className)}
>
<Container>
<div className="py-8 md:py-12">
<div className="flex flex-col gap-6 md:flex-row md:justify-between">
{/* Left section - Branding */}
<div className="space-y-2 text-center md:text-left">
<div className="flex items-center justify-center gap-2 md:justify-start">
<Ghost className="h-6 w-6" aria-hidden="true" />
<span className="text-lg font-semibold">GhostPaste</span>
</div>
<p className="text-muted-foreground text-sm">
© {currentYear} GhostPaste. Zero-knowledge encrypted code
sharing.
</p>
</div>

{/* Right section - Navigation */}
<nav
className="flex items-center justify-center gap-6 text-sm md:justify-end"
aria-label="Footer navigation"
>
<Link
href="https://github.com/nullcoder/ghostpaste"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors hover:underline"
>
GitHub
</Link>
<Link
href="/privacy"
className="text-muted-foreground hover:text-foreground transition-colors hover:underline"
>
Privacy
</Link>
<Link
href="/terms"
className="text-muted-foreground hover:text-foreground transition-colors hover:underline"
>
Terms
</Link>
</nav>
</div>

{/* Optional: Build info */}
{buildId && (
<div className="text-muted-foreground/70 mt-6 text-center text-xs md:text-left">
Build {buildId}
</div>
)}
</div>
</Container>
</footer>
);
}

/**
* Footer with build ID from environment variable
*/
export function FooterWithBuildInfo(props: Omit<FooterProps, "buildId">) {
const buildId =
process.env.NEXT_PUBLIC_BUILD_ID ||
process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7);

return <Footer {...props} buildId={buildId} />;
}
Loading