Compare commits
3 Commits
e02329b734
...
b630559e0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b630559e0b | ||
|
|
fe289228e1 | ||
|
|
63c171f83e |
@@ -16,6 +16,9 @@ const customJestConfig = {
|
||||
'<rootDir>/tests/**/*.test.ts',
|
||||
'<rootDir>/tests/**/*.test.tsx',
|
||||
],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(react-syntax-highlighter|refractor|hastscript|hast-.*|unist-.*|property-information|space-separated-tokens|comma-separated-tokens|web-namespaces)/)',
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,jsx,ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
|
||||
@@ -23,6 +23,19 @@ global.BroadcastChannel = class BroadcastChannel {
|
||||
removeEventListener() {}
|
||||
};
|
||||
|
||||
// Mock IntersectionObserver for components that use viewport detection
|
||||
global.IntersectionObserver = class IntersectionObserver {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
observe() {
|
||||
// Immediately trigger the callback with isIntersecting: true for tests
|
||||
this.callback([{ isIntersecting: true }]);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
// Use real Web Crypto API polyfill for Node environment
|
||||
const cryptoPolyfill = new Crypto();
|
||||
|
||||
|
||||
185
frontend/package-lock.json
generated
185
frontend/package-lock.json
generated
@@ -22,10 +22,12 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"axios": "^1.13.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "^15.5.6",
|
||||
@@ -34,6 +36,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
@@ -5185,6 +5188,12 @@
|
||||
"@types/pg": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
|
||||
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
@@ -5204,6 +5213,15 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-syntax-highlighter": {
|
||||
"version": "15.5.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
|
||||
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/shimmer": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz",
|
||||
@@ -8757,6 +8775,19 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fault": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
|
||||
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"format": "^0.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/fb-watchman": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
|
||||
@@ -8910,6 +8941,14 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/format": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
|
||||
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded-parse": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz",
|
||||
@@ -8917,6 +8956,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.23.24",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
|
||||
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.23",
|
||||
"motion-utils": "^12.23.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
@@ -9456,6 +9522,19 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-parse-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-jsx-runtime": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||
@@ -9525,6 +9604,23 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hastscript": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
|
||||
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"comma-separated-tokens": "^2.0.0",
|
||||
"hast-util-parse-selector": "^4.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"space-separated-tokens": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
@@ -9534,6 +9630,12 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/highlightjs-vue": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
|
||||
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
@@ -13199,6 +13301,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.23",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.23.6",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -14216,6 +14333,15 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
@@ -14553,6 +14679,49 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-syntax-highlighter": {
|
||||
"version": "16.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz",
|
||||
"integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"highlight.js": "^10.4.1",
|
||||
"highlightjs-vue": "^1.0.0",
|
||||
"lowlight": "^1.17.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"refractor": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.20.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-syntax-highlighter/node_modules/highlight.js": {
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
|
||||
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-syntax-highlighter/node_modules/lowlight": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
|
||||
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fault": "^1.0.0",
|
||||
"highlight.js": "~10.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
@@ -14658,6 +14827,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/refractor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
|
||||
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/prismjs": "^1.0.0",
|
||||
"hastscript": "^9.0.0",
|
||||
"parse-entities": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
|
||||
@@ -35,10 +35,12 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"axios": "^1.13.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "^15.5.6",
|
||||
@@ -47,6 +49,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
|
||||
@@ -1,100 +1,95 @@
|
||||
import Image from "next/image";
|
||||
/**
|
||||
* Homepage / Landing Page
|
||||
* Main landing page for the FastNext Template project
|
||||
* Showcases features, tech stack, and provides demos for developers
|
||||
*/
|
||||
|
||||
import { Header } from '@/components/home/Header';
|
||||
import { HeroSection } from '@/components/home/HeroSection';
|
||||
import { ContextSection } from '@/components/home/ContextSection';
|
||||
import { AnimatedTerminal } from '@/components/home/AnimatedTerminal';
|
||||
import { FeatureGrid } from '@/components/home/FeatureGrid';
|
||||
import { DemoSection } from '@/components/home/DemoSection';
|
||||
import { StatsSection } from '@/components/home/StatsSection';
|
||||
import { TechStackSection } from '@/components/home/TechStackSection';
|
||||
import { PhilosophySection } from '@/components/home/PhilosophySection';
|
||||
import { QuickStartCode } from '@/components/home/QuickStartCode';
|
||||
import { CTASection } from '@/components/home/CTASection';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
<div className="min-h-screen">
|
||||
{/* Header Navigation */}
|
||||
<Header />
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
{/* Main Content */}
|
||||
<main>
|
||||
{/* Hero Section with CTAs */}
|
||||
<HeroSection />
|
||||
|
||||
{/* What is this template? */}
|
||||
<ContextSection />
|
||||
|
||||
{/* Animated Terminal with Quick Start */}
|
||||
<AnimatedTerminal />
|
||||
|
||||
{/* 6 Feature Cards Grid */}
|
||||
<FeatureGrid />
|
||||
|
||||
{/* Interactive Demo Cards */}
|
||||
<DemoSection />
|
||||
|
||||
{/* Statistics with Animated Counters */}
|
||||
<StatsSection />
|
||||
|
||||
{/* Tech Stack Grid */}
|
||||
<TechStackSection />
|
||||
|
||||
{/* For Developers, By Developers */}
|
||||
<PhilosophySection />
|
||||
|
||||
{/* Quick Start Code Block */}
|
||||
<QuickStartCode />
|
||||
|
||||
{/* Final CTA Section */}
|
||||
<CTASection />
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t bg-muted/30">
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} FastNext Template. MIT Licensed.
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground">
|
||||
<a
|
||||
href="https://github.com/your-org/fast-next-template"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/your-org/fast-next-template#documentation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/your-org/fast-next-template/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Report Issue
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
140
frontend/src/components/home/AnimatedTerminal.tsx
Normal file
140
frontend/src/components/home/AnimatedTerminal.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Animated Terminal
|
||||
* Terminal with typing animation showing installation/setup commands
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Terminal, Play } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
const commands = [
|
||||
{ text: '# Clone the repository', delay: 0 },
|
||||
{ text: '$ git clone https://github.com/your-org/fast-next-template.git', delay: 800 },
|
||||
{ text: '$ cd fast-next-template', delay: 1600 },
|
||||
{ text: '', delay: 2200 },
|
||||
{ text: '# Start with Docker (one command)', delay: 2400 },
|
||||
{ text: '$ docker-compose up', delay: 3200 },
|
||||
{ text: '', delay: 4000 },
|
||||
{ text: '✓ Backend running at http://localhost:8000', delay: 4200, isSuccess: true },
|
||||
{ text: '✓ Frontend running at http://localhost:3000', delay: 4400, isSuccess: true },
|
||||
{ text: '✓ Admin panel at http://localhost:3000/admin', delay: 4600, isSuccess: true },
|
||||
{ text: '✓ API docs at http://localhost:8000/docs', delay: 4800, isSuccess: true },
|
||||
];
|
||||
|
||||
export function AnimatedTerminal() {
|
||||
const [displayedLines, setDisplayedLines] = useState<typeof commands>([]);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hasAnimated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only animate once when component enters viewport
|
||||
if (hasAnimated.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && !hasAnimated.current) {
|
||||
hasAnimated.current = true;
|
||||
setIsAnimating(true);
|
||||
animateCommands();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.3 }
|
||||
);
|
||||
|
||||
if (containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const animateCommands = () => {
|
||||
commands.forEach((cmd) => {
|
||||
setTimeout(() => {
|
||||
setDisplayedLines((prev) => [...prev, cmd]);
|
||||
}, cmd.delay);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="container mx-auto px-6 py-16 md:py-24" ref={containerRef}>
|
||||
<motion.div
|
||||
className="mx-auto max-w-4xl"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">Get Started in Seconds</h2>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Clone, run, and start building. No complex setup required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Terminal Window */}
|
||||
<div className="rounded-lg border bg-card shadow-lg overflow-hidden">
|
||||
{/* Terminal Header */}
|
||||
<div className="flex items-center gap-2 border-b bg-muted/50 px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" aria-hidden="true" />
|
||||
<div className="h-3 w-3 rounded-full bg-yellow-500" aria-hidden="true" />
|
||||
<div className="h-3 w-3 rounded-full bg-green-500" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4 text-sm text-muted-foreground">
|
||||
<Terminal className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="font-mono">bash</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Content */}
|
||||
<div className="bg-slate-950 p-6 font-mono text-sm overflow-x-auto" style={{ minHeight: '400px' }}>
|
||||
<div className="space-y-2">
|
||||
{displayedLines.map((line, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`${
|
||||
line.isSuccess
|
||||
? 'text-green-400'
|
||||
: line.text.startsWith('#')
|
||||
? 'text-slate-500'
|
||||
: line.text.startsWith('$')
|
||||
? 'text-blue-400'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{line.text || '\u00A0'}
|
||||
{index === displayedLines.length - 1 && isAnimating && !line.isSuccess && (
|
||||
<span className="inline-block w-2 h-4 ml-1 bg-slate-400 animate-pulse" aria-hidden="true" />
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Below Terminal */}
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Or try the live demo without installing anything
|
||||
</p>
|
||||
<Button asChild size="lg" variant="outline" className="gap-2">
|
||||
<Link href="/login">
|
||||
<Play className="h-5 w-5" aria-hidden="true" />
|
||||
Try Live Demo
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
124
frontend/src/components/home/CTASection.tsx
Normal file
124
frontend/src/components/home/CTASection.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* CTA Section
|
||||
* Final call-to-action footer section
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Github, Star, Play, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DemoCredentialsModal } from './DemoCredentialsModal';
|
||||
|
||||
export function CTASection() {
|
||||
const [demoModalOpen, setDemoModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-primary/10 via-background to-background">
|
||||
{/* Background Pattern */}
|
||||
<div
|
||||
className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_50%_50%,rgba(var(--primary-rgb,120,119,198),0.1),transparent_70%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-6 py-24 md:py-32">
|
||||
<motion.div
|
||||
className="mx-auto max-w-3xl text-center space-y-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{/* Headline */}
|
||||
<h2 className="text-4xl md:text-5xl font-bold tracking-tight">
|
||||
Start Building,{' '}
|
||||
<span className="bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
Not Boilerplating
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{/* Subtext */}
|
||||
<p className="text-xl text-muted-foreground leading-relaxed">
|
||||
Clone the repository, read the docs, and ship features on day one.{' '}
|
||||
<span className="text-foreground font-medium">Free forever, MIT licensed.</span>
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="gap-2 text-base group"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/your-org/fast-next-template"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Github className="h-5 w-5" aria-hidden="true" />
|
||||
Get Started on GitHub
|
||||
<div className="inline-flex items-center gap-1 ml-2 rounded-full bg-background/20 px-2 py-0.5 text-xs">
|
||||
<Star className="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
Star
|
||||
</div>
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setDemoModalOpen(true)}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="gap-2 text-base group"
|
||||
>
|
||||
<Play className="h-5 w-5 group-hover:scale-110 transition-transform" aria-hidden="true" />
|
||||
Try Live Demo
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="gap-2 text-base group"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/your-org/fast-next-template#documentation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read Documentation
|
||||
<ArrowRight className="h-4 w-4 group-hover:translate-x-1 transition-transform" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Extra Info */}
|
||||
<motion.div
|
||||
className="pt-8 text-sm text-muted-foreground"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<p>
|
||||
Need help getting started? Check out the{' '}
|
||||
<Link href="/dev" className="text-primary hover:underline">
|
||||
component showcase
|
||||
</Link>
|
||||
{' '}or explore the{' '}
|
||||
<Link href="/admin" className="text-primary hover:underline">
|
||||
admin dashboard demo
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Demo Credentials Modal */}
|
||||
<DemoCredentialsModal
|
||||
open={demoModalOpen}
|
||||
onClose={() => setDemoModalOpen(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/home/ContextSection.tsx
Normal file
59
frontend/src/components/home/ContextSection.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Context Section
|
||||
* Explains what the template is and what you get out of the box
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
|
||||
export function ContextSection() {
|
||||
const features = [
|
||||
'Clone & Deploy in < 5 minutes',
|
||||
'97% Test Coverage (743 tests)',
|
||||
'12+ Documentation Guides',
|
||||
'Zero Commercial Dependencies',
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="container mx-auto px-6 py-16 md:py-24">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<motion.div
|
||||
className="text-center space-y-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold">
|
||||
What You Get Out of the Box
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground leading-relaxed">
|
||||
This isn't a boilerplate generator or a paid SaaS template. It's a complete,
|
||||
production-ready codebase you can clone and customize. Everything you need to build
|
||||
modern web applications without reinventing authentication, authorization, and admin
|
||||
infrastructure.
|
||||
</p>
|
||||
|
||||
{/* Feature Badges */}
|
||||
<div className="flex flex-wrap justify-center gap-3 pt-4">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature}
|
||||
className="inline-flex items-center gap-2 rounded-full border bg-card px-4 py-2 text-sm font-medium"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" aria-hidden="true" />
|
||||
<span>{feature}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
141
frontend/src/components/home/DemoCredentialsModal.tsx
Normal file
141
frontend/src/components/home/DemoCredentialsModal.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Demo Credentials Modal
|
||||
* Displays demo login credentials for testing the live application
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface DemoCredentialsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DemoCredentialsModal({ open, onClose }: DemoCredentialsModalProps) {
|
||||
const [copiedRegular, setCopiedRegular] = useState(false);
|
||||
const [copiedAdmin, setCopiedAdmin] = useState(false);
|
||||
|
||||
const regularCredentials = 'demo@example.com\nDemo123!';
|
||||
const adminCredentials = 'admin@example.com\nAdmin123!';
|
||||
|
||||
const copyToClipboard = async (text: string, type: 'regular' | 'admin') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (type === 'regular') {
|
||||
setCopiedRegular(true);
|
||||
setTimeout(() => setCopiedRegular(false), 2000);
|
||||
} else {
|
||||
setCopiedAdmin(true);
|
||||
setTimeout(() => setCopiedAdmin(false), 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Try the Live Demo</DialogTitle>
|
||||
<DialogDescription>
|
||||
Use these credentials to explore the template's features. Both accounts are pre-configured with sample data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 my-4">
|
||||
{/* Regular User Credentials */}
|
||||
<div className="rounded-lg border p-4 space-y-3 bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold text-sm">Regular User</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(regularCredentials, 'regular')}
|
||||
className="h-8 gap-2"
|
||||
>
|
||||
{copiedRegular ? (
|
||||
<Check className="h-4 w-4 text-green-600" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
<span className="sr-only">Copy regular user credentials</span>
|
||||
{copiedRegular ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="font-mono text-sm space-y-1 text-muted-foreground">
|
||||
<p className="flex items-center gap-2">
|
||||
<span className="text-xs font-sans text-muted-foreground/70">Email:</span>
|
||||
<span className="text-foreground">demo@example.com</span>
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<span className="text-xs font-sans text-muted-foreground/70">Password:</span>
|
||||
<span className="text-foreground">Demo123!</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Access settings, organizations, and user features
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Admin User Credentials */}
|
||||
<div className="rounded-lg border p-4 space-y-3 bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold text-sm">Admin User (Superuser)</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(adminCredentials, 'admin')}
|
||||
className="h-8 gap-2"
|
||||
>
|
||||
{copiedAdmin ? (
|
||||
<Check className="h-4 w-4 text-green-600" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
<span className="sr-only">Copy admin user credentials</span>
|
||||
{copiedAdmin ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="font-mono text-sm space-y-1 text-muted-foreground">
|
||||
<p className="flex items-center gap-2">
|
||||
<span className="text-xs font-sans text-muted-foreground/70">Email:</span>
|
||||
<span className="text-foreground">admin@example.com</span>
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<span className="text-xs font-sans text-muted-foreground/70">Password:</span>
|
||||
<span className="text-foreground">Admin123!</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Full admin panel access: user management, analytics, bulk operations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col sm:flex-row gap-2">
|
||||
<Button variant="outline" onClick={onClose} className="w-full sm:w-auto">
|
||||
Close
|
||||
</Button>
|
||||
<Button asChild className="w-full sm:w-auto">
|
||||
<Link href="/login" onClick={onClose}>
|
||||
Go to Login →
|
||||
</Link>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
97
frontend/src/components/home/DemoSection.tsx
Normal file
97
frontend/src/components/home/DemoSection.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Demo Section
|
||||
* Interactive demo cards showing live features with demo credentials
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Play, Layers, ShieldCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const demos = [
|
||||
{
|
||||
icon: Layers,
|
||||
title: 'Component Showcase',
|
||||
description:
|
||||
'Browse the complete design system, UI components, and interactive examples built with shadcn/ui and TailwindCSS',
|
||||
href: '/dev',
|
||||
cta: 'View Components',
|
||||
variant: 'outline' as const,
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: 'Authentication Flow',
|
||||
description:
|
||||
'Test the complete auth flow: login, session management, password reset. Full security implementation with JWT and refresh tokens',
|
||||
href: '/login',
|
||||
credentials: 'demo@example.com / Demo123!',
|
||||
cta: 'Try Auth Demo',
|
||||
variant: 'default' as const,
|
||||
},
|
||||
{
|
||||
icon: Play,
|
||||
title: 'Admin Dashboard',
|
||||
description:
|
||||
'Experience the admin panel with user management, real-time analytics charts, bulk operations, and session monitoring',
|
||||
href: '/admin',
|
||||
credentials: 'admin@example.com / Admin123!',
|
||||
cta: 'Launch Admin',
|
||||
variant: 'outline' as const,
|
||||
},
|
||||
];
|
||||
|
||||
export function DemoSection() {
|
||||
return (
|
||||
<section className="container mx-auto px-6 py-16 md:py-24 bg-muted/30">
|
||||
<motion.div
|
||||
className="text-center mb-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">See It In Action</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
Explore the template's capabilities with live demos. Login with demo credentials to test features.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||
{demos.map((demo, index) => (
|
||||
<motion.div
|
||||
key={demo.title}
|
||||
className="h-full flex flex-col rounded-lg border bg-card p-6 hover:shadow-lg transition-shadow"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
whileHover={{ y: -4 }}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-primary to-primary/60">
|
||||
<demo.icon className="h-6 w-6 text-primary-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-2">{demo.title}</h3>
|
||||
<p className="text-muted-foreground mb-4 leading-relaxed flex-1">
|
||||
{demo.description}
|
||||
</p>
|
||||
|
||||
{demo.credentials && (
|
||||
<div className="mb-4 p-3 rounded-md bg-muted font-mono text-xs border">
|
||||
<div className="text-muted-foreground mb-1">Demo Credentials:</div>
|
||||
<div className="text-foreground">{demo.credentials}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button asChild className="w-full" variant={demo.variant}>
|
||||
<Link href={demo.href}>{demo.cta} →</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/home/FeatureCard.tsx
Normal file
63
frontend/src/components/home/FeatureCard.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Feature Card
|
||||
* Reusable feature card component with icon, title, description, and CTA link
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight, LucideIcon } from 'lucide-react';
|
||||
|
||||
interface FeatureCardProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
highlight: string;
|
||||
ctaText: string;
|
||||
ctaHref: string;
|
||||
}
|
||||
|
||||
export function FeatureCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
highlight,
|
||||
ctaText,
|
||||
ctaHref,
|
||||
}: FeatureCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="group relative h-full flex flex-col rounded-lg border bg-card p-6 hover:shadow-lg transition-all"
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{/* Icon with Gradient Background */}
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-primary to-primary/60">
|
||||
<Icon className="h-6 w-6 text-primary-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-xl font-semibold mb-2">{title}</h3>
|
||||
|
||||
{/* Highlight Badge */}
|
||||
<div className="mb-3">
|
||||
<span className="inline-block rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||
{highlight}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground mb-4 leading-relaxed flex-1">{description}</p>
|
||||
|
||||
{/* CTA Link */}
|
||||
<Link
|
||||
href={ctaHref}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline group-hover:gap-2 transition-all"
|
||||
>
|
||||
{ctaText}
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
117
frontend/src/components/home/FeatureGrid.tsx
Normal file
117
frontend/src/components/home/FeatureGrid.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Feature Grid
|
||||
* Grid layout displaying 6 main features with stagger animation
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Shield, Users, BarChart3, BookOpen, Server, Code } from 'lucide-react';
|
||||
import { FeatureCard } from './FeatureCard';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Authentication & Security',
|
||||
description:
|
||||
'JWT authentication with refresh tokens, session management, password reset flow, rate limiting, CSRF protection, and comprehensive security tests preventing common attacks (CVE-2015-9235, session hijacking)',
|
||||
highlight: 'Battle-tested security',
|
||||
ctaText: 'View Auth Flow',
|
||||
ctaHref: '/login',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Multi-Tenant Organizations',
|
||||
description:
|
||||
'Complete organization system with 3-tier RBAC (Owner/Admin/Member). Invite members, manage permissions, and scope data access per organization—all batteries included',
|
||||
highlight: 'Multi-tenancy built-in',
|
||||
ctaText: 'See Organizations',
|
||||
ctaHref: '/admin/organizations',
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Admin Dashboard',
|
||||
description:
|
||||
'Full-featured admin panel with user management, real-time analytics charts, bulk operations, session monitoring, and role-based access controls',
|
||||
highlight: 'Enterprise-ready admin',
|
||||
ctaText: 'Try Admin Panel',
|
||||
ctaHref: '/admin',
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: 'Complete Documentation',
|
||||
description:
|
||||
'12+ documentation guides covering architecture, design system, testing patterns, deployment, and AI code generation guidelines. Interactive API docs with Swagger and ReDoc',
|
||||
highlight: 'Developer-first docs',
|
||||
ctaText: 'Browse Docs',
|
||||
ctaHref: 'https://github.com/your-org/fast-next-template#documentation',
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: 'Production Ready',
|
||||
description:
|
||||
'Docker deployment configs, database migrations with Alembic helpers, connection pooling, health checks, monitoring setup, and production security headers',
|
||||
highlight: 'Deploy with confidence',
|
||||
ctaText: 'Deployment Guide',
|
||||
ctaHref: 'https://github.com/your-org/fast-next-template#deployment',
|
||||
},
|
||||
{
|
||||
icon: Code,
|
||||
title: 'Developer Experience',
|
||||
description:
|
||||
'Auto-generated TypeScript API client from OpenAPI spec, hot reload in development, migration helpers (python migrate.py auto), VS Code settings, and comprehensive component library',
|
||||
highlight: 'Delightful DX',
|
||||
ctaText: 'Explore Components',
|
||||
ctaHref: '/dev',
|
||||
},
|
||||
];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.15,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
export function FeatureGrid() {
|
||||
return (
|
||||
<section className="container mx-auto px-6 py-16 md:py-24">
|
||||
<motion.div
|
||||
className="text-center mb-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
Comprehensive Features, No Assembly Required
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
Everything you need to build production-grade web applications. Clone, customize, and ship.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
>
|
||||
{features.map((feature) => (
|
||||
<motion.div key={feature.title} variants={itemVariants}>
|
||||
<FeatureCard {...feature} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
155
frontend/src/components/home/Header.tsx
Normal file
155
frontend/src/components/home/Header.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Homepage Header
|
||||
* Navigation header for the landing page with demo credentials modal
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Menu, X, Github, Star } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DemoCredentialsModal } from './DemoCredentialsModal';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
|
||||
export function Header() {
|
||||
const [demoModalOpen, setDemoModalOpen] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/dev', label: 'Components' },
|
||||
{ href: '/admin', label: 'Admin Demo' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-6">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="font-bold text-xl hover:opacity-80 transition-opacity">
|
||||
<span className="bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
FastNext
|
||||
</span>
|
||||
{' '}
|
||||
<span className="text-foreground">Template</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* GitHub Link with Star */}
|
||||
<a
|
||||
href="https://github.com/your-org/fast-next-template"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Github className="h-4 w-4" aria-hidden="true" />
|
||||
<span>GitHub</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
<Star className="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
<span aria-label="GitHub stars">Star</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* CTAs */}
|
||||
<Button
|
||||
onClick={() => setDemoModalOpen(true)}
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
Try Demo
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Link href="/login">Login</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<SheetTrigger asChild className="md:hidden">
|
||||
<Button variant="ghost" size="icon" aria-label="Toggle menu">
|
||||
{mobileMenuOpen ? (
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[300px] sm:w-[400px]">
|
||||
<nav className="flex flex-col gap-4 mt-8">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="text-lg font-medium hover:text-primary transition-colors"
|
||||
>
|
||||
{link.href}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* GitHub Link */}
|
||||
<a
|
||||
href="https://github.com/your-org/fast-next-template"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 text-lg font-medium hover:text-primary transition-colors"
|
||||
>
|
||||
<Github className="h-5 w-5" aria-hidden="true" />
|
||||
<span>GitHub</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs ml-auto">
|
||||
<Star className="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
Star
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div className="border-t pt-4 mt-4 space-y-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
setDemoModalOpen(true);
|
||||
}}
|
||||
variant="default"
|
||||
className="w-full"
|
||||
>
|
||||
Try Demo
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
|
||||
Login
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Demo Credentials Modal */}
|
||||
<DemoCredentialsModal
|
||||
open={demoModalOpen}
|
||||
onClose={() => setDemoModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
150
frontend/src/components/home/HeroSection.tsx
Normal file
150
frontend/src/components/home/HeroSection.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Hero Section
|
||||
* Main hero section with headline, subheadline, CTAs, and gradient background
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight, Github, Play } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DemoCredentialsModal } from './DemoCredentialsModal';
|
||||
|
||||
export function HeroSection() {
|
||||
const [demoModalOpen, setDemoModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden">
|
||||
{/* Gradient Background */}
|
||||
<div className="absolute inset-0 -z-10 bg-gradient-to-br from-primary/5 via-background to-background" aria-hidden="true" />
|
||||
<div
|
||||
className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_30%_20%,rgba(var(--primary-rgb,120,119,198),0.1),transparent_50%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_70%_80%,rgba(var(--primary-rgb,120,119,198),0.05),transparent_50%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-6 py-24 md:py-32 lg:py-40">
|
||||
<div className="mx-auto max-w-4xl text-center space-y-8">
|
||||
{/* Badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border bg-background/50 px-4 py-1.5 text-sm backdrop-blur">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-green-500 animate-pulse" aria-hidden="true" />
|
||||
<span className="font-medium">MIT Licensed</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="font-medium">97% Test Coverage</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="font-medium">Production Ready</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.h1
|
||||
className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
>
|
||||
<span className="block">Everything You Need to Build</span>
|
||||
<span className="block bg-gradient-to-r from-primary via-primary/80 to-primary/60 bg-clip-text text-transparent">
|
||||
Modern Web Applications
|
||||
</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
<motion.p
|
||||
className="mx-auto max-w-2xl text-lg md:text-xl text-muted-foreground leading-relaxed"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
Production-ready FastAPI + Next.js template with authentication, multi-tenancy, and
|
||||
comprehensive admin panel. Built by developers, for developers.{' '}
|
||||
<span className="text-foreground font-medium">Start building features on day one.</span>
|
||||
</motion.p>
|
||||
|
||||
{/* CTAs */}
|
||||
<motion.div
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => setDemoModalOpen(true)}
|
||||
className="gap-2 text-base group"
|
||||
>
|
||||
<Play className="h-5 w-5 group-hover:scale-110 transition-transform" aria-hidden="true" />
|
||||
Try Live Demo
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="gap-2 text-base group"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/your-org/fast-next-template"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Github className="h-5 w-5" aria-hidden="true" />
|
||||
View on GitHub
|
||||
<ArrowRight className="h-4 w-4 group-hover:translate-x-1 transition-transform" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="gap-2 text-base group"
|
||||
>
|
||||
<Link href="/dev">
|
||||
Explore Components
|
||||
<ArrowRight className="h-4 w-4 group-hover:translate-x-1 transition-transform" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<motion.div
|
||||
className="flex flex-wrap items-center justify-center gap-6 pt-8 text-sm text-muted-foreground"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-foreground">97%</span>
|
||||
<span>Test Coverage</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border" aria-hidden="true" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-foreground">743</span>
|
||||
<span>Passing Tests</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border" aria-hidden="true" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-foreground">0</span>
|
||||
<span>Flaky Tests</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Credentials Modal */}
|
||||
<DemoCredentialsModal
|
||||
open={demoModalOpen}
|
||||
onClose={() => setDemoModalOpen(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/home/PhilosophySection.tsx
Normal file
103
frontend/src/components/home/PhilosophySection.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Philosophy Section
|
||||
* "For Developers, By Developers" section explaining why this template exists
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Check } from 'lucide-react';
|
||||
|
||||
const wontFind = [
|
||||
'Vendor lock-in to specific services',
|
||||
'Hidden costs or upgrade prompts',
|
||||
'Poorly documented "magic"',
|
||||
'Outdated dependencies',
|
||||
];
|
||||
|
||||
const willFind = [
|
||||
'Production patterns that actually work',
|
||||
'Comprehensive test coverage (not aspirational)',
|
||||
'Clear, honest documentation',
|
||||
'Active development and improvements',
|
||||
];
|
||||
|
||||
export function PhilosophySection() {
|
||||
return (
|
||||
<section className="container mx-auto px-6 py-16 md:py-24">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<motion.div
|
||||
className="text-center mb-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-6">
|
||||
Why This Template Exists
|
||||
</h2>
|
||||
<div className="space-y-4 text-lg text-muted-foreground leading-relaxed">
|
||||
<p>
|
||||
We built this template after rebuilding the same authentication, authorization, and
|
||||
admin infrastructure for the fifth time. Instead of yet another tutorial or boilerplate
|
||||
generator, we created a complete, tested, documented codebase that you can clone and
|
||||
customize.
|
||||
</p>
|
||||
<p className="text-foreground font-semibold text-xl">
|
||||
No vendor lock-in. No subscriptions. No license restrictions.
|
||||
</p>
|
||||
<p>
|
||||
Just clean, modern code with patterns that scale. MIT licensed forever.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 mt-12">
|
||||
{/* What You Won't Find */}
|
||||
<motion.div
|
||||
className="h-full rounded-lg border bg-card p-6"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h3 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<X className="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
What You Won't Find Here
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{wontFind.map((item) => (
|
||||
<li key={item} className="flex items-start gap-3">
|
||||
<X className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<span className="text-muted-foreground">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
{/* What You Will Find */}
|
||||
<motion.div
|
||||
className="h-full rounded-lg border bg-card p-6"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h3 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<Check className="h-5 w-5 text-green-600" aria-hidden="true" />
|
||||
What You Will Find
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{willFind.map((item) => (
|
||||
<li key={item} className="flex items-start gap-3">
|
||||
<Check className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<span className="text-muted-foreground">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/home/QuickStartCode.tsx
Normal file
101
frontend/src/components/home/QuickStartCode.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Quick Start Code Block
|
||||
* Code snippet showing quick start commands with copy functionality
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const codeString = `# Clone and start with Docker
|
||||
git clone https://github.com/your-org/fast-next-template.git
|
||||
cd fast-next-template
|
||||
docker-compose up
|
||||
|
||||
# Or set up locally
|
||||
cd backend && python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cd ../frontend && npm install`;
|
||||
|
||||
export function QuickStartCode() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codeString);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="container mx-auto px-6 py-16 md:py-24">
|
||||
<motion.div
|
||||
className="mx-auto max-w-4xl"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">5-Minute Setup</h2>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Clone, run, and start building. It's that simple.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-lg border bg-card shadow-lg overflow-hidden">
|
||||
{/* Header with Copy Button */}
|
||||
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground font-mono">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-green-500" aria-hidden="true" />
|
||||
<span>bash</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
className="gap-2"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 text-green-600" aria-hidden="true" />
|
||||
<span>Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Code Block */}
|
||||
<div className="overflow-x-auto">
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={vscDarkPlus}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1.5rem',
|
||||
background: 'transparent',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
wrapLines
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
134
frontend/src/components/home/StatsSection.tsx
Normal file
134
frontend/src/components/home/StatsSection.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Stats Section
|
||||
* Animated statistics with counters
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
import { CheckCircle2, TestTube, Zap, FileCode } from 'lucide-react';
|
||||
|
||||
interface Stat {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
value: number;
|
||||
suffix: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const stats: Stat[] = [
|
||||
{
|
||||
icon: Zap,
|
||||
value: 97,
|
||||
suffix: '%',
|
||||
label: 'Test Coverage',
|
||||
description: 'Comprehensive testing across backend and frontend',
|
||||
},
|
||||
{
|
||||
icon: TestTube,
|
||||
value: 743,
|
||||
suffix: '',
|
||||
label: 'Passing Tests',
|
||||
description: 'Backend, frontend unit, and E2E tests',
|
||||
},
|
||||
{
|
||||
icon: CheckCircle2,
|
||||
value: 0,
|
||||
suffix: '',
|
||||
label: 'Flaky Tests',
|
||||
description: 'Production-stable test suite',
|
||||
},
|
||||
{
|
||||
icon: FileCode,
|
||||
value: 30,
|
||||
suffix: '+',
|
||||
label: 'API Endpoints',
|
||||
description: 'Fully documented with OpenAPI',
|
||||
},
|
||||
];
|
||||
|
||||
function AnimatedCounter({ value, suffix }: { value: number; suffix: string }) {
|
||||
const [count, setCount] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return;
|
||||
|
||||
const duration = 2000; // 2 seconds
|
||||
const steps = 60;
|
||||
const increment = value / steps;
|
||||
let current = 0;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
current += increment;
|
||||
if (current >= value) {
|
||||
setCount(value);
|
||||
clearInterval(timer);
|
||||
} else {
|
||||
setCount(Math.floor(current));
|
||||
}
|
||||
}, duration / steps);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isInView, value]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="text-5xl md:text-6xl font-bold">
|
||||
{count}
|
||||
{suffix}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsSection() {
|
||||
return (
|
||||
<section className="container mx-auto px-6 py-16 md:py-24">
|
||||
<motion.div
|
||||
className="text-center mb-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">Built with Quality in Mind</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
Not just another template. Comprehensive testing, documentation, and production-ready patterns.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
|
||||
{stats.map((stat, index) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
className="text-center space-y-4"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<stat.icon className="h-8 w-8 text-primary" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animated Counter */}
|
||||
<div className="bg-gradient-to-br from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
<AnimatedCounter value={stat.value} suffix={stat.suffix} />
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div>
|
||||
<div className="font-semibold text-lg mb-1">{stat.label}</div>
|
||||
<div className="text-sm text-muted-foreground">{stat.description}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
104
frontend/src/components/home/TechStackSection.tsx
Normal file
104
frontend/src/components/home/TechStackSection.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tech Stack Section
|
||||
* Displays technology logos/badges with tooltips
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface Tech {
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const technologies: Tech[] = [
|
||||
{
|
||||
name: 'FastAPI',
|
||||
description: 'Async Python web framework, auto-docs, type hints',
|
||||
color: 'from-teal-500 to-green-600',
|
||||
},
|
||||
{
|
||||
name: 'Next.js 15',
|
||||
description: 'React 19, App Router, Server Components',
|
||||
color: 'from-slate-900 to-slate-700',
|
||||
},
|
||||
{
|
||||
name: 'PostgreSQL',
|
||||
description: 'Reliable, scalable SQL database',
|
||||
color: 'from-blue-600 to-blue-800',
|
||||
},
|
||||
{
|
||||
name: 'TypeScript',
|
||||
description: 'End-to-end type safety',
|
||||
color: 'from-blue-500 to-blue-700',
|
||||
},
|
||||
{
|
||||
name: 'Docker',
|
||||
description: 'Containerized deployment',
|
||||
color: 'from-blue-400 to-blue-600',
|
||||
},
|
||||
{
|
||||
name: 'TailwindCSS',
|
||||
description: 'Utility-first styling with OKLCH colors',
|
||||
color: 'from-cyan-500 to-blue-500',
|
||||
},
|
||||
{
|
||||
name: 'shadcn/ui',
|
||||
description: 'Accessible component library (New York variant)',
|
||||
color: 'from-slate-800 to-slate-600',
|
||||
},
|
||||
{
|
||||
name: 'Playwright',
|
||||
description: 'Reliable E2E testing (zero flaky tests)',
|
||||
color: 'from-green-600 to-emerald-700',
|
||||
},
|
||||
];
|
||||
|
||||
export function TechStackSection() {
|
||||
return (
|
||||
<section className="container mx-auto px-6 py-16 md:py-24 bg-muted/30">
|
||||
<motion.div
|
||||
className="text-center mb-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
Modern, Type-Safe, Production-Grade Stack
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
Built with the best tools for full-stack development. Async architecture, type safety, and developer experience.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-5xl mx-auto">
|
||||
{technologies.map((tech, index) => (
|
||||
<motion.div
|
||||
key={tech.name}
|
||||
className="group relative h-full"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.4, delay: index * 0.05 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<div className="h-full flex flex-col items-center justify-center rounded-lg border bg-card p-6 text-center hover:shadow-lg transition-all">
|
||||
{/* Tech Badge */}
|
||||
<div className={`inline-block rounded-full bg-gradient-to-r ${tech.color} px-4 py-2 text-white font-semibold text-sm mb-2`}>
|
||||
{tech.name}
|
||||
</div>
|
||||
|
||||
{/* Hover Tooltip */}
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-xs text-muted-foreground mt-2">{tech.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
33
frontend/src/hooks/usePrefersReducedMotion.ts
Normal file
33
frontend/src/hooks/usePrefersReducedMotion.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Hook to detect if user prefers reduced motion
|
||||
* Respects prefers-reduced-motion media query for accessibility
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function usePrefersReducedMotion(): boolean {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if window is defined (SSR safety)
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
setPrefersReducedMotion(mediaQuery.matches);
|
||||
|
||||
// Listen for changes
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setPrefersReducedMotion(event.matches);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return prefersReducedMotion;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Tests for Admin Organization Members Page
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import OrganizationMembersPage from '@/app/admin/organizations/[id]/members/page';
|
||||
|
||||
// Mock Next.js Link
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, href, ...props }: any) => {
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock OrganizationMembersContent component
|
||||
jest.mock('@/components/admin/organizations/OrganizationMembersContent', () => ({
|
||||
OrganizationMembersContent: ({ organizationId }: { organizationId: string }) => (
|
||||
<div data-testid="organization-members-content">
|
||||
Organization ID: {organizationId}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('OrganizationMembersPage', () => {
|
||||
it('renders back button to organizations', async () => {
|
||||
const params = Promise.resolve({ id: 'org-123' });
|
||||
render(await OrganizationMembersPage({ params }));
|
||||
|
||||
const backLink = screen.getByRole('link');
|
||||
expect(backLink).toHaveAttribute('href', '/admin/organizations');
|
||||
});
|
||||
|
||||
it('renders organization members content with correct ID', async () => {
|
||||
const params = Promise.resolve({ id: 'org-456' });
|
||||
render(await OrganizationMembersPage({ params }));
|
||||
|
||||
expect(screen.getByTestId('organization-members-content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Organization ID: org-456')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders container with proper styling', async () => {
|
||||
const params = Promise.resolve({ id: 'org-789' });
|
||||
const { container } = render(await OrganizationMembersPage({ params }));
|
||||
|
||||
const mainContainer = container.querySelector('.container');
|
||||
expect(mainContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles different organization IDs', async () => {
|
||||
const testIds = ['org-001', 'org-abc-123', 'test-org'];
|
||||
|
||||
for (const testId of testIds) {
|
||||
const params = Promise.resolve({ id: testId });
|
||||
const { unmount } = render(await OrganizationMembersPage({ params }));
|
||||
|
||||
expect(screen.getByText(`Organization ID: ${testId}`)).toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Tests for Home Page
|
||||
* Smoke tests for static content
|
||||
* Tests for the new FastNext Template landing page
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import Home from '@/app/page';
|
||||
|
||||
// Mock Next.js Image component
|
||||
// Mock Next.js components
|
||||
jest.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => {
|
||||
@@ -15,56 +15,241 @@ jest.mock('next/image', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, href, ...props }: any) => {
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock framer-motion to avoid animation issues in tests
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
h1: ({ children, ...props }: any) => <h1 {...props}>{children}</h1>,
|
||||
p: ({ children, ...props }: any) => <p {...props}>{children}</p>,
|
||||
section: ({ children, ...props }: any) => <section {...props}>{children}</section>,
|
||||
},
|
||||
AnimatePresence: ({ children }: any) => <>{children}</>,
|
||||
useInView: () => true, // Always in view for tests
|
||||
}));
|
||||
|
||||
// Mock react-syntax-highlighter to avoid ESM issues
|
||||
jest.mock('react-syntax-highlighter', () => ({
|
||||
Prism: ({ children, ...props }: any) => <pre {...props}>{children}</pre>,
|
||||
}));
|
||||
|
||||
jest.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
|
||||
vscDarkPlus: {},
|
||||
}));
|
||||
|
||||
describe('HomePage', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/get started by editing/i)).toBeInTheDocument();
|
||||
describe('Page Structure', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByRole('banner')).toBeInTheDocument(); // header
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
expect(screen.getByRole('contentinfo')).toBeInTheDocument(); // footer
|
||||
});
|
||||
|
||||
it('renders header with logo', () => {
|
||||
render(<Home />);
|
||||
const header = screen.getByRole('banner');
|
||||
expect(within(header).getByText('FastNext')).toBeInTheDocument();
|
||||
expect(within(header).getByText('Template')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders footer with copyright', () => {
|
||||
render(<Home />);
|
||||
const footer = screen.getByRole('contentinfo');
|
||||
expect(within(footer).getByText(/FastNext Template. MIT Licensed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Next.js logo', () => {
|
||||
render(<Home />);
|
||||
describe('Hero Section', () => {
|
||||
it('renders main headline', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getAllByText(/Everything You Need to Build/i)[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Modern Web Applications/i)[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const logo = screen.getByAltText('Next.js logo');
|
||||
expect(logo).toBeInTheDocument();
|
||||
expect(logo).toHaveAttribute('src', '/next.svg');
|
||||
it('renders production-ready messaging', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/Production-ready FastAPI/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders test coverage stats', () => {
|
||||
render(<Home />);
|
||||
const coverageTexts = screen.getAllByText('97%');
|
||||
expect(coverageTexts.length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Test Coverage/i)[0]).toBeInTheDocument();
|
||||
const testCountTexts = screen.getAllByText('743');
|
||||
expect(testCountTexts.length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Passing Tests/i)[0]).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Vercel logo', () => {
|
||||
render(<Home />);
|
||||
describe('Context Section', () => {
|
||||
it('renders what you get message', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/What You Get Out of the Box/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const logo = screen.getByAltText('Vercel logomark');
|
||||
expect(logo).toBeInTheDocument();
|
||||
expect(logo).toHaveAttribute('src', '/vercel.svg');
|
||||
it('renders key features', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getAllByText(/Clone & Deploy in < 5 minutes/i)[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/97% Test Coverage \(743 tests\)/i)[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/12\+ Documentation Guides/i)[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Zero Commercial Dependencies/i)[0]).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('has correct external links', () => {
|
||||
render(<Home />);
|
||||
describe('Feature Grid', () => {
|
||||
it('renders comprehensive features heading', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/Comprehensive Features, No Assembly Required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deployLink = screen.getByRole('link', { name: /deploy now/i });
|
||||
expect(deployLink).toHaveAttribute('href', expect.stringContaining('vercel.com'));
|
||||
expect(deployLink).toHaveAttribute('target', '_blank');
|
||||
expect(deployLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
it('renders all 6 feature cards', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getAllByText('Authentication & Security')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Multi-Tenant Organizations')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Admin Dashboard')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Complete Documentation')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Production Ready')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Developer Experience')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const docsLink = screen.getByRole('link', { name: /read our docs/i });
|
||||
expect(docsLink).toHaveAttribute('href', expect.stringContaining('nextjs.org/docs'));
|
||||
expect(docsLink).toHaveAttribute('target', '_blank');
|
||||
it('has CTAs for each feature', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByRole('link', { name: /View Auth Flow/i })).toHaveAttribute('href', '/login');
|
||||
expect(screen.getByRole('link', { name: /See Organizations/i })).toHaveAttribute('href', '/admin/organizations');
|
||||
expect(screen.getByRole('link', { name: /Try Admin Panel/i })).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders footer links', () => {
|
||||
render(<Home />);
|
||||
describe('Demo Section', () => {
|
||||
it('renders demo section heading', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/See It In Action/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('link', { name: /learn/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /examples/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /go to nextjs\.org/i })).toBeInTheDocument();
|
||||
it('renders demo cards', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getAllByText('Component Showcase')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Authentication Flow')[0]).toBeInTheDocument();
|
||||
// Admin Dashboard appears in both Feature Grid and Demo Section, so use getAllByText
|
||||
const adminDashboards = screen.getAllByText('Admin Dashboard');
|
||||
expect(adminDashboards.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('displays demo credentials', () => {
|
||||
render(<Home />);
|
||||
const credentials = screen.getAllByText(/Demo Credentials:/i);
|
||||
expect(credentials.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('has accessible image alt texts', () => {
|
||||
render(<Home />);
|
||||
describe('Tech Stack Section', () => {
|
||||
it('renders tech stack heading', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/Modern, Type-Safe, Production-Grade Stack/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByAltText('Next.js logo')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('Vercel logomark')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('File icon')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('Window icon')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('Globe icon')).toBeInTheDocument();
|
||||
it('renders all technologies', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getAllByText('FastAPI')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Next.js 15')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('PostgreSQL')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('TypeScript')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Docker')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('TailwindCSS')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/shadcn\/ui/i)[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Playwright')[0]).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Philosophy Section', () => {
|
||||
it('renders why this template exists', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/Why This Template Exists/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders what you wont find section', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/What You Won't Find Here/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Vendor lock-in/i)[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders what you will find section', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/What You Will Find/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Production patterns that actually work/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quick Start Section', () => {
|
||||
it('renders quick start heading', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/5-Minute Setup/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CTA Section', () => {
|
||||
it('renders final CTA', () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/Start Building,/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Not Boilerplating/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has GitHub link', () => {
|
||||
render(<Home />);
|
||||
const githubLinks = screen.getAllByRole('link', { name: /GitHub/i });
|
||||
expect(githubLinks.length).toBeGreaterThan(0);
|
||||
expect(githubLinks[0]).toHaveAttribute('href', expect.stringContaining('github.com'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Links', () => {
|
||||
it('has login link', () => {
|
||||
render(<Home />);
|
||||
const loginLinks = screen.getAllByRole('link', { name: /Login/i });
|
||||
expect(loginLinks.some(link => link.getAttribute('href') === '/login')).toBe(true);
|
||||
});
|
||||
|
||||
it('has component showcase link', () => {
|
||||
render(<Home />);
|
||||
const devLinks = screen.getAllByRole('link', { name: /Component/i });
|
||||
expect(devLinks.some(link => link.getAttribute('href') === '/dev')).toBe(true);
|
||||
});
|
||||
|
||||
it('has admin demo link', () => {
|
||||
render(<Home />);
|
||||
const adminLinks = screen.getAllByRole('link', { name: /Admin/i });
|
||||
expect(adminLinks.some(link => link.getAttribute('href') === '/admin')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has proper heading hierarchy', () => {
|
||||
render(<Home />);
|
||||
const main = screen.getByRole('main');
|
||||
const headings = within(main).getAllByRole('heading');
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('has external links with proper attributes', () => {
|
||||
render(<Home />);
|
||||
const githubLinks = screen.getAllByRole('link', { name: /GitHub/i });
|
||||
const externalLink = githubLinks.find(link =>
|
||||
link.getAttribute('href')?.includes('github.com')
|
||||
);
|
||||
expect(externalLink).toHaveAttribute('target', '_blank');
|
||||
expect(externalLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,14 +6,33 @@ import { render, screen } from '@testing-library/react';
|
||||
import { UserStatusChart } from '@/components/charts/UserStatusChart';
|
||||
import type { UserStatusData } from '@/components/charts/UserStatusChart';
|
||||
|
||||
// Capture label function at module level for testing
|
||||
let capturedLabelFunction: ((entry: any) => string) | null = null;
|
||||
|
||||
// Mock recharts to avoid rendering issues in tests
|
||||
jest.mock('recharts', () => {
|
||||
const OriginalModule = jest.requireActual('recharts');
|
||||
|
||||
const MockPie = (props: any) => {
|
||||
// Capture the label function for testing
|
||||
if (props.label && typeof props.label === 'function') {
|
||||
capturedLabelFunction = props.label;
|
||||
}
|
||||
return <div data-testid="pie-chart">{props.children}</div>;
|
||||
};
|
||||
|
||||
return {
|
||||
...OriginalModule,
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="responsive-container">{children}</div>
|
||||
),
|
||||
PieChart: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="pie-chart-container">{children}</div>
|
||||
),
|
||||
Pie: MockPie,
|
||||
Cell: ({ fill }: { fill: string }) => <div data-testid="cell" style={{ fill }} />,
|
||||
Tooltip: () => <div data-testid="tooltip" />,
|
||||
Legend: () => <div data-testid="legend" />,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -69,4 +88,57 @@ describe('UserStatusChart', () => {
|
||||
expect(screen.getByText('User Status Distribution')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('renderLabel function', () => {
|
||||
beforeEach(() => {
|
||||
capturedLabelFunction = null;
|
||||
});
|
||||
|
||||
it('formats label with name and percentage', () => {
|
||||
render(<UserStatusChart data={mockData} />);
|
||||
|
||||
expect(capturedLabelFunction).toBeTruthy();
|
||||
|
||||
if (capturedLabelFunction) {
|
||||
const result = capturedLabelFunction({ name: 'Active', percent: 0.75 });
|
||||
expect(result).toBe('Active: 75%');
|
||||
}
|
||||
});
|
||||
|
||||
it('formats label with zero percent', () => {
|
||||
render(<UserStatusChart data={mockData} />);
|
||||
|
||||
if (capturedLabelFunction) {
|
||||
const result = capturedLabelFunction({ name: 'Inactive', percent: 0 });
|
||||
expect(result).toBe('Inactive: 0%');
|
||||
}
|
||||
});
|
||||
|
||||
it('formats label with 100 percent', () => {
|
||||
render(<UserStatusChart data={mockData} />);
|
||||
|
||||
if (capturedLabelFunction) {
|
||||
const result = capturedLabelFunction({ name: 'All Users', percent: 1 });
|
||||
expect(result).toBe('All Users: 100%');
|
||||
}
|
||||
});
|
||||
|
||||
it('rounds percentage to nearest whole number', () => {
|
||||
render(<UserStatusChart data={mockData} />);
|
||||
|
||||
if (capturedLabelFunction) {
|
||||
const result = capturedLabelFunction({ name: 'Pending', percent: 0.4567 });
|
||||
expect(result).toBe('Pending: 46%');
|
||||
}
|
||||
});
|
||||
|
||||
it('handles small percentages', () => {
|
||||
render(<UserStatusChart data={mockData} />);
|
||||
|
||||
if (capturedLabelFunction) {
|
||||
const result = capturedLabelFunction({ name: 'Suspended', percent: 0.025 });
|
||||
expect(result).toBe('Suspended: 3%');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
99
frontend/tests/components/home/AnimatedTerminal.test.tsx
Normal file
99
frontend/tests/components/home/AnimatedTerminal.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Tests for AnimatedTerminal component
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { AnimatedTerminal } from '@/components/home/AnimatedTerminal';
|
||||
|
||||
// Mock framer-motion
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Next.js Link
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, href, ...props }: any) => {
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// IntersectionObserver is already mocked in jest.setup.js
|
||||
|
||||
describe('AnimatedTerminal', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders the section heading', () => {
|
||||
render(<AnimatedTerminal />);
|
||||
|
||||
expect(screen.getByText('Get Started in Seconds')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Clone, run, and start building/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders terminal window with header', () => {
|
||||
render(<AnimatedTerminal />);
|
||||
|
||||
expect(screen.getByText('bash')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Try Live Demo button', () => {
|
||||
render(<AnimatedTerminal />);
|
||||
|
||||
const demoLink = screen.getByRole('link', { name: /try live demo/i });
|
||||
expect(demoLink).toHaveAttribute('href', '/login');
|
||||
});
|
||||
|
||||
it('displays message about trying demo', () => {
|
||||
render(<AnimatedTerminal />);
|
||||
|
||||
expect(screen.getByText(/Or try the live demo without installing/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts animation when component mounts', () => {
|
||||
render(<AnimatedTerminal />);
|
||||
|
||||
// Animation should start because IntersectionObserver mock triggers immediately
|
||||
// Advance timers to show first command
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
// Check if animated content appears (the mock renders all commands immediately in tests)
|
||||
const terminalContent = screen.getByText('bash').parentElement?.parentElement;
|
||||
expect(terminalContent).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders terminal with proper structure', () => {
|
||||
render(<AnimatedTerminal />);
|
||||
|
||||
// Verify terminal window has proper structure
|
||||
const bashIndicator = screen.getByText('bash');
|
||||
expect(bashIndicator).toBeInTheDocument();
|
||||
expect(bashIndicator.parentElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has descriptive text for screen readers', () => {
|
||||
render(<AnimatedTerminal />);
|
||||
|
||||
expect(screen.getByText('Get Started in Seconds')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has proper link to demo', () => {
|
||||
render(<AnimatedTerminal />);
|
||||
|
||||
const demoLink = screen.getByRole('link', { name: /try live demo/i });
|
||||
expect(demoLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
132
frontend/tests/components/home/CTASection.test.tsx
Normal file
132
frontend/tests/components/home/CTASection.test.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Tests for CTASection component
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { CTASection } from '@/components/home/CTASection';
|
||||
|
||||
// Mock framer-motion
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Next.js Link
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, href, ...props }: any) => {
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock DemoCredentialsModal
|
||||
jest.mock('@/components/home/DemoCredentialsModal', () => ({
|
||||
DemoCredentialsModal: ({ open, onClose }: any) => (
|
||||
open ? <div data-testid="demo-modal">
|
||||
<button onClick={onClose}>Close Modal</button>
|
||||
</div> : null
|
||||
),
|
||||
}));
|
||||
|
||||
describe('CTASection', () => {
|
||||
it('renders main headline', () => {
|
||||
render(<CTASection />);
|
||||
|
||||
expect(screen.getByText(/Start Building,/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Not Boilerplating/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders subtext with key messaging', () => {
|
||||
render(<CTASection />);
|
||||
|
||||
expect(screen.getByText(/Clone the repository, read the docs/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Free forever, MIT licensed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders GitHub CTA button', () => {
|
||||
render(<CTASection />);
|
||||
|
||||
const githubLink = screen.getByRole('link', { name: /get started on github/i });
|
||||
expect(githubLink).toHaveAttribute('href', 'https://github.com/your-org/fast-next-template');
|
||||
expect(githubLink).toHaveAttribute('target', '_blank');
|
||||
expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('renders Try Live Demo button', () => {
|
||||
render(<CTASection />);
|
||||
|
||||
const demoButton = screen.getByRole('button', { name: /try live demo/i });
|
||||
expect(demoButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Read Documentation link', () => {
|
||||
render(<CTASection />);
|
||||
|
||||
const docsLink = screen.getByRole('link', { name: /read documentation/i });
|
||||
expect(docsLink).toHaveAttribute('href', 'https://github.com/your-org/fast-next-template#documentation');
|
||||
expect(docsLink).toHaveAttribute('target', '_blank');
|
||||
expect(docsLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('renders help text with internal links', () => {
|
||||
render(<CTASection />);
|
||||
|
||||
expect(screen.getByText(/Need help getting started\?/i)).toBeInTheDocument();
|
||||
|
||||
const componentShowcaseLink = screen.getByRole('link', { name: /component showcase/i });
|
||||
expect(componentShowcaseLink).toHaveAttribute('href', '/dev');
|
||||
|
||||
const adminDashboardLink = screen.getByRole('link', { name: /admin dashboard demo/i });
|
||||
expect(adminDashboardLink).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
|
||||
it('opens demo modal when Try Live Demo button is clicked', () => {
|
||||
render(<CTASection />);
|
||||
|
||||
const demoButton = screen.getByRole('button', { name: /try live demo/i });
|
||||
fireEvent.click(demoButton);
|
||||
|
||||
expect(screen.getByTestId('demo-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes demo modal when close is called', () => {
|
||||
render(<CTASection />);
|
||||
|
||||
// Open modal
|
||||
const demoButton = screen.getByRole('button', { name: /try live demo/i });
|
||||
fireEvent.click(demoButton);
|
||||
expect(screen.getByTestId('demo-modal')).toBeInTheDocument();
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByText('Close Modal');
|
||||
fireEvent.click(closeButton);
|
||||
expect(screen.queryByTestId('demo-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has proper external link attributes', () => {
|
||||
render(<CTASection />);
|
||||
|
||||
const externalLinks = [
|
||||
screen.getByRole('link', { name: /get started on github/i }),
|
||||
screen.getByRole('link', { name: /read documentation/i }),
|
||||
];
|
||||
|
||||
externalLinks.forEach(link => {
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
it('has descriptive button text', () => {
|
||||
render(<CTASection />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /try live demo/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
170
frontend/tests/components/home/DemoCredentialsModal.test.tsx
Normal file
170
frontend/tests/components/home/DemoCredentialsModal.test.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Tests for DemoCredentialsModal component
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { DemoCredentialsModal } from '@/components/home/DemoCredentialsModal';
|
||||
|
||||
// Mock Next.js Link
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, href, ...props }: any) => {
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DemoCredentialsModal', () => {
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnClose.mockClear();
|
||||
// Mock clipboard API
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn(() => Promise.resolve()),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders when open is true', () => {
|
||||
render(<DemoCredentialsModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText('Try the Live Demo')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Use these credentials to explore/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when open is false', () => {
|
||||
render(<DemoCredentialsModal open={false} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.queryByText('Try the Live Demo')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays regular user credentials', () => {
|
||||
render(<DemoCredentialsModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText('Regular User')).toBeInTheDocument();
|
||||
expect(screen.getByText('demo@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Demo123!')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Access settings, organizations/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays admin user credentials', () => {
|
||||
render(<DemoCredentialsModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText('Admin User (Superuser)')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin123!')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Full admin panel access/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies regular user credentials to clipboard', async () => {
|
||||
render(<DemoCredentialsModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const copyButtons = screen.getAllByRole('button');
|
||||
const regularCopyButton = copyButtons.find(btn => btn.textContent?.includes('Copy'));
|
||||
|
||||
fireEvent.click(regularCopyButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('demo@example.com\nDemo123!');
|
||||
const copiedButtons = screen.getAllByRole('button');
|
||||
const copiedButton = copiedButtons.find(btn => btn.textContent?.includes('Copied!'));
|
||||
expect(copiedButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('copies admin user credentials to clipboard', async () => {
|
||||
render(<DemoCredentialsModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const copyButtons = screen.getAllByRole('button');
|
||||
const adminCopyButton = copyButtons.filter(btn => btn.textContent?.includes('Copy'))[1];
|
||||
|
||||
fireEvent.click(adminCopyButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('admin@example.com\nAdmin123!');
|
||||
const copiedButtons = screen.getAllByRole('button');
|
||||
const copiedButton = copiedButtons.find(btn => btn.textContent?.includes('Copied!'));
|
||||
expect(copiedButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets copied state after 2 seconds', async () => {
|
||||
jest.useFakeTimers();
|
||||
render(<DemoCredentialsModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const copyButtons = screen.getAllByRole('button');
|
||||
const copyButton = copyButtons.find(btn => btn.textContent?.includes('Copy'));
|
||||
fireEvent.click(copyButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
const copiedButtons = screen.getAllByRole('button');
|
||||
const copiedButton = copiedButtons.find(btn => btn.textContent?.includes('Copied!'));
|
||||
expect(copiedButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const copiedButton = buttons.find(btn => btn.textContent?.includes('Copied!'));
|
||||
expect(copiedButton).toBeUndefined();
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('handles clipboard copy failure gracefully', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn(() => Promise.reject(new Error('Clipboard error'))),
|
||||
},
|
||||
});
|
||||
|
||||
render(<DemoCredentialsModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const copyButtons = screen.getAllByRole('button');
|
||||
const copyButton = copyButtons.find(btn => btn.textContent?.includes('Copy'));
|
||||
fireEvent.click(copyButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to copy:', expect.any(Error));
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
render(<DemoCredentialsModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
// Find the "Close" button (filter to get the one that's visible and is the footer button)
|
||||
const closeButtons = screen.getAllByRole('button', { name: 'Close' });
|
||||
const footerCloseButton = closeButtons.find(btn =>
|
||||
btn.textContent === 'Close' && !btn.querySelector('.sr-only')
|
||||
);
|
||||
fireEvent.click(footerCloseButton!);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has a link to login page', () => {
|
||||
render(<DemoCredentialsModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const loginLink = screen.getByRole('link', { name: /go to login/i });
|
||||
expect(loginLink).toHaveAttribute('href', '/login');
|
||||
});
|
||||
|
||||
it('calls onClose when login link is clicked', () => {
|
||||
render(<DemoCredentialsModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const loginLink = screen.getByRole('link', { name: /go to login/i });
|
||||
fireEvent.click(loginLink);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
152
frontend/tests/components/home/Header.test.tsx
Normal file
152
frontend/tests/components/home/Header.test.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Tests for Header component
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Header } from '@/components/home/Header';
|
||||
|
||||
// Mock Next.js Link
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, href, ...props }: any) => {
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock DemoCredentialsModal
|
||||
jest.mock('@/components/home/DemoCredentialsModal', () => ({
|
||||
DemoCredentialsModal: ({ open, onClose }: any) => (
|
||||
open ? <div data-testid="demo-modal">
|
||||
<button onClick={onClose}>Close Modal</button>
|
||||
</div> : null
|
||||
),
|
||||
}));
|
||||
|
||||
describe('Header', () => {
|
||||
it('renders logo', () => {
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.getByText('FastNext')).toBeInTheDocument();
|
||||
expect(screen.getByText('Template')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('logo links to homepage', () => {
|
||||
render(<Header />);
|
||||
|
||||
const logoLink = screen.getByRole('link', { name: /fastnext template/i });
|
||||
expect(logoLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
describe('Desktop Navigation', () => {
|
||||
it('renders navigation links', () => {
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Components' })).toHaveAttribute('href', '/dev');
|
||||
expect(screen.getByRole('link', { name: 'Admin Demo' })).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
|
||||
it('renders GitHub link with star badge', () => {
|
||||
render(<Header />);
|
||||
|
||||
const githubLinks = screen.getAllByRole('link', { name: /github/i });
|
||||
const desktopGithubLink = githubLinks.find(link =>
|
||||
link.getAttribute('href')?.includes('github.com')
|
||||
);
|
||||
|
||||
expect(desktopGithubLink).toHaveAttribute('href', 'https://github.com/your-org/fast-next-template');
|
||||
expect(desktopGithubLink).toHaveAttribute('target', '_blank');
|
||||
expect(desktopGithubLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('renders Try Demo button', () => {
|
||||
render(<Header />);
|
||||
|
||||
const demoButton = screen.getByRole('button', { name: /try demo/i });
|
||||
expect(demoButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Login button', () => {
|
||||
render(<Header />);
|
||||
|
||||
const loginLinks = screen.getAllByRole('link', { name: /login/i });
|
||||
expect(loginLinks.length).toBeGreaterThan(0);
|
||||
expect(loginLinks[0]).toHaveAttribute('href', '/login');
|
||||
});
|
||||
|
||||
it('opens demo modal when Try Demo button is clicked', () => {
|
||||
render(<Header />);
|
||||
|
||||
const demoButton = screen.getByRole('button', { name: /try demo/i });
|
||||
fireEvent.click(demoButton);
|
||||
|
||||
expect(screen.getByTestId('demo-modal')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mobile Menu', () => {
|
||||
it('renders mobile menu toggle button', () => {
|
||||
render(<Header />);
|
||||
|
||||
// SheetTrigger wraps the button, so we need to find it by aria-label
|
||||
const menuButton = screen.getByRole('button', { name: /toggle menu/i });
|
||||
expect(menuButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mobile menu contains navigation links', () => {
|
||||
render(<Header />);
|
||||
|
||||
// Note: SheetContent is hidden by default in tests, but we can verify the links exist
|
||||
// The actual mobile menu behavior is tested in E2E tests
|
||||
const componentsLinks = screen.getAllByRole('link', { name: /components/i });
|
||||
expect(componentsLinks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('mobile menu contains GitHub link', () => {
|
||||
render(<Header />);
|
||||
|
||||
const githubLinks = screen.getAllByRole('link', { name: /github/i });
|
||||
expect(githubLinks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Demo Modal Integration', () => {
|
||||
it('closes demo modal when close is called', () => {
|
||||
render(<Header />);
|
||||
|
||||
// Open modal
|
||||
const demoButton = screen.getByRole('button', { name: /try demo/i });
|
||||
fireEvent.click(demoButton);
|
||||
expect(screen.getByTestId('demo-modal')).toBeInTheDocument();
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByText('Close Modal');
|
||||
fireEvent.click(closeButton);
|
||||
expect(screen.queryByTestId('demo-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has proper ARIA labels for icon buttons', () => {
|
||||
render(<Header />);
|
||||
|
||||
const menuButton = screen.getByRole('button', { name: /toggle menu/i });
|
||||
expect(menuButton).toHaveAccessibleName();
|
||||
});
|
||||
|
||||
it('has proper external link attributes', () => {
|
||||
render(<Header />);
|
||||
|
||||
const githubLinks = screen.getAllByRole('link', { name: /github/i });
|
||||
const externalLink = githubLinks.find(link =>
|
||||
link.getAttribute('href')?.includes('github.com')
|
||||
);
|
||||
|
||||
expect(externalLink).toHaveAttribute('target', '_blank');
|
||||
expect(externalLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
});
|
||||
137
frontend/tests/components/home/HeroSection.test.tsx
Normal file
137
frontend/tests/components/home/HeroSection.test.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Tests for HeroSection component
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { HeroSection } from '@/components/home/HeroSection';
|
||||
|
||||
// Mock framer-motion
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
h1: ({ children, ...props }: any) => <h1 {...props}>{children}</h1>,
|
||||
p: ({ children, ...props }: any) => <p {...props}>{children}</p>,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Next.js Link
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, href, ...props }: any) => {
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock DemoCredentialsModal
|
||||
jest.mock('@/components/home/DemoCredentialsModal', () => ({
|
||||
DemoCredentialsModal: ({ open, onClose }: any) => (
|
||||
open ? <div data-testid="demo-modal">
|
||||
<button onClick={onClose}>Close Modal</button>
|
||||
</div> : null
|
||||
),
|
||||
}));
|
||||
|
||||
describe('HeroSection', () => {
|
||||
it('renders badge with key highlights', () => {
|
||||
render(<HeroSection />);
|
||||
|
||||
expect(screen.getByText('MIT Licensed')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('97% Test Coverage')[0]).toBeInTheDocument();
|
||||
expect(screen.getByText('Production Ready')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders main headline', () => {
|
||||
render(<HeroSection />);
|
||||
|
||||
expect(screen.getAllByText(/Everything You Need to Build/i)[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Modern Web Applications/i)[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders subheadline with key messaging', () => {
|
||||
render(<HeroSection />);
|
||||
|
||||
expect(screen.getByText(/Production-ready FastAPI \+ Next.js template/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Start building features on day one/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Try Live Demo button', () => {
|
||||
render(<HeroSection />);
|
||||
|
||||
const demoButton = screen.getByRole('button', { name: /try live demo/i });
|
||||
expect(demoButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders View on GitHub link', () => {
|
||||
render(<HeroSection />);
|
||||
|
||||
const githubLink = screen.getByRole('link', { name: /view on github/i });
|
||||
expect(githubLink).toHaveAttribute('href', 'https://github.com/your-org/fast-next-template');
|
||||
expect(githubLink).toHaveAttribute('target', '_blank');
|
||||
expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('renders Explore Components link', () => {
|
||||
render(<HeroSection />);
|
||||
|
||||
const componentsLink = screen.getByRole('link', { name: /explore components/i });
|
||||
expect(componentsLink).toHaveAttribute('href', '/dev');
|
||||
});
|
||||
|
||||
it('displays test coverage stats', () => {
|
||||
render(<HeroSection />);
|
||||
|
||||
const coverageTexts = screen.getAllByText('97%');
|
||||
expect(coverageTexts.length).toBeGreaterThan(0);
|
||||
|
||||
const testCountTexts = screen.getAllByText('743');
|
||||
expect(testCountTexts.length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Passing Tests/i)[0]).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Flaky Tests/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens demo modal when Try Live Demo button is clicked', () => {
|
||||
render(<HeroSection />);
|
||||
|
||||
const demoButton = screen.getByRole('button', { name: /try live demo/i });
|
||||
fireEvent.click(demoButton);
|
||||
|
||||
expect(screen.getByTestId('demo-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes demo modal when close is called', () => {
|
||||
render(<HeroSection />);
|
||||
|
||||
// Open modal
|
||||
const demoButton = screen.getByRole('button', { name: /try live demo/i });
|
||||
fireEvent.click(demoButton);
|
||||
expect(screen.getByTestId('demo-modal')).toBeInTheDocument();
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByText('Close Modal');
|
||||
fireEvent.click(closeButton);
|
||||
expect(screen.queryByTestId('demo-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has proper heading hierarchy', () => {
|
||||
render(<HeroSection />);
|
||||
|
||||
const heading = screen.getAllByRole('heading', { level: 1 })[0];
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has proper external link attributes', () => {
|
||||
render(<HeroSection />);
|
||||
|
||||
const githubLink = screen.getByRole('link', { name: /view on github/i });
|
||||
expect(githubLink).toHaveAttribute('target', '_blank');
|
||||
expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
});
|
||||
128
frontend/tests/components/home/QuickStartCode.test.tsx
Normal file
128
frontend/tests/components/home/QuickStartCode.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tests for QuickStartCode component
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QuickStartCode } from '@/components/home/QuickStartCode';
|
||||
|
||||
// Mock framer-motion
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-syntax-highlighter
|
||||
jest.mock('react-syntax-highlighter', () => ({
|
||||
Prism: ({ children, ...props }: any) => <pre {...props}>{children}</pre>,
|
||||
}));
|
||||
|
||||
jest.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
|
||||
vscDarkPlus: {},
|
||||
}));
|
||||
|
||||
describe('QuickStartCode', () => {
|
||||
beforeEach(() => {
|
||||
// Mock clipboard API
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn(() => Promise.resolve()),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the section heading', () => {
|
||||
render(<QuickStartCode />);
|
||||
|
||||
expect(screen.getByText('5-Minute Setup')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Clone, run, and start building/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bash indicator', () => {
|
||||
render(<QuickStartCode />);
|
||||
|
||||
expect(screen.getByText('bash')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders copy button', () => {
|
||||
render(<QuickStartCode />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy/i });
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the code snippet', () => {
|
||||
render(<QuickStartCode />);
|
||||
|
||||
const codeBlock = screen.getByText(/git clone/i);
|
||||
expect(codeBlock).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies code to clipboard when copy button is clicked', async () => {
|
||||
render(<QuickStartCode />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy/i });
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const clipboardContent = (navigator.clipboard.writeText as jest.Mock).mock.calls[0][0];
|
||||
expect(clipboardContent).toContain('git clone');
|
||||
expect(clipboardContent).toContain('docker-compose up');
|
||||
expect(clipboardContent).toContain('pip install -r requirements.txt');
|
||||
});
|
||||
|
||||
it('shows "Copied!" message after copying', async () => {
|
||||
render(<QuickStartCode />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy/i });
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets copied state after 2 seconds', async () => {
|
||||
jest.useFakeTimers();
|
||||
render(<QuickStartCode />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy/i });
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Copied!')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Copy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('handles clipboard copy failure gracefully', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn(() => Promise.reject(new Error('Clipboard error'))),
|
||||
},
|
||||
});
|
||||
|
||||
render(<QuickStartCode />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy/i });
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to copy:', expect.any(Error));
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
115
frontend/tests/components/home/StatsSection.test.tsx
Normal file
115
frontend/tests/components/home/StatsSection.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Tests for StatsSection component
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { StatsSection } from '@/components/home/StatsSection';
|
||||
|
||||
// Mock framer-motion
|
||||
jest.mock('framer-motion', () => {
|
||||
const React = require('react');
|
||||
return {
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
},
|
||||
useInView: () => true, // Always in view for tests
|
||||
};
|
||||
});
|
||||
|
||||
describe('StatsSection', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders section heading', () => {
|
||||
render(<StatsSection />);
|
||||
|
||||
expect(screen.getByText('Built with Quality in Mind')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Not just another template/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all stat cards', () => {
|
||||
render(<StatsSection />);
|
||||
|
||||
expect(screen.getByText('Test Coverage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Passing Tests')).toBeInTheDocument();
|
||||
expect(screen.getByText('Flaky Tests')).toBeInTheDocument();
|
||||
expect(screen.getByText('API Endpoints')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays stat descriptions', () => {
|
||||
render(<StatsSection />);
|
||||
|
||||
expect(screen.getByText(/Comprehensive testing across backend and frontend/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Backend, frontend unit, and E2E tests/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Production-stable test suite/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Fully documented with OpenAPI/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders animated counters with correct suffixes', () => {
|
||||
render(<StatsSection />);
|
||||
|
||||
// Counters start at 0, so we should see 0 initially
|
||||
const counters = screen.getAllByText(/^[0-9]+[%+]?$/);
|
||||
expect(counters.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('animates counters when in view', () => {
|
||||
render(<StatsSection />);
|
||||
|
||||
// The useInView mock returns true, so animation should start
|
||||
// Advance timers to let the counter animation run
|
||||
jest.advanceTimersByTime(2000);
|
||||
|
||||
// After animation, we should see the final values
|
||||
// The component should eventually show the stat values
|
||||
const statsSection = screen.getByText('Test Coverage').parentElement;
|
||||
expect(statsSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays icons for each stat', () => {
|
||||
render(<StatsSection />);
|
||||
|
||||
// Icons are rendered via lucide-react components
|
||||
// We can verify the stat cards are rendered with proper structure
|
||||
const testCoverageCard = screen.getByText('Test Coverage').closest('div');
|
||||
expect(testCoverageCard).toBeInTheDocument();
|
||||
|
||||
const passingTestsCard = screen.getByText('Passing Tests').closest('div');
|
||||
expect(passingTestsCard).toBeInTheDocument();
|
||||
|
||||
const flakyTestsCard = screen.getByText('Flaky Tests').closest('div');
|
||||
expect(flakyTestsCard).toBeInTheDocument();
|
||||
|
||||
const apiEndpointsCard = screen.getByText('API Endpoints').closest('div');
|
||||
expect(apiEndpointsCard).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has proper heading hierarchy', () => {
|
||||
render(<StatsSection />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /built with quality in mind/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has descriptive labels for stats', () => {
|
||||
render(<StatsSection />);
|
||||
|
||||
const statLabels = [
|
||||
'Test Coverage',
|
||||
'Passing Tests',
|
||||
'Flaky Tests',
|
||||
'API Endpoints',
|
||||
];
|
||||
|
||||
statLabels.forEach(label => {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
178
frontend/tests/hooks/usePrefersReducedMotion.test.ts
Normal file
178
frontend/tests/hooks/usePrefersReducedMotion.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Tests for usePrefersReducedMotion hook
|
||||
* Tests media query detection for accessibility preferences
|
||||
*/
|
||||
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion';
|
||||
|
||||
describe('usePrefersReducedMotion', () => {
|
||||
let mockMatchMedia: jest.Mock;
|
||||
let mockListeners: ((event: MediaQueryListEvent) => void)[];
|
||||
|
||||
beforeEach(() => {
|
||||
mockListeners = [];
|
||||
|
||||
mockMatchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => {
|
||||
if (event === 'change') {
|
||||
mockListeners.push(listener);
|
||||
}
|
||||
}),
|
||||
removeEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => {
|
||||
if (event === 'change') {
|
||||
const index = mockListeners.indexOf(listener);
|
||||
if (index > -1) {
|
||||
mockListeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: mockMatchMedia,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns false when user does not prefer reduced motion', () => {
|
||||
mockMatchMedia.mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => usePrefersReducedMotion());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
expect(mockMatchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)');
|
||||
});
|
||||
|
||||
it('returns true when user prefers reduced motion', () => {
|
||||
mockMatchMedia.mockImplementation((query: string) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => usePrefersReducedMotion());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('updates when media query preference changes to true', () => {
|
||||
const mockMediaQuery = {
|
||||
matches: false,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => {
|
||||
if (event === 'change') {
|
||||
mockListeners.push(listener);
|
||||
}
|
||||
}),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
};
|
||||
|
||||
mockMatchMedia.mockReturnValue(mockMediaQuery);
|
||||
|
||||
const { result } = renderHook(() => usePrefersReducedMotion());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Simulate media query change
|
||||
act(() => {
|
||||
mockListeners.forEach(listener => {
|
||||
listener({ matches: true } as MediaQueryListEvent);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('updates when media query preference changes to false', () => {
|
||||
const mockMediaQuery = {
|
||||
matches: true,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => {
|
||||
if (event === 'change') {
|
||||
mockListeners.push(listener);
|
||||
}
|
||||
}),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
};
|
||||
|
||||
mockMatchMedia.mockReturnValue(mockMediaQuery);
|
||||
|
||||
const { result } = renderHook(() => usePrefersReducedMotion());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Simulate media query change
|
||||
act(() => {
|
||||
mockListeners.forEach(listener => {
|
||||
listener({ matches: false } as MediaQueryListEvent);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('cleans up event listener on unmount', () => {
|
||||
const removeEventListenerSpy = jest.fn();
|
||||
|
||||
const mockMediaQuery = {
|
||||
matches: false,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => {
|
||||
if (event === 'change') {
|
||||
mockListeners.push(listener);
|
||||
}
|
||||
}),
|
||||
removeEventListener: removeEventListenerSpy,
|
||||
dispatchEvent: jest.fn(),
|
||||
};
|
||||
|
||||
mockMatchMedia.mockReturnValue(mockMediaQuery);
|
||||
|
||||
const { unmount } = renderHook(() => usePrefersReducedMotion());
|
||||
|
||||
expect(mockMediaQuery.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
});
|
||||
|
||||
it('handles SSR environment safely', () => {
|
||||
const originalWindow = global.window;
|
||||
|
||||
// @ts-ignore - Simulating SSR
|
||||
delete global.window;
|
||||
|
||||
const { result } = renderHook(() => usePrefersReducedMotion());
|
||||
|
||||
// Should return false in SSR environment
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Restore window
|
||||
global.window = originalWindow;
|
||||
});
|
||||
});
|
||||
117
frontend/tests/lib/chart-colors.test.ts
Normal file
117
frontend/tests/lib/chart-colors.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Tests for chart-colors utility
|
||||
* Tests color configuration and helper functions for data visualization
|
||||
*/
|
||||
|
||||
import { withOpacity, CHART_COLORS, CHART_PALETTES, CHART_GRADIENTS } from '@/lib/chart-colors';
|
||||
|
||||
describe('chart-colors', () => {
|
||||
describe('withOpacity', () => {
|
||||
it('converts opacity 0 to hex 00', () => {
|
||||
const result = withOpacity('#3b82f6', 0);
|
||||
expect(result).toBe('#3b82f600');
|
||||
});
|
||||
|
||||
it('converts opacity 1 to hex ff', () => {
|
||||
const result = withOpacity('#3b82f6', 1);
|
||||
expect(result).toBe('#3b82f6ff');
|
||||
});
|
||||
|
||||
it('converts opacity 0.5 to hex 80', () => {
|
||||
const result = withOpacity('#3b82f6', 0.5);
|
||||
expect(result).toBe('#3b82f680');
|
||||
});
|
||||
|
||||
it('converts opacity 0.25 to hex 40', () => {
|
||||
const result = withOpacity('#3b82f6', 0.25);
|
||||
expect(result).toBe('#3b82f640');
|
||||
});
|
||||
|
||||
it('converts opacity 0.75 to hex bf', () => {
|
||||
const result = withOpacity('#3b82f6', 0.75);
|
||||
expect(result).toBe('#3b82f6bf');
|
||||
});
|
||||
|
||||
it('pads single digit hex values with zero', () => {
|
||||
const result = withOpacity('#3b82f6', 0.01);
|
||||
expect(result).toBe('#3b82f603');
|
||||
});
|
||||
|
||||
it('works with 3-digit hex colors', () => {
|
||||
const result = withOpacity('#fff', 0.5);
|
||||
expect(result).toBe('#fff80');
|
||||
});
|
||||
|
||||
it('works with uppercase hex colors', () => {
|
||||
const result = withOpacity('#3B82F6', 0.5);
|
||||
expect(result).toBe('#3B82F680');
|
||||
});
|
||||
|
||||
it('handles edge case of very small opacity', () => {
|
||||
const result = withOpacity('#000000', 0.004);
|
||||
expect(result).toBe('#00000001');
|
||||
});
|
||||
|
||||
it('handles edge case of very high opacity', () => {
|
||||
const result = withOpacity('#ffffff', 0.996);
|
||||
expect(result).toBe('#fffffffe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHART_COLORS', () => {
|
||||
it('exports primary color palette', () => {
|
||||
expect(CHART_COLORS.primary).toBe('#3b82f6');
|
||||
expect(CHART_COLORS.primaryLight).toBe('#60a5fa');
|
||||
expect(CHART_COLORS.primaryDark).toBe('#2563eb');
|
||||
});
|
||||
|
||||
it('exports accent colors', () => {
|
||||
expect(CHART_COLORS.accent1).toBeDefined();
|
||||
expect(CHART_COLORS.accent2).toBeDefined();
|
||||
expect(CHART_COLORS.accent3).toBeDefined();
|
||||
expect(CHART_COLORS.accent4).toBeDefined();
|
||||
expect(CHART_COLORS.accent5).toBeDefined();
|
||||
});
|
||||
|
||||
it('exports status colors', () => {
|
||||
expect(CHART_COLORS.success).toBeDefined();
|
||||
expect(CHART_COLORS.warning).toBeDefined();
|
||||
expect(CHART_COLORS.error).toBeDefined();
|
||||
expect(CHART_COLORS.info).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHART_PALETTES', () => {
|
||||
it('exports line chart palette', () => {
|
||||
expect(CHART_PALETTES.line).toHaveLength(2);
|
||||
expect(CHART_PALETTES.line).toContain(CHART_COLORS.primary);
|
||||
expect(CHART_PALETTES.line).toContain(CHART_COLORS.accent1);
|
||||
});
|
||||
|
||||
it('exports bar chart palette', () => {
|
||||
expect(CHART_PALETTES.bar).toHaveLength(2);
|
||||
expect(CHART_PALETTES.bar).toContain(CHART_COLORS.primary);
|
||||
expect(CHART_PALETTES.bar).toContain(CHART_COLORS.accent2);
|
||||
});
|
||||
|
||||
it('exports pie chart palette with 4 colors', () => {
|
||||
expect(CHART_PALETTES.pie).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('exports multi-series palette with 6 colors', () => {
|
||||
expect(CHART_PALETTES.multi).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHART_GRADIENTS', () => {
|
||||
it('exports primary gradient definition', () => {
|
||||
expect(CHART_GRADIENTS.primary.start).toBe(CHART_COLORS.primary);
|
||||
expect(CHART_GRADIENTS.primary.end).toMatch(/#3b82f6[a-f0-9]{2}/);
|
||||
});
|
||||
|
||||
it('exports accent gradient definition', () => {
|
||||
expect(CHART_GRADIENTS.accent.start).toBe(CHART_COLORS.accent1);
|
||||
expect(CHART_GRADIENTS.accent.end).toMatch(/#8b5cf6[a-f0-9]{2}/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user