diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css index 5aa051e1a..877b98d65 100644 --- a/packages/frontend/src/index.css +++ b/packages/frontend/src/index.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Sora:wght@400;500;600;700&family=Manrope:wght@400;500;600;700&display=swap'); @import 'tailwindcss'; @@ -54,18 +54,23 @@ --color-sidebar-border: hsl(var(--sidebar-border)); --color-sidebar-ring: hsl(var(--sidebar-ring)); - /* Discord blurple accent palette */ + /* Neon Lucky brand palette (neon pink/orange) */ + --color-lucky-neon-pink: #ec4899; + --color-lucky-neon-orange: #fb923c; + --color-lucky-neon-purple: #8b5cf6; + + /* Discord blurple accent palette (legacy) */ --color-lucky-blurple-700: #4752c4; --color-lucky-blurple-600: #5865f2; --color-lucky-blurple-500: #6d7af4; --color-lucky-blurple-400: #8891f6; --color-lucky-blurple-200: #c0c5fb; - /* Semantic brand tokens (for bg-lucky-brand, text-lucky-brand, etc.) */ - --color-lucky-brand: #5865f2; - --color-lucky-brand-strong: #4752c4; - --color-lucky-accent: #5865f2; - --color-lucky-accent-soft: #6d7af4; + /* Semantic brand tokens (now using neon pink) */ + --color-lucky-brand: #ec4899; + --color-lucky-brand-strong: #db2777; + --color-lucky-accent: #fb923c; + --color-lucky-accent-soft: #fdba74; /* Status colors */ --color-lucky-success: #23a55a; @@ -118,8 +123,8 @@ --color-lucky-blue: #5865f2; --color-lucky-purple: #5865f2; - --font-lucky-display: 'Inter', 'Segoe UI', system-ui, sans-serif; - --font-lucky-body: 'Inter', 'Segoe UI', system-ui, sans-serif; + --font-lucky-display: 'Sora', 'Segoe UI', system-ui, sans-serif; + --font-lucky-body: 'Manrope', 'Segoe UI', system-ui, sans-serif; --font-lucky-mono: 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; --font-sans: var(--font-lucky-body); @@ -372,8 +377,8 @@ --lucky-warning: var(--color-lucky-warning); --lucky-info: var(--color-lucky-info); - --lucky-gradient-brand: linear-gradient(135deg, #5865f2 0%, #4752c4 100%); - --lucky-gradient-brand-soft: linear-gradient(135deg, rgb(88 101 242 / 0.15) 0%, rgb(71 82 196 / 0.05) 100%); + --lucky-gradient-brand: linear-gradient(135deg, #ec4899 0%, #fb923c 100%); + --lucky-gradient-brand-soft: linear-gradient(135deg, rgb(236 72 153 / 0.15) 0%, rgb(251 146 60 / 0.05) 100%); --lucky-red: var(--lucky-brand); --lucky-bg-primary: var(--lucky-surface-canvas); diff --git a/packages/frontend/src/pages/Landing.test.tsx b/packages/frontend/src/pages/Landing.test.tsx index b0299409c..a525f043b 100644 --- a/packages/frontend/src/pages/Landing.test.tsx +++ b/packages/frontend/src/pages/Landing.test.tsx @@ -1,5 +1,5 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import Landing from './Landing' import { useAuthStore } from '@/stores/authStore' @@ -9,7 +9,21 @@ import { api } from '@/services/api' vi.mock('@/stores/authStore') vi.mock('@/hooks/usePageMetadata') -vi.mock('framer-motion') +vi.mock('framer-motion', async () => { + const React = await import('react') + const passthrough = (tag: string) => + React.forwardRef(({ children, ...props }: any, ref: any) => + React.createElement(tag, { ...props, ref }, children), + ) + return { + motion: new Proxy( + {}, + { get: (_t, prop: string) => passthrough(prop) }, + ), + AnimatePresence: ({ children }: any) => children, + useReducedMotion: vi.fn(() => false), + } +}) vi.mock('@/services/api') const mockLogin = vi.fn() @@ -49,20 +63,27 @@ describe('Landing', () => { render() - const container = screen.getByText(/The all-in-one Discord bot for your community/i) + const container = screen.getByText(/All-in-one Discord bot/i) .closest('.lucky-shell') expect(container).toBeInTheDocument() }) - test('renders hero section with headline and CTA buttons', () => { + test('renders hero section with logo, headline and CTA buttons', () => { render() - const headline = screen.getByRole('heading', { - name: /The all-in-one Discord bot for your community/i, - }) + // Check for logo + const logo = screen.getByAltText('Lucky Bot') + expect(logo).toBeInTheDocument() + + // Check for headline + const headline = screen.getByText(/All-in-one Discord bot/i) expect(headline).toBeInTheDocument() + const subheadline = screen.getByText(/for your community/i) + expect(subheadline).toBeInTheDocument() + + // Check for CTA buttons const addToDiscordBtn = screen.getByRole('button', { name: /Add to Discord/i }) const openDashboardBtn = screen.getByRole('button', { name: /Open Dashboard/i }) @@ -70,12 +91,12 @@ describe('Landing', () => { expect(openDashboardBtn).toBeInTheDocument() }) - test('displays hero subtitle', () => { + test('displays hero subtitle with "Built for vibes" tagline', () => { render() expect( screen.getByText( - /Music, moderation, custom commands, auto-mod, and a full web dashboard./i + /Music, moderation, custom commands, auto-mod, and a full web dashboard\. Built for vibes\./i ) ).toBeInTheDocument() }) @@ -97,7 +118,7 @@ describe('Landing', () => { }) }) - test('renders stats strip with live data', async () => { + test('renders stats strip with live data and icons', async () => { render() // Wait for the stats labels to be rendered @@ -129,26 +150,32 @@ describe('Landing', () => { }) }) - test('FAQ accordion contains expandable details elements', () => { + test('FAQ items are expandable via button clicks', async () => { render() - const faqSection = screen.getByText(/Frequently Asked Questions/) - expect(faqSection).toBeInTheDocument() + const user = userEvent.setup() + // Get the first FAQ button const firstQuestion = screen.getByText('Is Lucky free?') - const detailsElement = firstQuestion.closest('details') + const firstButton = firstQuestion.closest('button') - expect(detailsElement).toBeInTheDocument() - expect(detailsElement).toHaveClass('rounded-lg', 'border', 'border-lucky-border') + expect(firstButton).toBeInTheDocument() + // Click to expand + await user.click(firstButton!) + + // Answer should be visible const answer = screen.getByText('Yes, Lucky is completely free with no premium tier.') expect(answer).toBeInTheDocument() }) - test('renders footer with all link sections', () => { + test('renders footer with logo and all link sections', () => { render() - expect(screen.getByText('Lucky')).toBeInTheDocument() + // Check for footer logo + const footerLogo = screen.getAllByAltText('Lucky').find(el => el.closest('footer')) + expect(footerLogo).toBeInTheDocument() + expect(screen.getByText('Links')).toBeInTheDocument() expect(screen.getByText('Support')).toBeInTheDocument() }) @@ -177,18 +204,16 @@ describe('Landing', () => { }) test('renders Add to Discord button with correct href when clicked', async () => { + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) render() const addToDiscordBtn = screen.getByRole('button', { name: /Add to Discord/i }) expect(addToDiscordBtn).toBeInTheDocument() - vi.spyOn(window, 'open').mockReturnValue(null) - - const user = userEvent.setup() - await user.click(addToDiscordBtn) + fireEvent.click(addToDiscordBtn) await waitFor(() => { - expect(window.open).toHaveBeenCalledWith( + expect(openSpy).toHaveBeenCalledWith( expect.stringContaining('discord.com/oauth2/authorize'), '_blank' ) @@ -199,9 +224,7 @@ describe('Landing', () => { render() const openDashboardBtn = screen.getByRole('button', { name: /Open Dashboard/i }) - - const user = userEvent.setup() - await user.click(openDashboardBtn) + fireEvent.click(openDashboardBtn) await waitFor(() => { expect(mockLogin).toHaveBeenCalled() @@ -242,10 +265,24 @@ describe('Landing', () => { test('renders all footer columns with proper hierarchy', () => { render() - const footerText = screen.getByText('Discord bot with music, moderation, and more.') + const footerText = screen.getByText('Discord bot with music, moderation, and more. Built for vibes.') expect(footerText).toBeInTheDocument() const needHelpText = screen.getByText(/Need help\? Join our Discord community\./) expect(needHelpText).toBeInTheDocument() }) + + test('renders Powerful Features heading', () => { + render() + + const featuresHeading = screen.getByRole('heading', { name: /Powerful Features/i }) + expect(featuresHeading).toBeInTheDocument() + }) + + test('renders Frequently Asked Questions heading', () => { + render() + + const faqHeading = screen.getByRole('heading', { name: /Frequently Asked Questions/i }) + expect(faqHeading).toBeInTheDocument() + }) }) diff --git a/packages/frontend/src/pages/Landing.tsx b/packages/frontend/src/pages/Landing.tsx index 0455f2df1..573d8e86c 100644 --- a/packages/frontend/src/pages/Landing.tsx +++ b/packages/frontend/src/pages/Landing.tsx @@ -3,14 +3,15 @@ import { useAuthStore } from '@/stores/authStore' import { usePageMetadata } from '@/hooks/usePageMetadata' import { useCountUp } from '@/hooks/useCountUp' import Button from '@/components/ui/Button' -import { useReducedMotion } from 'framer-motion' +import { useReducedMotion, motion } from 'framer-motion' import { api } from '@/services/api' +import { Music, Shield, Zap, BarChart3, Palette, Sparkles, ChevronDown, Server, Users, Radio } from 'lucide-react' const CLIENT_ID = '962198089161134131' const BOT_INVITE_URL = `https://discord.com/oauth2/authorize?client_id=${CLIENT_ID}&scope=bot%20applications.commands&permissions=8` -const HERO_GRADIENT = 'bg-gradient-to-br from-indigo-950 via-purple-950 to-slate-950' -const HERO_GRADIENT_ANIMATED = `${HERO_GRADIENT} bg-[length:200%_200%] animate-[gradient_15s_ease_infinite]` +// Neon glow backdrop gradients +const NEON_BACKDROP = 'bg-gradient-to-br from-slate-950 via-purple-950 to-slate-950' export default function Landing() { const login = useAuthStore((state) => state.login) @@ -52,193 +53,368 @@ export default function Landing() { description: 'The all-in-one Discord bot for your community. Music, moderation, custom commands, auto-mod, and a full web dashboard. Free forever.', }) - const containerClass = prefersReducedMotion ? HERO_GRADIENT : HERO_GRADIENT_ANIMATED + const logoAnimation = prefersReducedMotion + ? {} + : { + animate: { scale: [1, 1.03, 1] }, + transition: { duration: 3, repeat: Infinity, ease: 'easeInOut' }, + } return (
- {/* Hero */} -
-
-
-

- The all-in-one Discord bot for your community -

-

- Music, moderation, custom commands, auto-mod, and a full web dashboard. Free forever. -

-
- -
- - -
+ {/* Hero Section with Neon Logo */} + + + {/* Feature Grid */} + + + {/* Stats Strip */} + + + {/* FAQ */} + + + {/* Footer */} + +
+ ) +} + +// Hero Section with Animated Logo +function HeroSection({ logoAnimation, onLogin }: { logoAnimation: any; onLogin: () => void }) { + const prefersReducedMotion = useReducedMotion() + + return ( +
+ {/* Animated neon glow blobs */} + {!prefersReducedMotion && ( + <> +
+
+ + )} + + + {/* Logo */} + + Lucky Bot + + + {/* Headline with gradient text */} +
+

+ All-in-one Discord bot + for your community +

+

+ Music, moderation, custom commands, auto-mod, and a full web dashboard. Built for vibes. +

-
- - {/* Feature Grid (Phase 3) */} -
-
-
-

Powerful Features

-

Everything you need to manage your Discord server

-
- -
- {/* Music */} -
-
🎵
-

Music

-

Spotify-powered autoplay with genre matching

-
- {/* Auto-mod */} -
-
🛡️
-

Auto-mod

-

Spam/caps/link filters with per-guild rules

-
+ {/* CTA Buttons */} + + + + + +
+ ) +} - {/* Custom Commands */} -
-
⚙️
-

Custom Commands

-

User-defined responses with variable interpolation

-
+// Feature Grid with Neon Icon Orbs +function FeatureSection() { + const prefersReducedMotion = useReducedMotion() - {/* Web Dashboard */} -
-
📊
-

Web Dashboard

-

Full control from your browser

-
+ const features = [ + { + icon: Music, + title: 'Music', + desc: 'Spotify-powered autoplay with genre matching', + color: 'from-pink-500/20 to-pink-600/10', + iconColor: 'text-pink-400', + }, + { + icon: Shield, + title: 'Auto-mod', + desc: 'Spam/caps/link filters with per-guild rules', + color: 'from-orange-500/20 to-orange-600/10', + iconColor: 'text-orange-400', + }, + { + icon: Zap, + title: 'Custom Commands', + desc: 'User-defined responses with variable interpolation', + color: 'from-purple-500/20 to-purple-600/10', + iconColor: 'text-purple-400', + }, + { + icon: BarChart3, + title: 'Web Dashboard', + desc: 'Full control from your browser', + color: 'from-blue-500/20 to-blue-600/10', + iconColor: 'text-blue-400', + }, + { + icon: Palette, + title: 'Embed Builder', + desc: 'Rich embeds without JSON', + color: 'from-cyan-500/20 to-cyan-600/10', + iconColor: 'text-cyan-400', + }, + { + icon: Sparkles, + title: 'Artist Preferences', + desc: 'Tailor autoplay to your taste', + color: 'from-amber-500/20 to-amber-600/10', + iconColor: 'text-amber-400', + }, + ] - {/* Embed Builder */} -
-
🎨
-

Embed Builder

-

Rich embeds without JSON

-
+ return ( +
+ {/* Subtle grid pattern background */} +
- {/* Artist Preferences */} -
-
⭐
-

Artist Preferences

-

Tailor autoplay to your taste

-
-
+
+ +

Powerful Features

+

Everything you need to manage your Discord server

+
+ +
+ {features.map((feature, idx) => { + const Icon = feature.icon + return ( + +
+ +
+
+

{feature.title}

+

{feature.desc}

+
+
+ ) + })}
-
- - {/* Stats Strip (Phase 4 - Live Data) */} -
-
-
-
-

- {statsLoading ? '---' : `${guildCount.toLocaleString()}${stats?.totalGuilds ? '+' : ''}`} -

-

Servers

-
-
-

- {statsLoading ? '---' : `${userCount.toLocaleString()}+`} -

-

Users

-
-
-

- {stats?.serversOnline ? '✓ Online' : statsLoading ? '---' : '✗ Offline'} -

-

Status

-
-
+
+
+ ) +} + +// Stats Strip with Neon Numbers +function StatsSection({ statsLoading, guildCount, userCount, serversOnline }: any) { + const stats = [ + { + label: 'Servers', + value: statsLoading ? '---' : `${guildCount.toLocaleString()}${guildCount > 0 ? '+' : ''}`, + icon: Server, + }, + { + label: 'Users', + value: statsLoading ? '---' : `${userCount.toLocaleString()}+`, + icon: Users, + }, + { + label: 'Status', + value: serversOnline ? '✓ Online' : statsLoading ? '---' : '✗ Offline', + icon: Radio, + isStatus: true, + }, + ] + + return ( +
+
+
+ {stats.map((stat, idx) => { + const Icon = stat.icon + return ( + +
+ +
+ + {stat.value} + +

{stat.label}

+
+ ) + })}
-
- - {/* FAQ (Phase 5) */} -
-
-
-

Frequently Asked Questions

-
- -
- {[ - { - q: 'Is Lucky free?', - a: 'Yes, Lucky is completely free with no premium tier.', - }, - { - q: 'What commands does Lucky support?', - a: 'Lucky supports 100+ commands across music, moderation, custom commands, and more.', - }, - { - q: 'How does autoplay work?', - a: 'Autoplay uses Spotify to match similar songs based on artist preferences you set.', - }, - { - q: 'Can I self-host Lucky?', - a: 'Lucky is hosted and managed by us. You cannot self-host the bot, but you can use the dashboard to configure it.', - }, - { - q: 'How do I moderate spam?', - a: 'Configure auto-mod rules for spam, caps, links, and more in the dashboard.', - }, - { - q: 'Where do I get support?', - a: 'Join our Discord support server or open an issue on GitHub.', - }, - ].map(({ q, a }, idx) => ( -
- - {q} - ▼ - -

{a}

-
- ))} -
+
+
+ ) +} + +// FAQ Section with Smooth Accordions +function FAQSection() { + const [openIdx, setOpenIdx] = useState(null) + + const faqs = [ + { + q: 'Is Lucky free?', + a: 'Yes, Lucky is completely free with no premium tier.', + }, + { + q: 'What commands does Lucky support?', + a: 'Lucky supports 100+ commands across music, moderation, custom commands, and more.', + }, + { + q: 'How does autoplay work?', + a: 'Autoplay uses Spotify to match similar songs based on artist preferences you set.', + }, + { + q: 'Can I self-host Lucky?', + a: 'Lucky is hosted and managed by us. You cannot self-host the bot, but you can use the dashboard to configure it.', + }, + { + q: 'How do I moderate spam?', + a: 'Configure auto-mod rules for spam, caps, links, and more in the dashboard.', + }, + { + q: 'Where do I get support?', + a: 'Join our Discord support server or open an issue on GitHub.', + }, + ] + + return ( +
+
+ +

Frequently Asked Questions

+
+ +
+ {faqs.map(({ q, a }, idx) => ( + + + +

{a}

+
+
+ ))}
-
- - {/* Footer (Phase 5) */} -
-
-
-
-

Lucky

-

Discord bot with music, moderation, and more.

-
-
-

Links

- -
-
-

Support

-

Need help? Join our Discord community.

+
+
+ ) +} + +// Footer with Logo Tile +function FooterSection() { + return ( + -
+ +

© 2026 Lucky. All rights reserved.

+
+ + ) }