feat: add landing page hero section with animated background
- Create ColorWaveBackground canvas component with animated gradient blobs - Create HeroSection with ShieldAI branding, headline, CTAs, and trust signals - Compose landing page in index.tsx route with background + hero - Add unit tests for HeroSection (13 tests) and ColorWaveBackground (8 tests) - Background respects prefers-reduced-motion accessibility preference - Responsive layout with mobile-first text sizing and stacked buttons
This commit is contained in:
119
web/src/components/landing/ColorWaveBackground.tsx
Normal file
119
web/src/components/landing/ColorWaveBackground.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { onMount, onCleanup } from "solid-js";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface ColorWaveBackgroundProps {
|
||||||
|
class?: string;
|
||||||
|
yOffset?: number;
|
||||||
|
scale?: number;
|
||||||
|
speed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
indigo: "#4f46e5",
|
||||||
|
cyan: "#06b6d4",
|
||||||
|
slate: "#0f172a",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ColorWaveBackground(props: ColorWaveBackgroundProps) {
|
||||||
|
const canvasRef = { current: null as HTMLCanvasElement | null };
|
||||||
|
let animationId: number | undefined;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const reducedMotion = window.matchMedia(
|
||||||
|
"(prefers-reduced-motion: reduce)",
|
||||||
|
).matches;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = canvas.clientWidth * dpr;
|
||||||
|
canvas.height = canvas.clientHeight * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
|
||||||
|
const speed = props.speed ?? 1;
|
||||||
|
const scale = props.scale ?? 1;
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
const blobs = [
|
||||||
|
{
|
||||||
|
x: 0.2,
|
||||||
|
y: 0.3,
|
||||||
|
r: 0.35,
|
||||||
|
color: COLORS.indigo,
|
||||||
|
phase: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: 0.7,
|
||||||
|
y: 0.6,
|
||||||
|
r: 0.4,
|
||||||
|
color: COLORS.cyan,
|
||||||
|
phase: 2.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.8,
|
||||||
|
r: 0.3,
|
||||||
|
color: COLORS.slate,
|
||||||
|
phase: 4.2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const w = canvas.clientWidth;
|
||||||
|
const h = canvas.clientHeight;
|
||||||
|
const t = (Date.now() - t0) * 0.001 * speed;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
blobs.forEach((blob) => {
|
||||||
|
const ox = Math.sin(t + blob.phase) * w * 0.15;
|
||||||
|
const oy = Math.cos(t * 0.7 + blob.phase) * h * 0.12;
|
||||||
|
const x = (blob.x + (props.yOffset ?? 0) * 0.001) * w + ox;
|
||||||
|
const y = blob.y * h + oy;
|
||||||
|
const r = blob.r * Math.min(w, h) * scale;
|
||||||
|
|
||||||
|
const grad = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||||
|
grad.addColorStop(0, blob.color + "40");
|
||||||
|
grad.addColorStop(1, blob.color + "00");
|
||||||
|
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reducedMotion) {
|
||||||
|
animationId = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (animationId !== undefined) {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setRef = (el: HTMLCanvasElement) => {
|
||||||
|
canvasRef.current = el;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={setRef}
|
||||||
|
class={cn("absolute inset-0 w-full h-full", props.class)}
|
||||||
|
style="pointer-events: none;"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
web/src/components/landing/HeroSection.tsx
Normal file
115
web/src/components/landing/HeroSection.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { onMount } from "solid-js";
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { Button } from "~/components/ui";
|
||||||
|
import PageContainer from "~/components/layout/PageContainer";
|
||||||
|
|
||||||
|
function ShieldIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="w-12 h-12 md:w-16 md:h-16"
|
||||||
|
>
|
||||||
|
<circle cx="24" cy="24" r="24" class="gradient-primary" />
|
||||||
|
<path
|
||||||
|
d="M24 8L14 12V20C14 28.4 19.2 36 24 38C28.8 36 34 28.4 34 20V12L24 8Z"
|
||||||
|
fill="white"
|
||||||
|
fill-opacity="0.9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeroSectionProps {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroSection(props: HeroSectionProps) {
|
||||||
|
let heroRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (heroRef) {
|
||||||
|
heroRef.style.opacity = "1";
|
||||||
|
heroRef.style.transform = "translateY(0)";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class={cn("relative", props.class)}>
|
||||||
|
<PageContainer>
|
||||||
|
<div
|
||||||
|
ref={heroRef}
|
||||||
|
class="flex flex-col items-center text-center py-20 md:py-32 transition-all duration-700 ease-out"
|
||||||
|
style="opacity: 0; transform: translateY(20px);"
|
||||||
|
>
|
||||||
|
<div class="mb-6 shadow-glow-primary rounded-full p-3">
|
||||||
|
<ShieldIcon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight mb-6 max-w-4xl">
|
||||||
|
<span class="text-[var(--color-text-primary)]">AI-Powered </span>
|
||||||
|
<span class="text-gradient-primary">Identity Protection</span>
|
||||||
|
<br />
|
||||||
|
<span class="text-[var(--color-text-primary)]">for Everyone</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-xl md:text-2xl text-[var(--color-text-secondary)] max-w-2xl mb-10 leading-relaxed">
|
||||||
|
ShieldAI uses advanced AI to monitor, detect, and prevent identity
|
||||||
|
threats in real-time. Your digital identity, protected by
|
||||||
|
intelligence.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||||
|
<A href="/signup">
|
||||||
|
<Button variant="primary" size="lg">
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
</A>
|
||||||
|
<A href="#features">
|
||||||
|
<Button variant="ghost" size="lg">
|
||||||
|
Learn More
|
||||||
|
</Button>
|
||||||
|
</A>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-[var(--color-text-tertiary)]">
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z"
|
||||||
|
fill="var(--color-success)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
No credit card required
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z"
|
||||||
|
fill="var(--color-success)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Free tier available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
web/src/components/landing/background.test.tsx
Normal file
102
web/src/components/landing/background.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render } from "solid-js/web";
|
||||||
|
import type { JSX } from "solid-js";
|
||||||
|
|
||||||
|
import ColorWaveBackground from "./ColorWaveBackground";
|
||||||
|
|
||||||
|
function mount(comp: () => JSX.Element): HTMLDivElement {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
render(() => comp(), container);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn((query: string) => ({
|
||||||
|
matches: query === "(prefers-reduced-motion: reduce)",
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ColorWaveBackground", () => {
|
||||||
|
it("renders a canvas element", () => {
|
||||||
|
mount(() => <ColorWaveBackground />);
|
||||||
|
const canvas = document.querySelector("canvas");
|
||||||
|
expect(canvas).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has absolute positioning classes", () => {
|
||||||
|
mount(() => <ColorWaveBackground />);
|
||||||
|
const canvas = document.querySelector("canvas")!;
|
||||||
|
expect(canvas.className).toContain("absolute");
|
||||||
|
expect(canvas.className).toContain("inset-0");
|
||||||
|
expect(canvas.className).toContain("w-full");
|
||||||
|
expect(canvas.className).toContain("h-full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has pointer-events none style", () => {
|
||||||
|
mount(() => <ColorWaveBackground />);
|
||||||
|
const canvas = document.querySelector("canvas")!;
|
||||||
|
expect(canvas.getAttribute("style")).toContain("pointer-events");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges custom class prop", () => {
|
||||||
|
mount(() => <ColorWaveBackground class="custom-bg" />);
|
||||||
|
const canvas = document.querySelector("canvas")!;
|
||||||
|
expect(canvas.className).toContain("custom-bg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts yOffset prop", () => {
|
||||||
|
mount(() => <ColorWaveBackground yOffset={100} />);
|
||||||
|
const canvas = document.querySelector("canvas");
|
||||||
|
expect(canvas).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts scale prop", () => {
|
||||||
|
mount(() => <ColorWaveBackground scale={1.5} />);
|
||||||
|
const canvas = document.querySelector("canvas");
|
||||||
|
expect(canvas).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts speed prop", () => {
|
||||||
|
mount(() => <ColorWaveBackground speed={2} />);
|
||||||
|
const canvas = document.querySelector("canvas");
|
||||||
|
expect(canvas).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects prefers-reduced-motion", () => {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn((query: string) => ({
|
||||||
|
matches: true,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
mount(() => <ColorWaveBackground />);
|
||||||
|
const canvas = document.querySelector("canvas");
|
||||||
|
expect(canvas).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
120
web/src/components/landing/hero.test.tsx
Normal file
120
web/src/components/landing/hero.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render } from "solid-js/web";
|
||||||
|
import type { JSX } from "solid-js";
|
||||||
|
|
||||||
|
vi.mock("@solidjs/router", () => ({
|
||||||
|
A: (props: { href?: string; children?: JSX.Element }) => {
|
||||||
|
const href = props.href || "#";
|
||||||
|
return (
|
||||||
|
<a href={href}>
|
||||||
|
{props.children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import HeroSection from "./HeroSection";
|
||||||
|
|
||||||
|
function mount(comp: () => JSX.Element): HTMLDivElement {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
render(() => comp(), container);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HeroSection", () => {
|
||||||
|
it("renders the headline with AI-Powered Identity Protection", () => {
|
||||||
|
mount(() => <HeroSection />);
|
||||||
|
expect(document.body.textContent).toContain("AI-Powered");
|
||||||
|
expect(document.body.textContent).toContain("Identity Protection");
|
||||||
|
expect(document.body.textContent).toContain("for Everyone");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the subheadline", () => {
|
||||||
|
mount(() => <HeroSection />);
|
||||||
|
expect(document.body.textContent).toContain("ShieldAI uses advanced AI");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the Get Started CTA", () => {
|
||||||
|
mount(() => <HeroSection />);
|
||||||
|
expect(document.body.textContent).toContain("Get Started");
|
||||||
|
const primaryBtn = document.querySelector("button.gradient-primary");
|
||||||
|
expect(primaryBtn).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the Learn More CTA", () => {
|
||||||
|
mount(() => <HeroSection />);
|
||||||
|
expect(document.body.textContent).toContain("Learn More");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders trust indicators", () => {
|
||||||
|
mount(() => <HeroSection />);
|
||||||
|
expect(document.body.textContent).toContain("No credit card required");
|
||||||
|
expect(document.body.textContent).toContain("Free tier available");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the shield icon SVG", () => {
|
||||||
|
mount(() => <HeroSection />);
|
||||||
|
const svg = document.querySelector("svg");
|
||||||
|
expect(svg).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps content in PageContainer", () => {
|
||||||
|
mount(() => <HeroSection />);
|
||||||
|
const container = document.querySelector(".max-w-7xl");
|
||||||
|
expect(container).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders two buttons for CTAs", () => {
|
||||||
|
mount(() => <HeroSection />);
|
||||||
|
const buttons = document.querySelectorAll("button");
|
||||||
|
expect(buttons.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has Get Started button wrapped in link to /signup", () => {
|
||||||
|
mount(() => <HeroSection />);
|
||||||
|
const links = document.querySelectorAll("a");
|
||||||
|
const signupLink = Array.from(links).find(
|
||||||
|
(a) => a.getAttribute("href") === "/signup",
|
||||||
|
);
|
||||||
|
expect(signupLink).toBeTruthy();
|
||||||
|
expect(signupLink!.textContent).toContain("Get Started");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has Learn More button wrapped in link to #features", () => {
|
||||||
|
mount(() => <HeroSection />);
|
||||||
|
const links = document.querySelectorAll("a");
|
||||||
|
const featuresLink = Array.from(links).find(
|
||||||
|
(a) => a.getAttribute("href") === "#features",
|
||||||
|
);
|
||||||
|
expect(featuresLink).toBeTruthy();
|
||||||
|
expect(featuresLink!.textContent).toContain("Learn More");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies custom class prop", () => {
|
||||||
|
mount(() => <HeroSection class="custom-hero" />);
|
||||||
|
const section = document.querySelector("section.custom-hero");
|
||||||
|
expect(section).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has centered text layout", () => {
|
||||||
|
mount(() => <HeroSection />);
|
||||||
|
const inner = document.querySelector(".text-center");
|
||||||
|
expect(inner).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has responsive vertical padding", () => {
|
||||||
|
mount(() => <HeroSection />);
|
||||||
|
const inner = document.querySelector(".py-20");
|
||||||
|
expect(inner).toBeTruthy();
|
||||||
|
expect(inner!.className).toContain("md:py-32");
|
||||||
|
});
|
||||||
|
});
|
||||||
2
web/src/components/landing/index.ts
Normal file
2
web/src/components/landing/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ColorWaveBackground } from "./ColorWaveBackground";
|
||||||
|
export { default as HeroSection } from "./HeroSection";
|
||||||
@@ -1,25 +1,16 @@
|
|||||||
import { Title } from "@solidjs/meta";
|
import { Title } from "@solidjs/meta";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { ColorWaveBackground, HeroSection } from "~/components/landing";
|
||||||
import Counter from "~/components/Counter";
|
|
||||||
import { api } from "~/lib/api";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const hello = createAsync(() => api.example.hello.query("world"));
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main class="relative min-h-[calc(100vh-4rem)] overflow-hidden">
|
||||||
<Title>Hello World</Title>
|
<Title>ShieldAI — AI-Powered Identity Protection</Title>
|
||||||
<h1>Hello world!</h1>
|
<div class="absolute inset-0 z-0">
|
||||||
<Counter />
|
<ColorWaveBackground />
|
||||||
<p>
|
</div>
|
||||||
Visit{" "}
|
<div class="relative z-10">
|
||||||
<a href="https://start.solidjs.com" target="_blank">
|
<HeroSection />
|
||||||
start.solidjs.com
|
</div>
|
||||||
</a>{" "}
|
|
||||||
to learn how to build SolidStart apps.
|
|
||||||
</p>
|
|
||||||
<pre>
|
|
||||||
<code>{JSON.stringify(hello(), null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user