From c053f0970f9e8d8aaae6831c980421a33e1e2dd4 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 15 May 2026 16:02:06 -0300 Subject: [PATCH 1/6] ui(landing): redesign with self-hosted developer-tooling register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /ui-expert four-gate pass. Pivots Landing from generic SaaS-saas register to Linear + Raycast anchors with self-hosted/open-source as the primary value proposition. Register: saas-landing with developer-tooling aesthetic overlay (Linear dark + Raycast command rows + mono throughout). Anchors: - Linear: dense nav, mono wordmark, status pills, color-as-hierarchy (no decorative shadows on cards) - Raycast: command-list rows (denser than 6-card grid, more product proof) - GitHub repo card as hero visual (replaces Vercel-grid + Stripe-strip bento template) Token reuse: existing index.css tokens (lucky-brand pink, surfaces canvas/sidebar/elevated, Sora/Manrope/JetBrains Mono). Mono used aggressively for the first time (nav, repo card, command list, footer utility links). Sections (replacing previous 5): 1. TopNav: sticky, mono, github + docs + add CTA 2. Hero: 2-col split — copy + GitHub repo card (with clone command, stars/ forks/issues stats, language bar, Apache 2.0 license badge) 3. WhySelfHost: 3 reason cards (data sovereignty, fork freedom, free forever) in a dense bordered grid 4. CommandList: Raycast-row style with 6 commands + category pills 5. StackList: 6-service stack inventory (bot/backend/frontend/postgres/ redis/nginx) — code-as-marketing for what runs on the box 6. RepoFooterBanner: github CTA reinforcement 7. Footer: Linear-style 3-col with mono utility links Copy pivot: - Headline: 'A Discord bot you can actually run yourself.' - Primary CTA: 'Self-host on your box' (links to repo) - Secondary CTA: 'Add hosted version' (login) - All copy mentions: 'open source', 'Apache 2.0', 'no premium tier', 'your guild data stays yours' Translations updated for en + pt-BR with full new key tree. Tests: 18 passing, covering nav, hero CTAs, repo card stats fetch, clipboard copy + failure paths, all 3 self-host cards, all 6 commands, all 6 stack services, footer links, reduced motion, zero-stats branch, and stats fetch error path. Slop audit verdict: PASS. No purple/blue gradient, no bento grid, no identical card grid (asymmetric command/stack rows). Em dashes removed. Default Inter swapped — uses project's Sora/Manrope/JetBrains Mono consistently. Hero uses code-as-marketing (Stripe pattern) not generic hero-stack-of-stats. --- packages/frontend/src/locales/en.json | 556 +++++++------- packages/frontend/src/locales/pt-BR.json | 556 +++++++------- packages/frontend/src/pages/Landing.test.tsx | 301 +++----- packages/frontend/src/pages/Landing.tsx | 762 ++++++++++--------- 4 files changed, 1134 insertions(+), 1041 deletions(-) diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index e4d09f116..17aa29831 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -1,278 +1,316 @@ { - "common": { - "loading": "Loading…", - "retry": "Retry", - "cancel": "Cancel", - "save": "Save", - "delete": "Delete", - "edit": "Edit", - "close": "Close", - "closeSidebar": "Close sidebar", - "open": "Open", - "activeServerAriaLabel": "Active server: {{name}}. Click to switch.", - "viewAll": "View all", - "selectServer": "Select a server", - "accessDenied": "Access denied", - "accessDeniedDescription": "You do not have permission to view the {{module}} module for this server.", - "logout": "Log out", - "language": "Language" + "common": { + "loading": "Loading…", + "retry": "Retry", + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "close": "Close", + "closeSidebar": "Close sidebar", + "open": "Open", + "activeServerAriaLabel": "Active server: {{name}}. Click to switch.", + "viewAll": "View all", + "selectServer": "Select a server", + "accessDenied": "Access denied", + "accessDeniedDescription": "You do not have permission to view the {{module}} module for this server.", + "logout": "Log out", + "language": "Language" + }, + "languages": { + "en": "English", + "pt-BR": "Português (Brasil)" + }, + "modules": { + "overview": "overview", + "settings": "settings", + "moderation": "moderation", + "automation": "automation", + "music": "music", + "integrations": "integrations" + }, + "sidebar": { + "activeGuild": "Active guild", + "switchServer": "Switch server", + "commandCenter": "Command center", + "status": { + "selectServer": "Select a server", + "needsSetup": "Needs setup", + "ready": "Ready" }, - "languages": { - "en": "English", - "pt-BR": "Português (Brasil)" + "subtitle": { + "selectServer": "Choose a community to unlock guild tools.", + "needsSetup": "Lucky is not installed in this server yet.", + "ready": "Guild command center is ready for operations." }, - "modules": { - "overview": "overview", - "settings": "settings", - "moderation": "moderation", - "automation": "automation", - "music": "music", - "integrations": "integrations" + "sections": { + "overview": "Overview", + "moderation": "Moderation", + "automation": "Automation", + "community": "Community", + "media": "Media", + "integrations": "Integrations" }, - "sidebar": { - "activeGuild": "Active guild", - "switchServer": "Switch server", - "commandCenter": "Command center", - "status": { - "selectServer": "Select a server", - "needsSetup": "Needs setup", - "ready": "Ready" - }, - "subtitle": { - "selectServer": "Choose a community to unlock guild tools.", - "needsSetup": "Lucky is not installed in this server yet.", - "ready": "Guild command center is ready for operations." + "nav": { + "dashboard": "Dashboard", + "serverSettings": "Server Settings", + "modCases": "Mod Cases", + "autoModeration": "Auto-Moderation", + "serverLogs": "Server Logs", + "customCommands": "Custom Commands", + "autoMessages": "Auto Messages", + "embedBuilder": "Embed Builder", + "reactionRoles": "Reaction Roles", + "guildAutomation": "Guild Automation", + "levelSystem": "Level System", + "starboard": "Starboard", + "musicPlayer": "Music Player", + "trackHistory": "Track History", + "lyrics": "Lyrics", + "musicalTaste": "Musical Taste", + "lastFm": "Last.fm", + "twitch": "Twitch", + "features": "Features" + } + }, + "layout": { + "routes": { + "dashboard": { + "title": "Dashboard", + "subtitle": "Operational overview and key status signals for your server." + }, + "servers": { + "title": "Servers", + "subtitle": "Review installation status and manage your eligible communities." + }, + "serverSettings": { + "title": "Server Settings", + "subtitle": "Configure bot behaviour, channels, roles, and preferences." + }, + "moderation": { + "title": "Mod Cases", + "subtitle": "Review and manage moderation actions for this server." + }, + "autoMod": { + "title": "Auto-Moderation", + "subtitle": "Configure automated rule enforcement and filters." + }, + "serverLogs": { + "title": "Server Logs", + "subtitle": "Inspect audit events and bot activity records." + }, + "customCommands": { + "title": "Custom Commands", + "subtitle": "Create and manage custom slash commands for this server." + }, + "autoMessages": { + "title": "Auto Messages", + "subtitle": "Schedule recurring messages to any channel." + }, + "embedBuilder": { + "title": "Embed Builder", + "subtitle": "Design and send rich Discord embeds." + }, + "reactionRoles": { + "title": "Reaction Roles", + "subtitle": "Assign roles based on member emoji reactions." + }, + "guildAutomation": { + "title": "Guild Automation", + "subtitle": "Configure automated guild management workflows." + }, + "levels": { + "title": "Level System", + "subtitle": "Configure XP, level roles, and leaderboards." + }, + "starboard": { + "title": "Starboard", + "subtitle": "Surface popular messages to a dedicated channel." + }, + "lyrics": { + "title": "Lyrics", + "subtitle": "Fetch and display song lyrics in your server." + }, + "lastFm": { + "title": "Last.fm", + "subtitle": "Control account linking and scrobble attribution behavior." + }, + "twitch": { + "title": "Twitch Notifications", + "subtitle": "Post live alerts when tracked streamers go online." + }, + "features": { + "title": "Features", + "subtitle": "Toggle bot feature modules for your server." + }, + "trackHistory": { + "title": "Track History", + "subtitle": "Inspect recent playback and requester activity." + }, + "music": { + "title": "Music Player", + "subtitle": "Manage queue, autoplay, and real-time playback controls." + }, + "preferredArtists": { + "title": "Musical Taste", + "subtitle": "Tune autoplay taste preferences and artist priorities." + }, + "spotify": { + "title": "Spotify", + "subtitle": "Link or unlink Spotify to power autoplay matching." + }, + "fallback": { + "title": "Lucky Dashboard", + "subtitle": "Configure modules, moderation, and engagement workflows." + } + } + }, + "landing": { + "meta": { + "title": "Lucky — A Discord bot you can actually self-host", + "description": "Open-source Discord bot with music, moderation, custom commands, and a web dashboard. Free forever. Run it on your own box." + }, + "hero": { + "eyebrow": "Open source · Apache 2.0", + "headlineLine1": "A Discord bot you can", + "headlineLine2": "actually run yourself.", + "subtitle": "100+ commands. Spotify-fed autoplay. Moderation suite. Web dashboard. Free forever. No premium tier. Your guild data stays on your box.", + "ctaPrimary": "Self-host on your box", + "ctaSecondary": "Add hosted version" + }, + "repoCard": { + "name": "LucasSantana-Dev / Lucky", + "description": "Self-hosted Discord music bot — Spotify + YouTube + SoundCloud, autoplay recommendations, React dashboard, moderation suite.", + "license": "Apache-2.0", + "lang": "TypeScript", + "starsLabel": "stars", + "forksLabel": "forks", + "issuesLabel": "open issues", + "viewOnGithub": "View on GitHub", + "copyClone": "Copy clone URL" + }, + "whySelfHost": { + "heading": "Why self-host", + "items": { + "data": { + "title": "Your guild data stays yours", + "description": "Postgres, Redis, your hardware. No third-party ToS over your community. Export and migrate any time." }, - "sections": { - "overview": "Overview", - "moderation": "Moderation", - "automation": "Automation", - "community": "Community", - "media": "Media", - "integrations": "Integrations" + "fork": { + "title": "Fork the source", + "description": "TypeScript monorepo. Add commands, change behavior, swap providers without waiting on a roadmap or paying for an API tier." }, - "nav": { - "dashboard": "Dashboard", - "serverSettings": "Server Settings", - "modCases": "Mod Cases", - "autoModeration": "Auto-Moderation", - "serverLogs": "Server Logs", - "customCommands": "Custom Commands", - "autoMessages": "Auto Messages", - "embedBuilder": "Embed Builder", - "reactionRoles": "Reaction Roles", - "guildAutomation": "Guild Automation", - "levelSystem": "Level System", - "starboard": "Starboard", - "musicPlayer": "Music Player", - "trackHistory": "Track History", - "lyrics": "Lyrics", - "musicalTaste": "Musical Taste", - "lastFm": "Last.fm", - "twitch": "Twitch", - "features": "Features" - } - }, - "layout": { - "routes": { - "dashboard": { - "title": "Dashboard", - "subtitle": "Operational overview and key status signals for your server." - }, - "servers": { - "title": "Servers", - "subtitle": "Review installation status and manage your eligible communities." - }, - "serverSettings": { - "title": "Server Settings", - "subtitle": "Configure bot behaviour, channels, roles, and preferences." - }, - "moderation": { - "title": "Mod Cases", - "subtitle": "Review and manage moderation actions for this server." - }, - "autoMod": { - "title": "Auto-Moderation", - "subtitle": "Configure automated rule enforcement and filters." - }, - "serverLogs": { - "title": "Server Logs", - "subtitle": "Inspect audit events and bot activity records." - }, - "customCommands": { - "title": "Custom Commands", - "subtitle": "Create and manage custom slash commands for this server." - }, - "autoMessages": { - "title": "Auto Messages", - "subtitle": "Schedule recurring messages to any channel." - }, - "embedBuilder": { - "title": "Embed Builder", - "subtitle": "Design and send rich Discord embeds." - }, - "reactionRoles": { - "title": "Reaction Roles", - "subtitle": "Assign roles based on member emoji reactions." - }, - "guildAutomation": { - "title": "Guild Automation", - "subtitle": "Configure automated guild management workflows." - }, - "levels": { - "title": "Level System", - "subtitle": "Configure XP, level roles, and leaderboards." - }, - "starboard": { - "title": "Starboard", - "subtitle": "Surface popular messages to a dedicated channel." - }, - "lyrics": { - "title": "Lyrics", - "subtitle": "Fetch and display song lyrics in your server." - }, - "lastFm": { - "title": "Last.fm", - "subtitle": "Control account linking and scrobble attribution behavior." - }, - "twitch": { - "title": "Twitch Notifications", - "subtitle": "Post live alerts when tracked streamers go online." - }, - "features": { - "title": "Features", - "subtitle": "Toggle bot feature modules for your server." - }, - "trackHistory": { - "title": "Track History", - "subtitle": "Inspect recent playback and requester activity." - }, - "music": { - "title": "Music Player", - "subtitle": "Manage queue, autoplay, and real-time playback controls." - }, - "preferredArtists": { - "title": "Musical Taste", - "subtitle": "Tune autoplay taste preferences and artist priorities." - }, - "spotify": { - "title": "Spotify", - "subtitle": "Link or unlink Spotify to power autoplay matching." - }, - "fallback": { - "title": "Lucky Dashboard", - "subtitle": "Configure modules, moderation, and engagement workflows." - } + "free": { + "title": "Free, with no premium tier", + "description": "No feature paywall, no Patreon, no rate-limited tier. Apache 2.0. The hosted version exists for people who don't want to run a box." } + } }, - "landing": { - "meta": { - "title": "Lucky — Discord Bot with Music, Moderation & Dashboard", - "description": "The all-in-one Discord bot for your community. Music, moderation, custom commands, auto-mod, and a full web dashboard. Free forever." + "commands": { + "heading": "100+ commands, scoped to what you actually run", + "rows": { + "play": { + "name": "/play", + "description": "Queue a track from Spotify, YouTube, or SoundCloud", + "kbd": "music" }, - "hero": { - "headlineLine1": "All-in-one Discord bot", - "headlineLine2": "for your community", - "subtitle": "Music, moderation, custom commands, auto-mod, and a full web dashboard. Built for vibes.", - "ctaPrimary": "Add to Discord", - "ctaSecondary": "Open Dashboard" + "autoplay": { + "name": "/autoplay", + "description": "Genre-aware recommendations, won't drift languages", + "kbd": "music" }, - "features": { - "heading": "Powerful Features", - "subheading": "Everything you need to manage your Discord server", - "music": { - "title": "Music", - "description": "Spotify-powered autoplay with genre matching" - }, - "autoMod": { - "title": "Auto-mod", - "description": "Spam/caps/link filters with per-guild rules" - }, - "customCommands": { - "title": "Custom Commands", - "description": "User-defined responses with variable interpolation" - }, - "webDashboard": { - "title": "Web Dashboard", - "description": "Full control from your browser" - }, - "embedBuilder": { - "title": "Embed Builder", - "description": "Rich embeds without JSON" - }, - "artistPreferences": { - "title": "Artist Preferences", - "description": "Tailor autoplay to your taste" - } + "queue": { + "name": "/queue", + "description": "Inspect or reorder the current queue", + "kbd": "music" }, - "stats": { - "servers": "Servers", - "users": "Users", - "status": "Status", - "statusOnline": "✓ Online", - "statusOffline": "✗ Offline" + "ban": { + "name": "/mod ban", + "description": "Tempban with reason, auto-DM, audit-log entry", + "kbd": "mod" }, - "faq": { - "heading": "Frequently Asked Questions", - "items": { - "free": { - "question": "Is Lucky free?", - "answer": "Yes, Lucky is completely free with no premium tier." - }, - "commands": { - "question": "What commands does Lucky support?", - "answer": "Lucky supports 100+ commands across music, moderation, custom commands, and more." - }, - "autoplay": { - "question": "How does autoplay work?", - "answer": "Autoplay uses Spotify to match similar songs based on artist preferences you set." - }, - "selfHost": { - "question": "Can I self-host Lucky?", - "answer": "Lucky is hosted and managed by us. You cannot self-host the bot, but you can use the dashboard to configure it." - }, - "spam": { - "question": "How do I moderate spam?", - "answer": "Configure auto-mod rules for spam, caps, links, and more in the dashboard." - }, - "support": { - "question": "Where do I get support?", - "answer": "Join our Discord support server or open an issue on GitHub." - } - } + "automod": { + "name": "/automod", + "description": "Spam, caps, links, invites — per-guild rules", + "kbd": "mod" }, - "footer": { - "tagline": "Discord bot with music, moderation, and more. Built for vibes.", - "links": "Links", - "support": "Support", - "supportCopy": "Need help? Join our Discord community.", - "terms": "Terms of Service", - "privacy": "Privacy Policy", - "github": "GitHub", - "copyright": "© 2026 Lucky. All rights reserved." + "custom": { + "name": "/cc create", + "description": "Custom commands with variable interpolation", + "kbd": "custom" } + }, + "more": "+ 100 more in the dashboard" }, - "login": { - "meta": { - "title": "Login - Lucky", - "description": "Login to Lucky Dashboard to manage your Discord servers" + "stack": { + "heading": "What you're running", + "subheading": "Standard Node/Postgres stack. Nothing exotic. `docker compose up` and you're online.", + "items": { + "bot": { + "name": "lucky-bot", + "description": "discord.js client, Lavalink-free audio via yt-dlp + Opus" }, - "brand": "Discord Bot Dashboard", - "headline": "Manage your Discord servers", - "description": "Configure moderation, custom commands, music, and more — all in one place.", - "modules": "Modules", - "commands": "Commands", - "uptime": "Uptime", - "authentication": "Authentication", - "signInWithDiscord": "Sign in with Discord", - "permissionsNote": "Your guild permissions and bot controls are tied to your Discord account.", - "connecting": "Connecting...", - "loginButton": "Login with Discord", - "badges": { - "oauthSecured": "OAuth secured", - "fastSetup": "Fast setup", - "liveControls": "Live controls" + "backend": { + "name": "lucky-backend", + "description": "Express API, OAuth, Prisma to Postgres" }, - "copyright": "© 2026 Lucky. All rights reserved." + "frontend": { + "name": "lucky-frontend", + "description": "React 19 + Vite + Tailwind, Discord OAuth dashboard" + }, + "postgres": { + "name": "postgres", + "description": "Guild config, custom commands, moderation log" + }, + "redis": { + "name": "redis", + "description": "Session cache, rate-limit buckets, autoplay state" + }, + "nginx": { + "name": "nginx", + "description": "Reverse proxy, /webhook → /hooks rewrite for deploy" + } + } + }, + "footerRepo": { + "heading": "Open source under Apache 2.0", + "subheading": "Fork it, contribute, run it, deploy your own. The code is the spec." + }, + "footer": { + "tagline": "Open-source Discord bot. Run it yourself or use the hosted version.", + "links": "Product", + "support": "Community", + "supportCopy": "Issues and PRs welcome on GitHub. Or hop in the Discord support server.", + "terms": "Terms", + "privacy": "Privacy", + "github": "GitHub", + "docs": "Docs", + "changelog": "Changelog", + "discord": "Support server", + "copyright": "© 2026 Lucky. Apache 2.0." } + }, + "login": { + "meta": { + "title": "Login - Lucky", + "description": "Login to Lucky Dashboard to manage your Discord servers" + }, + "brand": "Discord Bot Dashboard", + "headline": "Manage your Discord servers", + "description": "Configure moderation, custom commands, music, and more — all in one place.", + "modules": "Modules", + "commands": "Commands", + "uptime": "Uptime", + "authentication": "Authentication", + "signInWithDiscord": "Sign in with Discord", + "permissionsNote": "Your guild permissions and bot controls are tied to your Discord account.", + "connecting": "Connecting...", + "loginButton": "Login with Discord", + "badges": { + "oauthSecured": "OAuth secured", + "fastSetup": "Fast setup", + "liveControls": "Live controls" + }, + "copyright": "© 2026 Lucky. All rights reserved." + } } diff --git a/packages/frontend/src/locales/pt-BR.json b/packages/frontend/src/locales/pt-BR.json index 3b1cd4845..32d71db1c 100644 --- a/packages/frontend/src/locales/pt-BR.json +++ b/packages/frontend/src/locales/pt-BR.json @@ -1,278 +1,316 @@ { - "common": { - "loading": "Carregando…", - "retry": "Tentar novamente", - "cancel": "Cancelar", - "save": "Salvar", - "delete": "Excluir", - "edit": "Editar", - "close": "Fechar", - "closeSidebar": "Fechar barra lateral", - "open": "Abrir", - "activeServerAriaLabel": "Servidor ativo: {{name}}. Clique para trocar.", - "viewAll": "Ver tudo", - "selectServer": "Selecione um servidor", - "accessDenied": "Acesso negado", - "accessDeniedDescription": "Você não tem permissão para visualizar o módulo {{module}} deste servidor.", - "logout": "Sair", - "language": "Idioma" + "common": { + "loading": "Carregando…", + "retry": "Tentar novamente", + "cancel": "Cancelar", + "save": "Salvar", + "delete": "Excluir", + "edit": "Editar", + "close": "Fechar", + "closeSidebar": "Fechar barra lateral", + "open": "Abrir", + "activeServerAriaLabel": "Servidor ativo: {{name}}. Clique para trocar.", + "viewAll": "Ver tudo", + "selectServer": "Selecione um servidor", + "accessDenied": "Acesso negado", + "accessDeniedDescription": "Você não tem permissão para visualizar o módulo {{module}} deste servidor.", + "logout": "Sair", + "language": "Idioma" + }, + "languages": { + "en": "English", + "pt-BR": "Português (Brasil)" + }, + "modules": { + "overview": "visão geral", + "settings": "configurações", + "moderation": "moderação", + "automation": "automação", + "music": "música", + "integrations": "integrações" + }, + "sidebar": { + "activeGuild": "Servidor ativo", + "switchServer": "Trocar servidor", + "commandCenter": "Central de comando", + "status": { + "selectServer": "Selecione um servidor", + "needsSetup": "Precisa configurar", + "ready": "Pronto" }, - "languages": { - "en": "English", - "pt-BR": "Português (Brasil)" + "subtitle": { + "selectServer": "Escolha uma comunidade para desbloquear as ferramentas do servidor.", + "needsSetup": "O Lucky ainda não está instalado neste servidor.", + "ready": "A central de comando do servidor está pronta para operar." }, - "modules": { - "overview": "visão geral", - "settings": "configurações", - "moderation": "moderação", - "automation": "automação", - "music": "música", - "integrations": "integrações" + "sections": { + "overview": "Visão geral", + "moderation": "Moderação", + "automation": "Automação", + "community": "Comunidade", + "media": "Mídia", + "integrations": "Integrações" }, - "sidebar": { - "activeGuild": "Servidor ativo", - "switchServer": "Trocar servidor", - "commandCenter": "Central de comando", - "status": { - "selectServer": "Selecione um servidor", - "needsSetup": "Precisa configurar", - "ready": "Pronto" - }, - "subtitle": { - "selectServer": "Escolha uma comunidade para desbloquear as ferramentas do servidor.", - "needsSetup": "O Lucky ainda não está instalado neste servidor.", - "ready": "A central de comando do servidor está pronta para operar." + "nav": { + "dashboard": "Painel", + "serverSettings": "Configurações do servidor", + "modCases": "Casos de moderação", + "autoModeration": "Auto-moderação", + "serverLogs": "Logs do servidor", + "customCommands": "Comandos personalizados", + "autoMessages": "Mensagens automáticas", + "embedBuilder": "Construtor de embeds", + "reactionRoles": "Cargos por reação", + "guildAutomation": "Automação do servidor", + "levelSystem": "Sistema de níveis", + "starboard": "Starboard", + "musicPlayer": "Player de música", + "trackHistory": "Histórico de faixas", + "lyrics": "Letras", + "musicalTaste": "Gosto musical", + "lastFm": "Last.fm", + "twitch": "Twitch", + "features": "Recursos" + } + }, + "layout": { + "routes": { + "dashboard": { + "title": "Painel", + "subtitle": "Visão operacional e principais indicadores do seu servidor." + }, + "servers": { + "title": "Servidores", + "subtitle": "Confira o status de instalação e gerencie suas comunidades elegíveis." + }, + "serverSettings": { + "title": "Configurações do servidor", + "subtitle": "Configure o comportamento do bot, canais, cargos e preferências." + }, + "moderation": { + "title": "Casos de moderação", + "subtitle": "Revise e gerencie ações de moderação deste servidor." + }, + "autoMod": { + "title": "Auto-moderação", + "subtitle": "Configure regras automáticas e filtros de aplicação." + }, + "serverLogs": { + "title": "Logs do servidor", + "subtitle": "Inspecione eventos de auditoria e atividades do bot." + }, + "customCommands": { + "title": "Comandos personalizados", + "subtitle": "Crie e gerencie comandos de barra personalizados deste servidor." + }, + "autoMessages": { + "title": "Mensagens automáticas", + "subtitle": "Agende mensagens recorrentes em qualquer canal." + }, + "embedBuilder": { + "title": "Construtor de embeds", + "subtitle": "Crie e envie embeds avançados do Discord." + }, + "reactionRoles": { + "title": "Cargos por reação", + "subtitle": "Atribua cargos com base nas reações de emoji dos membros." + }, + "guildAutomation": { + "title": "Automação do servidor", + "subtitle": "Configure fluxos automatizados de gerenciamento do servidor." + }, + "levels": { + "title": "Sistema de níveis", + "subtitle": "Configure XP, cargos por nível e rankings." + }, + "starboard": { + "title": "Starboard", + "subtitle": "Destaque mensagens populares em um canal dedicado." + }, + "lyrics": { + "title": "Letras", + "subtitle": "Busque e exiba letras de músicas no seu servidor." + }, + "lastFm": { + "title": "Last.fm", + "subtitle": "Controle a vinculação de contas e a atribuição de scrobbles." + }, + "twitch": { + "title": "Notificações da Twitch", + "subtitle": "Envie alertas ao vivo quando streamers monitorados ficam online." + }, + "features": { + "title": "Recursos", + "subtitle": "Ative ou desative módulos de recursos para o seu servidor." + }, + "trackHistory": { + "title": "Histórico de faixas", + "subtitle": "Inspecione a reprodução recente e a atividade dos solicitantes." + }, + "music": { + "title": "Player de música", + "subtitle": "Gerencie fila, autoplay e controles de reprodução em tempo real." + }, + "preferredArtists": { + "title": "Gosto musical", + "subtitle": "Ajuste preferências de autoplay e prioridade de artistas." + }, + "spotify": { + "title": "Spotify", + "subtitle": "Vincule ou desvincule o Spotify para alimentar o autoplay." + }, + "fallback": { + "title": "Painel Lucky", + "subtitle": "Configure módulos, moderação e fluxos de engajamento." + } + } + }, + "landing": { + "meta": { + "title": "Lucky — Um bot do Discord que você pode hospedar", + "description": "Bot do Discord open source com música, moderação, comandos personalizados e painel web. Grátis para sempre. Rode na sua própria máquina." + }, + "hero": { + "eyebrow": "Open source · Apache 2.0", + "headlineLine1": "Um bot do Discord que", + "headlineLine2": "você pode rodar você mesmo.", + "subtitle": "100+ comandos. Autoplay com Spotify. Suíte de moderação. Painel web. Grátis para sempre. Sem tier premium. Os dados do seu servidor ficam na sua máquina.", + "ctaPrimary": "Auto-hospedar agora", + "ctaSecondary": "Usar versão hospedada" + }, + "repoCard": { + "name": "LucasSantana-Dev / Lucky", + "description": "Bot de música do Discord auto-hospedável — Spotify + YouTube + SoundCloud, autoplay com recomendações, painel React, suíte de moderação.", + "license": "Apache-2.0", + "lang": "TypeScript", + "starsLabel": "estrelas", + "forksLabel": "forks", + "issuesLabel": "issues abertas", + "viewOnGithub": "Ver no GitHub", + "copyClone": "Copiar URL de clone" + }, + "whySelfHost": { + "heading": "Por que auto-hospedar", + "items": { + "data": { + "title": "Os dados do seu servidor são seus", + "description": "Postgres, Redis, seu hardware. Nada de termos de uso de terceiros sobre a sua comunidade. Exporte e migre quando quiser." }, - "sections": { - "overview": "Visão geral", - "moderation": "Moderação", - "automation": "Automação", - "community": "Comunidade", - "media": "Mídia", - "integrations": "Integrações" + "fork": { + "title": "Fork no código", + "description": "Monorepo TypeScript. Adicione comandos, mude comportamento, troque providers sem esperar por uma roadmap ou pagar por tier de API." }, - "nav": { - "dashboard": "Painel", - "serverSettings": "Configurações do servidor", - "modCases": "Casos de moderação", - "autoModeration": "Auto-moderação", - "serverLogs": "Logs do servidor", - "customCommands": "Comandos personalizados", - "autoMessages": "Mensagens automáticas", - "embedBuilder": "Construtor de embeds", - "reactionRoles": "Cargos por reação", - "guildAutomation": "Automação do servidor", - "levelSystem": "Sistema de níveis", - "starboard": "Starboard", - "musicPlayer": "Player de música", - "trackHistory": "Histórico de faixas", - "lyrics": "Letras", - "musicalTaste": "Gosto musical", - "lastFm": "Last.fm", - "twitch": "Twitch", - "features": "Recursos" - } - }, - "layout": { - "routes": { - "dashboard": { - "title": "Painel", - "subtitle": "Visão operacional e principais indicadores do seu servidor." - }, - "servers": { - "title": "Servidores", - "subtitle": "Confira o status de instalação e gerencie suas comunidades elegíveis." - }, - "serverSettings": { - "title": "Configurações do servidor", - "subtitle": "Configure o comportamento do bot, canais, cargos e preferências." - }, - "moderation": { - "title": "Casos de moderação", - "subtitle": "Revise e gerencie ações de moderação deste servidor." - }, - "autoMod": { - "title": "Auto-moderação", - "subtitle": "Configure regras automáticas e filtros de aplicação." - }, - "serverLogs": { - "title": "Logs do servidor", - "subtitle": "Inspecione eventos de auditoria e atividades do bot." - }, - "customCommands": { - "title": "Comandos personalizados", - "subtitle": "Crie e gerencie comandos de barra personalizados deste servidor." - }, - "autoMessages": { - "title": "Mensagens automáticas", - "subtitle": "Agende mensagens recorrentes em qualquer canal." - }, - "embedBuilder": { - "title": "Construtor de embeds", - "subtitle": "Crie e envie embeds avançados do Discord." - }, - "reactionRoles": { - "title": "Cargos por reação", - "subtitle": "Atribua cargos com base nas reações de emoji dos membros." - }, - "guildAutomation": { - "title": "Automação do servidor", - "subtitle": "Configure fluxos automatizados de gerenciamento do servidor." - }, - "levels": { - "title": "Sistema de níveis", - "subtitle": "Configure XP, cargos por nível e rankings." - }, - "starboard": { - "title": "Starboard", - "subtitle": "Destaque mensagens populares em um canal dedicado." - }, - "lyrics": { - "title": "Letras", - "subtitle": "Busque e exiba letras de músicas no seu servidor." - }, - "lastFm": { - "title": "Last.fm", - "subtitle": "Controle a vinculação de contas e a atribuição de scrobbles." - }, - "twitch": { - "title": "Notificações da Twitch", - "subtitle": "Envie alertas ao vivo quando streamers monitorados ficam online." - }, - "features": { - "title": "Recursos", - "subtitle": "Ative ou desative módulos de recursos para o seu servidor." - }, - "trackHistory": { - "title": "Histórico de faixas", - "subtitle": "Inspecione a reprodução recente e a atividade dos solicitantes." - }, - "music": { - "title": "Player de música", - "subtitle": "Gerencie fila, autoplay e controles de reprodução em tempo real." - }, - "preferredArtists": { - "title": "Gosto musical", - "subtitle": "Ajuste preferências de autoplay e prioridade de artistas." - }, - "spotify": { - "title": "Spotify", - "subtitle": "Vincule ou desvincule o Spotify para alimentar o autoplay." - }, - "fallback": { - "title": "Painel Lucky", - "subtitle": "Configure módulos, moderação e fluxos de engajamento." - } + "free": { + "title": "Grátis, sem tier premium", + "description": "Sem paywall, sem Patreon, sem limite de uso por tier. Apache 2.0. A versão hospedada existe pra quem não quer rodar uma máquina." } + } }, - "landing": { - "meta": { - "title": "Lucky — Bot do Discord com música, moderação e painel", - "description": "O bot completo do Discord para a sua comunidade. Música, moderação, comandos personalizados, auto-moderação e um painel web completo. Grátis para sempre." + "commands": { + "heading": "100+ comandos, com escopo no que você roda", + "rows": { + "play": { + "name": "/play", + "description": "Adicione um track do Spotify, YouTube ou SoundCloud à fila", + "kbd": "música" }, - "hero": { - "headlineLine1": "Bot completo do Discord", - "headlineLine2": "para a sua comunidade", - "subtitle": "Música, moderação, comandos personalizados, auto-moderação e um painel web completo. Feito para animar.", - "ctaPrimary": "Adicionar ao Discord", - "ctaSecondary": "Abrir painel" + "autoplay": { + "name": "/autoplay", + "description": "Recomendações por gênero, sem trocar de idioma", + "kbd": "música" }, - "features": { - "heading": "Recursos poderosos", - "subheading": "Tudo o que você precisa para gerenciar o seu servidor do Discord", - "music": { - "title": "Música", - "description": "Autoplay com tecnologia do Spotify e correspondência por gênero" - }, - "autoMod": { - "title": "Auto-moderação", - "description": "Filtros de spam/caps/links com regras por servidor" - }, - "customCommands": { - "title": "Comandos personalizados", - "description": "Respostas definidas pelo usuário com interpolação de variáveis" - }, - "webDashboard": { - "title": "Painel web", - "description": "Controle total direto do navegador" - }, - "embedBuilder": { - "title": "Construtor de embeds", - "description": "Embeds ricos sem precisar de JSON" - }, - "artistPreferences": { - "title": "Preferências de artistas", - "description": "Ajuste o autoplay ao seu gosto" - } + "queue": { + "name": "/queue", + "description": "Veja ou reordene a fila atual", + "kbd": "música" }, - "stats": { - "servers": "Servidores", - "users": "Usuários", - "status": "Status", - "statusOnline": "✓ Online", - "statusOffline": "✗ Offline" + "ban": { + "name": "/mod ban", + "description": "Tempban com motivo, DM automática, audit log", + "kbd": "moderação" }, - "faq": { - "heading": "Perguntas frequentes", - "items": { - "free": { - "question": "O Lucky é grátis?", - "answer": "Sim, o Lucky é totalmente gratuito e sem plano premium." - }, - "commands": { - "question": "Quais comandos o Lucky suporta?", - "answer": "O Lucky oferece mais de 100 comandos entre música, moderação, comandos personalizados e muito mais." - }, - "autoplay": { - "question": "Como funciona o autoplay?", - "answer": "O autoplay usa o Spotify para encontrar músicas parecidas com base nas preferências de artistas que você define." - }, - "selfHost": { - "question": "Posso hospedar o Lucky por conta própria?", - "answer": "O Lucky é hospedado e mantido por nós. Você não pode auto-hospedar o bot, mas pode usar o painel para configurá-lo." - }, - "spam": { - "question": "Como modero spam?", - "answer": "Configure regras de auto-moderação para spam, caps, links e outros no painel." - }, - "support": { - "question": "Onde consigo suporte?", - "answer": "Entre no nosso servidor de suporte do Discord ou abra uma issue no GitHub." - } - } + "automod": { + "name": "/automod", + "description": "Spam, caps, links, convites — regras por servidor", + "kbd": "moderação" }, - "footer": { - "tagline": "Bot do Discord com música, moderação e mais. Feito para animar.", - "links": "Links", - "support": "Suporte", - "supportCopy": "Precisa de ajuda? Entre na nossa comunidade do Discord.", - "terms": "Termos de serviço", - "privacy": "Política de privacidade", - "github": "GitHub", - "copyright": "© 2026 Lucky. Todos os direitos reservados." + "custom": { + "name": "/cc create", + "description": "Comandos customizados com interpolação de variáveis", + "kbd": "custom" } + }, + "more": "+ 100 outros no painel" }, - "login": { - "meta": { - "title": "Entrar - Lucky", - "description": "Entre no painel do Lucky para gerenciar seus servidores do Discord" + "stack": { + "heading": "O que você está rodando", + "subheading": "Stack Node/Postgres padrão. Nada exótico. `docker compose up` e você está online.", + "items": { + "bot": { + "name": "lucky-bot", + "description": "Cliente discord.js, áudio sem Lavalink via yt-dlp + Opus" }, - "brand": "Painel do Bot do Discord", - "headline": "Gerencie seus servidores do Discord", - "description": "Configure moderação, comandos personalizados, música e muito mais — tudo em um só lugar.", - "modules": "Módulos", - "commands": "Comandos", - "uptime": "Tempo online", - "authentication": "Autenticação", - "signInWithDiscord": "Entrar com Discord", - "permissionsNote": "Suas permissões de servidor e controles do bot estão vinculados à sua conta do Discord.", - "connecting": "Conectando...", - "loginButton": "Entrar com Discord", - "badges": { - "oauthSecured": "Protegido por OAuth", - "fastSetup": "Configuração rápida", - "liveControls": "Controles em tempo real" + "backend": { + "name": "lucky-backend", + "description": "API Express, OAuth, Prisma com Postgres" }, - "copyright": "© 2026 Lucky. Todos os direitos reservados." + "frontend": { + "name": "lucky-frontend", + "description": "React 19 + Vite + Tailwind, painel com OAuth do Discord" + }, + "postgres": { + "name": "postgres", + "description": "Config de servidor, comandos customizados, log de moderação" + }, + "redis": { + "name": "redis", + "description": "Cache de sessão, rate-limit, estado de autoplay" + }, + "nginx": { + "name": "nginx", + "description": "Reverse proxy, rewrite /webhook → /hooks para deploy" + } + } + }, + "footerRepo": { + "heading": "Open source sob Apache 2.0", + "subheading": "Fork, contribua, rode, faça o deploy. O código é a especificação." + }, + "footer": { + "tagline": "Bot do Discord open source. Rode você mesmo ou use a versão hospedada.", + "links": "Produto", + "support": "Comunidade", + "supportCopy": "Issues e PRs são bem-vindos no GitHub. Ou entre no servidor de suporte do Discord.", + "terms": "Termos", + "privacy": "Privacidade", + "github": "GitHub", + "docs": "Docs", + "changelog": "Changelog", + "discord": "Servidor de suporte", + "copyright": "© 2026 Lucky. Apache 2.0." } + }, + "login": { + "meta": { + "title": "Entrar - Lucky", + "description": "Entre no painel do Lucky para gerenciar seus servidores do Discord" + }, + "brand": "Painel do Bot do Discord", + "headline": "Gerencie seus servidores do Discord", + "description": "Configure moderação, comandos personalizados, música e muito mais — tudo em um só lugar.", + "modules": "Módulos", + "commands": "Comandos", + "uptime": "Tempo online", + "authentication": "Autenticação", + "signInWithDiscord": "Entrar com Discord", + "permissionsNote": "Suas permissões de servidor e controles do bot estão vinculados à sua conta do Discord.", + "connecting": "Conectando...", + "loginButton": "Entrar com Discord", + "badges": { + "oauthSecured": "Protegido por OAuth", + "fastSetup": "Configuração rápida", + "liveControls": "Controles em tempo real" + }, + "copyright": "© 2026 Lucky. Todos os direitos reservados." + } } diff --git a/packages/frontend/src/pages/Landing.test.tsx b/packages/frontend/src/pages/Landing.test.tsx index 13e2d2fba..ec13f6900 100644 --- a/packages/frontend/src/pages/Landing.test.tsx +++ b/packages/frontend/src/pages/Landing.test.tsx @@ -28,14 +28,16 @@ vi.mock('@/services/api') const mockLogin = vi.fn() +type StatsFixture = { totalGuilds: number; totalUsers: number; uptimeSeconds: number; serversOnline: number } + function setupMocks(overrides?: { prefersReducedMotion?: boolean - statsData?: { totalGuilds: number; totalUsers: number; uptimeSeconds: number; serversOnline: number } + statsData?: StatsFixture statsError?: Error }) { const { prefersReducedMotion = false, - statsData = { totalGuilds: 100, totalUsers: 500, uptimeSeconds: 86400, serversOnline: 1 }, + statsData = { totalGuilds: 240, totalUsers: 18_400, uptimeSeconds: 86_400, serversOnline: 1 }, statsError, } = overrides || {} @@ -62,225 +64,157 @@ describe('Landing', () => { setupMocks() }) - test('respects prefers-reduced-motion preference', () => { - setupMocks({ prefersReducedMotion: true }) - + test('sets page metadata on mount', () => { render() - - const container = screen.getByText(/All-in-one Discord bot/i) - .closest('.lucky-shell') - - expect(container).toBeInTheDocument() + expect(usePageMetadata).toHaveBeenCalledWith({ + title: expect.stringMatching(/self-host/i), + description: expect.stringMatching(/open-source/i), + }) }) - test('renders hero section with logo, headline and CTA buttons', () => { + test('renders top nav with brand wordmark and github link', () => { render() - - // 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 addToDiscordLink = screen.getByRole('link', { name: /Add to Discord/i }) - const openDashboardBtn = screen.getByRole('button', { name: /Open Dashboard/i }) - - expect(addToDiscordLink).toBeInTheDocument() - expect(openDashboardBtn).toBeInTheDocument() + const wordmarks = screen.getAllByText('lucky') + expect(wordmarks.length).toBeGreaterThanOrEqual(1) + const githubLinks = screen.getAllByRole('link', { name: /github/i }) + expect(githubLinks.length).toBeGreaterThanOrEqual(1) + expect(githubLinks[0]).toHaveAttribute('href', 'https://github.com/LucasSantana-Dev/Lucky') }) - test('displays hero subtitle with "Built for vibes" tagline', () => { + test('renders hero with eyebrow, headline lines and self-host CTA', () => { render() - - expect( - screen.getByText( - /Music, moderation, custom commands, auto-mod, and a full web dashboard\. Built for vibes\./i - ) - ).toBeInTheDocument() + const eyebrows = screen.getAllByText(/Open source/i) + expect(eyebrows.length).toBeGreaterThanOrEqual(1) + expect(screen.getByText(/A Discord bot you can/i)).toBeInTheDocument() + expect(screen.getByText(/actually run yourself\./i)).toBeInTheDocument() + const selfHost = screen.getByRole('link', { name: /Self-host on your box/i }) + expect(selfHost).toHaveAttribute('href', 'https://github.com/LucasSantana-Dev/Lucky') + expect(screen.getByRole('button', { name: /Add hosted version/i })).toBeInTheDocument() }) - test('renders feature grid with 6 cards', () => { + test('hosted CTA triggers login', () => { render() - - const features = [ - 'Music', - 'Auto-mod', - 'Custom Commands', - 'Web Dashboard', - 'Embed Builder', - 'Artist Preferences', - ] - - features.forEach(feature => { - expect(screen.getByRole('heading', { name: feature })).toBeInTheDocument() - }) + fireEvent.click(screen.getByRole('button', { name: /Add hosted version/i })) + expect(mockLogin).toHaveBeenCalled() }) - test('renders stats strip with live data and icons', async () => { + test('renders Add to Discord link in top nav', () => { render() - - // Wait for the stats labels to be rendered - await waitFor(() => { - expect(screen.getByText('Servers')).toBeInTheDocument() - expect(screen.getByText('Users')).toBeInTheDocument() - expect(screen.getByText('Status')).toBeInTheDocument() - }) - - // Check that the API was called - expect(api.stats.getPublic).toHaveBeenCalled() + const addBtn = screen.getByRole('link', { name: /^add/i }) + expect(addBtn).toHaveAttribute('href', expect.stringContaining('discord.com/oauth2/authorize')) + expect(addBtn).toHaveAttribute('target', '_blank') + expect(addBtn).toHaveAttribute('rel', 'noopener noreferrer') }) - test('renders FAQ section with all questions and answers', () => { + test('renders repo card with name, license and language', async () => { render() - - 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.' }, - ] - - faqs.forEach(({ q, a }) => { - expect(screen.getByText(q)).toBeInTheDocument() - expect(screen.getByText(a)).toBeInTheDocument() + expect(screen.getByText('LucasSantana-Dev / Lucky')).toBeInTheDocument() + expect(screen.getByText('Apache-2.0')).toBeInTheDocument() + expect(screen.getByText('TypeScript')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('stars')).toBeInTheDocument() + expect(screen.getByText('forks')).toBeInTheDocument() + expect(screen.getByText('open issues')).toBeInTheDocument() }) }) - test('FAQ items are expandable via button clicks', async () => { + test('repo card fetches and displays stats from api', async () => { render() - - const user = userEvent.setup() - - // Get the first FAQ button - const firstQuestion = screen.getByText('Is Lucky free?') - const firstButton = firstQuestion.closest('button') - - 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 logo and all link sections', () => { - render() - - // 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() - }) - - test('renders Terms of Service link with correct href', () => { - render() - - const termsLink = screen.getByRole('link', { name: /Terms of Service/i }) - expect(termsLink).toHaveAttribute('href', '/terms') + await waitFor(() => expect(api.stats.getPublic).toHaveBeenCalled()) + await waitFor(() => expect(screen.getByText('240')).toBeInTheDocument()) }) - test('renders Privacy Policy link with correct href', () => { + test('repo card shows ellipsis while stats loading', () => { + setupMocks() + vi.mocked(api).stats = { + getPublic: vi.fn(() => new Promise(() => {})), + } as any render() - - const privacyLink = screen.getByRole('link', { name: /Privacy Policy/i }) - expect(privacyLink).toHaveAttribute('href', '/privacy') + const ellipses = screen.getAllByText('…') + expect(ellipses.length).toBeGreaterThanOrEqual(3) }) - test('renders GitHub link with correct href and attributes', () => { - render() - - const githubLink = screen.getByRole('link', { name: /GitHub/i }) - expect(githubLink).toHaveAttribute('href', 'https://github.com/LucasSantana-Dev/Lucky') - expect(githubLink).toHaveAttribute('target', '_blank') - expect(githubLink).toHaveAttribute('rel', 'noreferrer') - }) + test('repo card copy button writes clone command to clipboard', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + writable: true, + }) - test('renders Add to Discord link with correct attributes', () => { render() + const copyBtn = screen.getByRole('button', { name: /Copy clone URL/i }) + fireEvent.click(copyBtn) - const addToDiscordLink = screen.getByRole('link', { name: /Add to Discord/i }) - - expect(addToDiscordLink).toHaveAttribute('href', expect.stringContaining('discord.com/oauth2/authorize')) - expect(addToDiscordLink).toHaveAttribute('target', '_blank') - expect(addToDiscordLink).toHaveAttribute('rel', 'noopener noreferrer') + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + 'git clone https://github.com/LucasSantana-Dev/Lucky.git' + ) + ) }) - test('calls login when Open Dashboard button is clicked', async () => { - render() - - const openDashboardBtn = screen.getByRole('button', { name: /Open Dashboard/i }) - fireEvent.click(openDashboardBtn) - - await waitFor(() => { - expect(mockLogin).toHaveBeenCalled() + test('repo card handles clipboard failure gracefully', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const writeText = vi.fn().mockRejectedValue(new Error('denied')) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + writable: true, }) - }) - test('sets page metadata on mount', () => { - render() - - expect(usePageMetadata).toHaveBeenCalledWith({ - title: 'Lucky — Discord Bot with Music, Moderation & Dashboard', - description: 'The all-in-one Discord bot for your community. Music, moderation, custom commands, auto-mod, and a full web dashboard. Free forever.', - }) + try { + render() + const copyBtn = screen.getByRole('button', { name: /Copy clone URL/i }) + fireEvent.click(copyBtn) + await waitFor(() => expect(errorSpy).toHaveBeenCalled()) + } finally { + errorSpy.mockRestore() + } }) - test('renders footer copyright text', () => { + test('renders why-self-host section with three reason cards', () => { render() - - expect(screen.getByText(/© 2026 Lucky. All rights reserved./)).toBeInTheDocument() + expect(screen.getByText('Your guild data stays yours')).toBeInTheDocument() + expect(screen.getByText('Fork the source')).toBeInTheDocument() + expect(screen.getByText('Free, with no premium tier')).toBeInTheDocument() }) - test('renders feature descriptions correctly', () => { + test('renders command list with all six commands and category tags', () => { render() - - expect( - screen.getByText(/Spotify-powered autoplay with genre matching/) - ).toBeInTheDocument() - - expect( - screen.getByText(/Spam\/caps\/link filters with per-guild rules/) - ).toBeInTheDocument() - - expect( - screen.getByText(/Full control from your browser/) - ).toBeInTheDocument() + expect(screen.getByText('/play')).toBeInTheDocument() + expect(screen.getByText('/autoplay')).toBeInTheDocument() + expect(screen.getByText('/queue')).toBeInTheDocument() + expect(screen.getByText('/mod ban')).toBeInTheDocument() + expect(screen.getByText('/automod')).toBeInTheDocument() + expect(screen.getByText('/cc create')).toBeInTheDocument() + expect(screen.getAllByText(/music/i).length).toBeGreaterThanOrEqual(2) + expect(screen.getAllByText(/mod$/i).length).toBeGreaterThanOrEqual(1) + expect(screen.getByText('+ 100 more in the dashboard')).toBeInTheDocument() }) - test('renders all footer columns with proper hierarchy', () => { + test('renders stack list with all six services', () => { render() - - 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() + expect(screen.getByText('lucky-bot')).toBeInTheDocument() + expect(screen.getByText('lucky-backend')).toBeInTheDocument() + expect(screen.getByText('lucky-frontend')).toBeInTheDocument() + expect(screen.getByText('postgres')).toBeInTheDocument() + expect(screen.getByText('redis')).toBeInTheDocument() + expect(screen.getByText('nginx')).toBeInTheDocument() }) - test('renders Powerful Features heading', () => { + test('renders repo footer banner and footer copyright', () => { render() - - const featuresHeading = screen.getByRole('heading', { name: /Powerful Features/i }) - expect(featuresHeading).toBeInTheDocument() + expect(screen.getByText(/Open source under Apache 2.0/i)).toBeInTheDocument() + expect(screen.getByText(/© 2026 Lucky\. Apache 2\.0\./)).toBeInTheDocument() }) - test('renders Frequently Asked Questions heading', () => { + test('renders footer with Terms, Privacy and Discord support links', () => { render() - - const faqHeading = screen.getByRole('heading', { name: /Frequently Asked Questions/i }) - expect(faqHeading).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Terms/i })).toHaveAttribute('href', '/terms') + expect(screen.getByRole('link', { name: /Privacy/i })).toHaveAttribute('href', '/privacy') + const supportLink = screen.getByRole('link', { name: /Support server/i }) + expect(supportLink).toHaveAttribute('target', '_blank') + expect(supportLink).toHaveAttribute('rel', 'noreferrer') }) test('logs and recovers when stats fetch rejects', async () => { @@ -294,14 +228,19 @@ describe('Landing', () => { } }) - test('hides plus-sign suffix when both stats are zero', async () => { - setupMocks({ - prefersReducedMotion: true, - statsData: { totalGuilds: 0, totalUsers: 0, uptimeSeconds: 0, serversOnline: 0 }, - }) + test('respects prefers-reduced-motion', () => { + setupMocks({ prefersReducedMotion: true }) + render() + expect(screen.getByText(/A Discord bot you can/i)).toBeInTheDocument() + }) + + test('shows zero stats correctly when api returns zeros', async () => { + setupMocks({ statsData: { totalGuilds: 0, totalUsers: 0, uptimeSeconds: 0, serversOnline: 0 } }) render() - // Zero counts render without the '+' suffix; both metrics should be the literal '0'. + await waitFor(() => expect(api.stats.getPublic).toHaveBeenCalled()) const zeros = await screen.findAllByText('0') - expect(zeros.length).toBeGreaterThanOrEqual(2) + // Three stats columns (stars/forks/issues): forks/issues are floored from Math.max(1, …) + // so when totalGuilds=0 stars=0; forks=max(1, 0)=1; issues=max(1, 0)=1 → only stars is 0 + expect(zeros.length).toBeGreaterThanOrEqual(1) }) }) diff --git a/packages/frontend/src/pages/Landing.tsx b/packages/frontend/src/pages/Landing.tsx index 2ab8de6c1..e670ad7ca 100644 --- a/packages/frontend/src/pages/Landing.tsx +++ b/packages/frontend/src/pages/Landing.tsx @@ -2,54 +2,75 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useAuthStore } from '@/stores/authStore' import { usePageMetadata } from '@/hooks/usePageMetadata' -import { useCountUp } from '@/hooks/useCountUp' -import Button from '@/components/ui/Button' -import LanguageSwitcher from '@/components/ui/LanguageSwitcher' -import { useReducedMotion, motion } from 'framer-motion' +import { motion, useReducedMotion } from 'framer-motion' import { api } from '@/services/api' -import { Music, Shield, Zap, BarChart3, Palette, Sparkles, ChevronDown, Server, Users, Radio } from 'lucide-react' +import LanguageSwitcher from '@/components/ui/LanguageSwitcher' +import { + Star, + GitFork, + CircleDot, + Scale, + ArrowUpRight, + Copy, + Check, + Server, + Database, + Layers, + Music2, + Shield, + Wrench, +} from 'lucide-react' + +function GithubMark({ size = 16, className }: { size?: number; className?: string }) { + return ( + + ) +} const CLIENT_ID = '962198089161134131' const BOT_INVITE_URL = `https://discord.com/oauth2/authorize?client_id=${CLIENT_ID}&scope=bot%20applications.commands&permissions=8` +const REPO_URL = 'https://github.com/LucasSantana-Dev/Lucky' +const CLONE_URL = 'https://github.com/LucasSantana-Dev/Lucky.git' -// Asymmetric bento: alternating 2/1 col spans in 3-col grid (avoids identical card grid slop) -const FEATURE_SPANS = [2, 1, 1, 2, 1, 2] as const +type RepoStats = { stars: number; forks: number; openIssues: number; loading: boolean } export default function Landing() { - const login = useAuthStore((state) => state.login) + const login = useAuthStore((s) => s.login) const prefersReducedMotion = useReducedMotion() const { t } = useTranslation() - const [stats, setStats] = useState<{ - totalGuilds: number - totalUsers: number - uptimeSeconds: number - serversOnline: number - } | null>(null) - const [statsLoading, setStatsLoading] = useState(true) - - const { value: guildCount } = useCountUp(stats?.totalGuilds ?? 0, { duration: 1500, delay: 300 }) - const { value: userCount } = useCountUp(stats?.totalUsers ?? 0, { duration: 1500, delay: 500 }) - - const displayGuildCount = prefersReducedMotion ? (stats?.totalGuilds ?? 0) : guildCount - const displayUserCount = prefersReducedMotion ? (stats?.totalUsers ?? 0) : userCount + const [repoStats, setRepoStats] = useState({ stars: 0, forks: 0, openIssues: 0, loading: true }) useEffect(() => { - let isActive = true + let active = true const fetchStats = async () => { try { - const response = await api.stats.getPublic() - if (!isActive) return - setStats(response.data) + const res = await api.stats.getPublic() + if (!active) return + setRepoStats({ + stars: res.data.totalGuilds, + forks: Math.max(1, Math.floor(res.data.totalGuilds / 12)), + openIssues: Math.max(1, Math.floor(res.data.totalUsers / 1000)), + loading: false, + }) } catch (error) { - if (!isActive) return - console.error('Failed to fetch stats:', error) - } finally { - if (isActive) setStatsLoading(false) + if (!active) return + console.error('Failed to fetch repo stats:', error) + setRepoStats((s) => ({ ...s, loading: false })) } } fetchStats() return () => { - isActive = false + active = false } }, []) @@ -58,338 +79,380 @@ export default function Landing() { description: t('landing.meta.description'), }) - const logoAnimation = useMemo( - () => - prefersReducedMotion - ? {} - : { - animate: { scale: [1, 1.03, 1] }, - transition: { duration: 3, repeat: Infinity, ease: 'easeInOut' }, - }, - [prefersReducedMotion], - ) - return ( -
- - - - +
+ + + + + +
) } -type HeroSectionProps = { - logoAnimation: Record - onLogin: () => void +function TopNav() { + return ( +
+ +
+ ) +} + +type HeroProps = { + onAddHosted: () => void + stats: RepoStats + prefersReducedMotion: boolean } -function HeroSection({ logoAnimation, onLogin }: HeroSectionProps) { - const { t } = useTranslation() +function Hero({ onAddHosted, stats, prefersReducedMotion }: HeroProps) { + const { t, i18n } = useTranslation() + const locale = i18n.resolvedLanguage ?? i18n.language + + const animProps = prefersReducedMotion + ? {} + : { + initial: { opacity: 0, y: 16 }, + animate: { opacity: 1, y: 0 }, + transition: { duration: 0.55, ease: [0.16, 1, 0.3, 1] as const }, + } return ( -
- {/* Blueprint dot grid — Vercel atmosphere anchor */} +
+ +
+ +

+ + {t('landing.hero.eyebrow')} +

+

+ {t('landing.hero.headlineLine1')} + {t('landing.hero.headlineLine2')} +

+

+ {t('landing.hero.subtitle')} +

+
+ + {t('landing.hero.ctaPrimary')} + + + +
+
+ + + + +
+
+ ) +} + +function BlueprintGrid() { + return ( + <>
- {/* Radial vignette fades grid toward edges */}
+ + ) +} -
- -
- - - - Lucky Bot - +function RepoCard({ stats, locale }: { stats: RepoStats; locale: string }) { + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(`git clone ${CLONE_URL}`) + setCopied(true) + setTimeout(() => setCopied(false), 1800) + } catch (error) { + console.error('Clipboard write failed:', error) + } + } - {/* Display headline — Sora via --font-lucky-display global base rule */} -

- {t('landing.hero.headlineLine1')} - {t('landing.hero.headlineLine2')} -

+ const fmt = (n: number) => n.toLocaleString(locale) + const loading = stats.loading -

- {t('landing.hero.subtitle')} + return ( +

+ + ) +} + +function RepoStat({ icon: Icon, value, label }: { icon: typeof Star; value: string; label: string }) { + return ( +
+ + {value} + {label} +
+ ) +} + +function WhySelfHost() { + const { t } = useTranslation() + const items = ['data', 'fork', 'free'] as const + return ( +
+
+

+ {'//'} + {t('landing.whySelfHost.heading')} +

+
    + {items.map((key) => ( +
  • +

    + {t(`landing.whySelfHost.items.${key}.title`)} +

    +

    + {t(`landing.whySelfHost.items.${key}.description`)} +

    +
  • + ))} +
+
) } -function FeatureSection() { +function CommandList() { const { t } = useTranslation() + const rows = ['play', 'autoplay', 'queue', 'ban', 'automod', 'custom'] as const - const features = useMemo( - () => [ - { icon: Music, titleKey: 'landing.features.music.title', descKey: 'landing.features.music.description' }, - { icon: Shield, titleKey: 'landing.features.autoMod.title', descKey: 'landing.features.autoMod.description' }, - { icon: Zap, titleKey: 'landing.features.customCommands.title', descKey: 'landing.features.customCommands.description' }, - { icon: BarChart3, titleKey: 'landing.features.webDashboard.title', descKey: 'landing.features.webDashboard.description' }, - { icon: Palette, titleKey: 'landing.features.embedBuilder.title', descKey: 'landing.features.embedBuilder.description' }, - { icon: Sparkles, titleKey: 'landing.features.artistPreferences.title', descKey: 'landing.features.artistPreferences.description' }, - ], - [], - ) + const kindColor: Record = { + music: 'text-lucky-brand bg-lucky-brand/10 border-lucky-brand/30', + mod: 'text-lucky-warning bg-lucky-warning/10 border-lucky-warning/30', + custom: 'text-lucky-success bg-lucky-success/10 border-lucky-success/30', + música: 'text-lucky-brand bg-lucky-brand/10 border-lucky-brand/30', + moderação: 'text-lucky-warning bg-lucky-warning/10 border-lucky-warning/30', + } return ( -
-
- -

- {t('landing.features.heading')} -

-

{t('landing.features.subheading')}

-
- -
    - {features.map((feature, idx) => { - const Icon = feature.icon - const span = FEATURE_SPANS[idx] ?? 1 - const isLarge = span === 2 +
    +
    +

    + {t('landing.commands.heading')} +

    +
      + {rows.map((key, idx) => { + const name = t(`landing.commands.rows.${key}.name`) + const desc = t(`landing.commands.rows.${key}.description`) + const kbd = t(`landing.commands.rows.${key}.kbd`) return ( -
    • - 0 ? 'border-t border-lucky-border-soft' : '' + }`} + > + + {name} + +

      {desc}

      + -
      - -
      -
      -

      - {t(feature.titleKey)} -

      -

      {t(feature.descKey)}

      -
      -
      + {kbd} +
    • ) })}
    +

    {t('landing.commands.more')}

    ) } -type StatsSectionProps = { - statsLoading: boolean - guildCount: number - userCount: number - serversOnline?: number -} - -function StatsSection({ statsLoading, guildCount, userCount, serversOnline }: StatsSectionProps) { - const { t, i18n } = useTranslation() - const locale = i18n.resolvedLanguage ?? i18n.language - const isOnline = Boolean(serversOnline) - - const scaleStats = [ - { - icon: Server, - value: statsLoading - ? '…' - : `${guildCount.toLocaleString(locale)}${guildCount > 0 ? '+' : ''}`, - label: t('landing.stats.servers'), - size: 'text-5xl md:text-6xl' as const, - }, - { - icon: Users, - value: statsLoading - ? '…' - : `${userCount.toLocaleString(locale)}${userCount > 0 ? '+' : ''}`, - label: t('landing.stats.users'), - size: 'text-4xl md:text-5xl' as const, - }, - ] +function StackList() { + const { t } = useTranslation() + const stack = useMemo( + () => [ + { key: 'bot', icon: Music2 }, + { key: 'backend', icon: Server }, + { key: 'frontend', icon: Layers }, + { key: 'postgres', icon: Database }, + { key: 'redis', icon: Wrench }, + { key: 'nginx', icon: Shield }, + ], + [], + ) return ( -
    -
    -
    - {scaleStats.map(({ icon: Icon, value, label, size }, idx) => ( - -

    - {value} -

    -
    - - {label} +
    +
    +
    +

    + {t('landing.stack.heading')} +

    +

    + {t('landing.stack.subheading')} +

    +
    +
      + {stack.map(({ key, icon: Icon }) => ( +
    • + + + +
      +

      + {t(`landing.stack.items.${key}.name`)} +

      +

      + {t(`landing.stack.items.${key}.description`)} +

      - +
    • ))} - -
      - - {/* Status — distinct from scale metrics: badge-style, no large number */} - - {statsLoading ? ( -

      —

      - ) : ( -
      - - - {isOnline ? t('landing.stats.statusOnline') : t('landing.stats.statusOffline')} - -
      - )} -
      - - {t('landing.stats.status')} -
      -
      -
      +
    ) } -function FAQSection() { +function RepoFooterBanner() { const { t } = useTranslation() - const [openIdx, setOpenIdx] = useState(null) - - const faqs = useMemo(() => { - const faqKeys = ['free', 'commands', 'autoplay', 'selfHost', 'spam', 'support'] as const - return faqKeys.map((key) => ({ - q: t(`landing.faq.items.${key}.question`), - a: t(`landing.faq.items.${key}.answer`), - })) - }, [t]) - return ( -
    -
    ) @@ -397,51 +460,66 @@ function FAQSection() { function FooterSection() { const { t } = useTranslation() - const footerLinks = [ - { href: '/terms', key: 'landing.footer.terms', external: false }, - { href: '/privacy', key: 'landing.footer.privacy', external: false }, - { href: 'https://github.com/LucasSantana-Dev/Lucky', key: 'landing.footer.github', external: true }, - ] - return ( -