diff --git a/web/src/app.tsx b/web/src/app.tsx index a2d4fc5..4e66af5 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -3,19 +3,17 @@ import { Router } from "@solidjs/router"; import { FileRoutes } from "@solidjs/start/router"; import { Suspense } from "solid-js"; import { useTheme } from "./lib/theme"; +import { AppShell } from "./components/layout"; import "./app.css"; export default function App() { useTheme(); return ( ( - - SolidStart - Basic - Index - About + root={(props) => ( + {props.children} - + )} > diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..a7f728a --- /dev/null +++ b/web/src/components/layout/AppShell.tsx @@ -0,0 +1,45 @@ +import { MetaProvider, Title } from "@solidjs/meta"; +import { A } from "@solidjs/router"; +import { createEffect, onMount, onCleanup, type JSX } from "solid-js"; +import Navbar from "./Navbar"; +import Footer from "./Footer"; + +interface AppShellProps { + children: JSX.Element; + title?: string; +} + +export default function AppShell(props: AppShellProps) { + const title = () => props.title ?? "ShieldAI"; + + onMount(() => { + const onRouteChange = () => { + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + const observer = new MutationObserver(() => { + onRouteChange(); + }); + + if (document.body) { + observer.observe(document.body, { childList: true, subtree: true }); + } + + onCleanup(() => { + observer.disconnect(); + }); + }); + + return ( + + {title()} +
+ +
+ {props.children} +
+
+
+
+ ); +} diff --git a/web/src/components/layout/Footer.tsx b/web/src/components/layout/Footer.tsx new file mode 100644 index 0000000..b7e01b1 --- /dev/null +++ b/web/src/components/layout/Footer.tsx @@ -0,0 +1,192 @@ +import { A } from "@solidjs/router"; +import { For } from "solid-js"; + +function ShieldLogo() { + return ( + + + + + + + + + + + ); +} + +const footerLinks = [ + { + title: "Product", + links: [ + { label: "Features", href: "/features" }, + { label: "Pricing", href: "/pricing" }, + { label: "Changelog", href: "/changelog" }, + { label: "Documentation", href: "/docs" }, + ], + }, + { + title: "Company", + links: [ + { label: "About", href: "/about" }, + { label: "Blog", href: "/blog" }, + { label: "Careers", href: "/careers" }, + { label: "Contact", href: "/contact" }, + ], + }, + { + title: "Resources", + links: [ + { label: "Community", href: "/community" }, + { label: "Help Center", href: "/help" }, + { label: "Partners", href: "/partners" }, + { label: "Status", href: "/status" }, + ], + }, + { + title: "Legal", + links: [ + { label: "Privacy", href: "/privacy" }, + { label: "Terms", href: "/terms" }, + { label: "Security", href: "/security" }, + { label: "Cookies", href: "/cookies" }, + ], + }, +]; + +function GithubIcon() { + return ( + + + + ); +} + +function TwitterIcon() { + return ( + + + + ); +} + +function LinkedInIcon() { + return ( + + + + ); +} + +const socialLinks = [ + { label: "GitHub", href: "https://github.com", Icon: GithubIcon }, + { label: "Twitter / X", href: "https://twitter.com", Icon: TwitterIcon }, + { label: "LinkedIn", href: "https://linkedin.com", Icon: LinkedInIcon }, +]; + +export default function Footer() { + return ( +
+
+
+
+
+ + + ShieldAI + +
+

+ AI-powered call intelligence that transforms how teams + communicate, collaborate, and close deals. +

+
+ + {(social) => ( + + + + )} + +
+
+ +
+ + {(group) => ( +
+

+ {group.title} +

+ +
+ )} +
+
+
+ +
+

+ {'\u00A9'} {new Date().getFullYear()} ShieldAI. All rights reserved. +

+ +
+
+
+ ); +} diff --git a/web/src/components/layout/Navbar.tsx b/web/src/components/layout/Navbar.tsx new file mode 100644 index 0000000..1553e5d --- /dev/null +++ b/web/src/components/layout/Navbar.tsx @@ -0,0 +1,245 @@ +import { createSignal, onMount, onCleanup, Show, Suspense } from "solid-js"; +import { A } from "@solidjs/router"; +import { cn } from "~/lib/utils"; +import { Button } from "~/components/ui"; +import { useTheme } from "~/lib/theme"; +import { useAuth } from "./useAuth"; + +function ShieldLogo() { + return ( + + + + + + + + + + + ); +} + +function ThemeToggle() { + const { toggle, resolved } = useTheme(); + + return ( + + ); +} + +const navLinks = [ + { label: "Features", href: "/features" }, + { label: "Pricing", href: "/pricing" }, + { label: "Blog", href: "/blog" }, + { label: "Dashboard", href: "/dashboard" }, +]; + +export default function Navbar() { + const [mobileOpen, setMobileOpen] = createSignal(false); + const [scrolled, setScrolled] = createSignal(false); + const auth = useAuth(); + + onMount(() => { + const onScroll = () => { + setScrolled(window.scrollY > 8); + }; + window.addEventListener("scroll", onScroll, { passive: true }); + onCleanup(() => window.removeEventListener("scroll", onScroll)); + }); + + return ( + + ); +} diff --git a/web/src/components/layout/PageContainer.tsx b/web/src/components/layout/PageContainer.tsx new file mode 100644 index 0000000..b5e5b78 --- /dev/null +++ b/web/src/components/layout/PageContainer.tsx @@ -0,0 +1,22 @@ +import { cn } from "~/lib/utils"; +import type { JSX } from "solid-js"; + +interface PageContainerProps { + class?: string; + py?: string; + children: JSX.Element; +} + +export default function PageContainer(props: PageContainerProps) { + return ( +
+ {props.children} +
+ ); +} diff --git a/web/src/components/layout/index.ts b/web/src/components/layout/index.ts new file mode 100644 index 0000000..696e06d --- /dev/null +++ b/web/src/components/layout/index.ts @@ -0,0 +1,5 @@ +export { default as Navbar } from "./Navbar"; +export { default as Footer } from "./Footer"; +export { default as PageContainer } from "./PageContainer"; +export { default as AppShell } from "./AppShell"; +export { useAuth } from "./useAuth"; diff --git a/web/src/components/layout/layout.test.tsx b/web/src/components/layout/layout.test.tsx new file mode 100644 index 0000000..57c750f --- /dev/null +++ b/web/src/components/layout/layout.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render } from "solid-js/web"; +import type { JSX } from "solid-js"; + +import PageContainer from "./PageContainer"; + +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("PageContainer", () => { + it("renders children", () => { + mount(() =>

Test content

); + expect(document.body.textContent).toContain("Test content"); + }); + + it("applies max-width and centered layout classes", () => { + mount(() =>

X

); + const container = document.querySelector(".max-w-7xl")!; + expect(container).toBeTruthy(); + expect(container.className).toContain("max-w-7xl"); + expect(container.className).toContain("mx-auto"); + }); + + it("applies responsive horizontal padding classes", () => { + mount(() =>

X

); + const container = document.querySelector(".px-4")!; + expect(container).toBeTruthy(); + expect(container.className).toContain("px-4"); + expect(container.className).toContain("md:px-6"); + expect(container.className).toContain("lg:px-8"); + }); + + it("applies w-full class", () => { + mount(() =>

X

); + const container = document.querySelector(".w-full")!; + expect(container).toBeTruthy(); + expect(container.className).toContain("w-full"); + }); + + it("merges custom class prop", () => { + mount(() => ( +

X

+ )); + const container = document.querySelector(".my-custom-class")!; + expect(container).toBeTruthy(); + expect(container.className).toContain("my-custom-class"); + }); + + it("applies vertical padding from py prop", () => { + mount(() => ( +

X

+ )); + const container = document.querySelector(".py-12")!; + expect(container).toBeTruthy(); + expect(container.className).toContain("py-12"); + }); + + it("applies both py and custom class", () => { + mount(() => ( +

X

+ )); + const container = document.querySelector(".custom")!; + expect(container).toBeTruthy(); + expect(container.className).toContain("py-8"); + expect(container.className).toContain("custom"); + }); +}); + +describe("useAuth", () => { + it("returns isAuthenticated as false by default", async () => { + const { useAuth } = await import("./useAuth"); + const auth = useAuth(); + expect(auth.isAuthenticated).toBe(false); + }); + + it("returns null user by default", async () => { + const { useAuth } = await import("./useAuth"); + const auth = useAuth(); + expect(auth.user).toBe(null); + }); + + it("provides signIn and signOut methods", async () => { + const { useAuth } = await import("./useAuth"); + const auth = useAuth(); + expect(typeof auth.signIn).toBe("function"); + expect(typeof auth.signOut).toBe("function"); + }); +}); diff --git a/web/src/components/layout/useAuth.ts b/web/src/components/layout/useAuth.ts new file mode 100644 index 0000000..b5c29d0 --- /dev/null +++ b/web/src/components/layout/useAuth.ts @@ -0,0 +1,19 @@ +import { createSignal } from "solid-js"; + +const [getAuth] = (() => { + let isAuthenticated = false; + let user: { name: string; email: string } | null = null; + + return [ + () => ({ + isAuthenticated, + user, + signIn: () => {}, + signOut: () => {}, + }), + ]; +})(); + +export function useAuth() { + return getAuth(); +}