diff --git a/frontend/app/[locale]/about/page.tsx b/frontend/app/[locale]/about/page.tsx index 5165b909..57c93a75 100644 --- a/frontend/app/[locale]/about/page.tsx +++ b/frontend/app/[locale]/about/page.tsx @@ -1,3 +1,4 @@ +import { getTranslations } from "next-intl/server" import { getPlatformStats } from "@/lib/about/stats" import { getSponsors } from "@/lib/about/github-sponsors" @@ -7,6 +8,14 @@ import { FeaturesSection } from "@/components/about/FeaturesSection" import { PricingSection } from "@/components/about/PricingSection" import { CommunitySection } from "@/components/about/CommunitySection" +export async function generateMetadata() { + const t = await getTranslations("about") + return { + title: t("metaTitle"), + description: t("metaDescription"), + } +} + export default async function AboutPage() { const [stats, sponsors] = await Promise.all([ getPlatformStats(), @@ -17,13 +26,13 @@ export default async function AboutPage() {
- + - +
) } \ No newline at end of file diff --git a/frontend/app/api/ai/explain/route.ts b/frontend/app/api/ai/explain/route.ts index 115ce801..41002db2 100644 --- a/frontend/app/api/ai/explain/route.ts +++ b/frontend/app/api/ai/explain/route.ts @@ -1,7 +1,7 @@ export const runtime = 'nodejs'; +export const maxDuration = 25; import { NextRequest, NextResponse } from 'next/server'; -import Groq from 'groq-sdk'; import { z } from 'zod'; import { createExplainPrompt, @@ -9,11 +9,61 @@ import { } from '@/lib/ai/prompts'; import { getClientIp } from '@/lib/security/client-ip'; +// ============================================================================= +// SERVER-SIDE LOGGING (sanitized - no sensitive data exposed) +// ============================================================================= +function logEnvironmentDiagnostics() { + const apiKey = process.env.GROQ_API_KEY; + console.log('[ENV] GROQ_API_KEY configured:', !!apiKey); + console.log('[ENV] GROQ_API_KEY length:', apiKey ? apiKey.length : 0); + console.log('[ENV] NODE_ENV:', process.env.NODE_ENV); + console.log('[ENV] NETLIFY:', process.env.NETLIFY ?? 'false'); + console.log('[ENV] CONTEXT:', process.env.CONTEXT ?? 'unknown'); +} + +function logRequestDiagnostics(request: NextRequest) { + console.log('[REQ] Method:', request.method); + console.log('[REQ] URL path:', new URL(request.url).pathname); +} + +function logBodyParsingResult(success: boolean, error?: unknown) { + console.log('[BODY] Parse success:', success); + if (error) { + console.log('[BODY] Parse error:', error instanceof Error ? error.message : 'Unknown error'); + } +} + +function logGroqInitialization(success: boolean, error?: unknown) { + console.log('[GROQ] Init success:', success); + if (error) { + const err = error as Error & { status?: number; code?: string }; + console.log('[GROQ] Init error:', err.name, err.message); + } +} + +function logGroqApiCall(phase: 'start' | 'success' | 'error', details?: unknown) { + if (phase === 'start') { + console.log('[GROQ] Starting API call'); + } else if (phase === 'success') { + console.log('[GROQ] API call successful'); + } else if (phase === 'error') { + const err = details as Error & { status?: number; code?: string }; + console.log('[GROQ] API error:', err?.name, err?.message); + } +} +// ============================================================================= + +// ============================================================================= +// RATE LIMITER (In-memory - limited effectiveness in serverless) +// Note: This Map only persists within a single warm function instance. +// For production, consider Upstash Redis or Netlify Blobs for true rate limiting. +// Current behavior: works during warm instance, resets on cold start. +// ============================================================================= const rateLimiter = new Map(); const MAX_REQUESTS_PER_WINDOW = 10; -const RATE_LIMIT_WINDOW_MS = 20 * 60 * 1000; +const RATE_LIMIT_WINDOW_MS = 20 * 60 * 1000; -const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; let lastCleanup = Date.now(); function cleanupRateLimiter() { @@ -40,7 +90,12 @@ const requestSchema = z.object({ }); -function checkRateLimit(ip: string): { allowed: boolean; remaining: number; resetIn: number } { +function checkRateLimit(ip: string): { allowed: boolean; remaining: number; resetIn: number; skipped: boolean } { + // Bypass rate limiting for unknown IPs (serverless safety) + if (ip === 'unknown') { + return { allowed: true, remaining: MAX_REQUESTS_PER_WINDOW, resetIn: RATE_LIMIT_WINDOW_MS, skipped: true }; + } + cleanupRateLimiter(); const now = Date.now(); @@ -48,12 +103,12 @@ function checkRateLimit(ip: string): { allowed: boolean; remaining: number; rese if (!entry || now > entry.resetAt) { rateLimiter.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); - return { allowed: true, remaining: MAX_REQUESTS_PER_WINDOW - 1, resetIn: RATE_LIMIT_WINDOW_MS }; + return { allowed: true, remaining: MAX_REQUESTS_PER_WINDOW - 1, resetIn: RATE_LIMIT_WINDOW_MS, skipped: false }; } if (entry.count >= MAX_REQUESTS_PER_WINDOW) { const resetIn = entry.resetAt - now; - return { allowed: false, remaining: 0, resetIn }; + return { allowed: false, remaining: 0, resetIn, skipped: false }; } entry.count++; @@ -61,6 +116,7 @@ function checkRateLimit(ip: string): { allowed: boolean; remaining: number; rese allowed: true, remaining: MAX_REQUESTS_PER_WINDOW - entry.count, resetIn: entry.resetAt - now, + skipped: false, }; } @@ -91,14 +147,17 @@ function parseExplanationResponse(content: string): ExplanationResponse { } export async function POST(request: NextRequest) { + logEnvironmentDiagnostics(); + logRequestDiagnostics(request); + const apiKey = process.env.GROQ_API_KEY; if (!apiKey) { - console.error('GROQ_API_KEY is not configured'); - console.error('Available env vars starting with GROQ:', - Object.keys(process.env).filter(k => k.startsWith('GROQ')) - ); + console.error('[FATAL] GROQ_API_KEY is not configured. Check environment variables.'); return NextResponse.json( - { error: 'AI service not configured', code: 'SERVICE_UNAVAILABLE' }, + { + error: 'AI service not configured', + code: 'SERVICE_UNAVAILABLE', + }, { status: 503 } ); } @@ -125,10 +184,21 @@ export async function POST(request: NextRequest) { ); } + // Safe JSON body parsing for Netlify let body: unknown; try { - body = await request.json(); - } catch { + const text = await request.text(); + if (!text || text.trim() === '') { + console.log('[BODY] Empty request body received'); + return NextResponse.json( + { error: 'Request body is empty', code: 'EMPTY_BODY' }, + { status: 400 } + ); + } + body = JSON.parse(text); + logBodyParsingResult(true); + } catch (parseError) { + logBodyParsingResult(false, parseError); return NextResponse.json( { error: 'Invalid JSON body', code: 'INVALID_JSON' }, { status: 400 } @@ -149,11 +219,46 @@ export async function POST(request: NextRequest) { const { term, context } = validationResult.data; - const groq = new Groq({ apiKey }); + // Dynamic import for Netlify compatibility + let Groq: typeof import('groq-sdk').default; + try { + const groqModule = await import('groq-sdk'); + Groq = groqModule.default; + } catch (importError) { + console.error('[SDK_IMPORT_ERROR] Failed to import groq-sdk:', + importError instanceof Error ? importError.message : String(importError) + ); + return NextResponse.json( + { + error: 'Failed to load AI client', + code: 'SDK_IMPORT_ERROR', + }, + { status: 503 } + ); + } + + let groq: InstanceType; + try { + groq = new Groq({ apiKey }); + logGroqInitialization(true); + } catch (initError) { + logGroqInitialization(false, initError); + console.error('[SDK_INIT_ERROR] Failed to initialize Groq client:', + initError instanceof Error ? initError.message : String(initError) + ); + return NextResponse.json( + { + error: 'Failed to initialize AI client', + code: 'SDK_INIT_ERROR', + }, + { status: 503 } + ); + } try { const prompt = createExplainPrompt({ term, context }); + logGroqApiCall('start'); const chatCompletion = await groq.chat.completions.create({ messages: [ { @@ -166,10 +271,12 @@ export async function POST(request: NextRequest) { max_tokens: 1500, top_p: 1, }); + logGroqApiCall('success', chatCompletion); const content = chatCompletion.choices[0]?.message?.content; if (!content) { + console.error('[ERROR] No content in Groq response'); throw new Error('No content in response'); } @@ -184,17 +291,8 @@ export async function POST(request: NextRequest) { }, }); } catch (error) { - console.error('Groq API error:', error); - - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - const errorName = error instanceof Error ? error.name : 'UnknownError'; - - console.error('Error details:', { - name: errorName, - message: errorMessage, - stack: error instanceof Error ? error.stack : undefined, - }); + logGroqApiCall('error', error); + console.error('[GROQ_ERROR]', error instanceof Error ? error.message : 'Unknown error'); if (error instanceof Error) { if ( @@ -202,8 +300,12 @@ export async function POST(request: NextRequest) { error.message.includes('authentication') || error.message.includes('Invalid API Key') ) { + console.error('[AUTH_ERROR] API key authentication failed'); return NextResponse.json( - { error: 'AI service authentication failed', code: 'AUTH_ERROR' }, + { + error: 'AI service authentication failed', + code: 'AUTH_ERROR', + }, { status: 503 } ); } @@ -221,7 +323,6 @@ export async function POST(request: NextRequest) { { error: 'AI model not available', code: 'MODEL_ERROR', - details: errorMessage, }, { status: 503 } ); @@ -232,10 +333,27 @@ export async function POST(request: NextRequest) { { error: 'Failed to generate explanation', code: 'AI_ERROR', - details: - process.env.NODE_ENV === 'development' ? errorMessage : undefined, }, { status: 500 } ); } } + +export async function GET() { + const apiKey = process.env.GROQ_API_KEY; + return NextResponse.json( + { + status: apiKey ? 'ok' : 'misconfigured', + service: 'ai-explain', + timestamp: new Date().toISOString(), + env: { + hasGroqKey: !!apiKey, + groqKeyLength: apiKey ? apiKey.length : 0, + nodeEnv: process.env.NODE_ENV, + isNetlify: !!process.env.NETLIFY, + context: process.env.CONTEXT ?? 'unknown', + }, + }, + { status: apiKey ? 200 : 503 } + ); +} diff --git a/frontend/components/about/CommunitySection.tsx b/frontend/components/about/CommunitySection.tsx index bd402a44..91c38756 100644 --- a/frontend/components/about/CommunitySection.tsx +++ b/frontend/components/about/CommunitySection.tsx @@ -2,31 +2,34 @@ import { Github, ArrowRight, ExternalLink, MessageCircle } from "lucide-react" import Link from "next/link" +import { useTranslations } from "next-intl" import { TESTIMONIALS, type Testimonial } from "@/data/about" import { GradientBadge } from "@/components/ui/gradient-badge" import { SectionHeading } from "@/components/ui/section-heading" export function CommunitySection() { + const t = useTranslations("about.community") + return (
- +
- +
- - - + +
- +
@@ -60,7 +63,7 @@ export function CommunitySection() { hover:text-[#1e5eff] dark:hover:text-[#ff2d55]" > - Join Discussion + {t("joinDiscussion")}
@@ -77,7 +80,7 @@ export function CommunitySection() { hover:shadow-[0_0_30px_-5px_rgba(30,94,255,0.15)] dark:hover:shadow-[0_0_30px_-5px_rgba(255,45,85,0.15)]" > - Have a success story or feature request? + {t("successStory")} - Join Discussion + {t("joinDiscussion")}

- We read every single thread + {t("readThreads")}

diff --git a/frontend/components/about/FeaturesSection.tsx b/frontend/components/about/FeaturesSection.tsx index 84eec101..093bd2c9 100644 --- a/frontend/components/about/FeaturesSection.tsx +++ b/frontend/components/about/FeaturesSection.tsx @@ -3,7 +3,8 @@ import { useState, useEffect } from "react" import { motion, AnimatePresence, useReducedMotion } from "framer-motion" import { Link } from "@/i18n/routing" -import { +import { useTranslations } from "next-intl" +import { MessageCircle, Brain, Trophy, User, ShoppingBag, BookOpen, Globe, Cpu, Shield, Languages, Clock, Lightbulb, Target, Medal, Users, Flame, Zap, BarChart3, TrendingUp, Tag, History, @@ -15,8 +16,8 @@ import { SectionHeading } from "@/components/ui/section-heading" interface Feature { icon: LucideIcon - label: string - desc: string + labelKey: string + descKey: string x: number y: number size: number @@ -29,26 +30,6 @@ interface Page { features: Feature[] } -const translations: Record = { - "title": "Features designed to", - "titleHighlight": "make you unstoppable", - "subtitle": "Stop searching for scattered info. We curated the ultimate toolkit to crack technical interviews.", - "qa.title": "Q&A", - "qa.description": "No fluff. Just a massive library of real interview questions with precise, recruiter-approved answers.", - "quiz.title": "Quizzes", - "quiz.description": "Validate your confidence. Fast-paced interactive quizzes to spot your weak points before the interviewer does.", - "leaderboard.title": "Leaderboard", - "leaderboard.description": "Gamify your prep. Earn points for every correct answer, keep your streak alive, and rank up against others.", - "blog.title": "Blog", - "blog.description": "Stay updated with detailed articles on tech trends, coding tutorials, and industry insights to keep you ahead of the curve.", - "profile.title": "Analytics", - "profile.description": "Don't fly blind. Visualize your progress with detailed charts to see exactly which topics you've mastered.", - "shop.title": "Shop", - "shop.description": "Upgrade your setup. High-quality developer apparel, desk accessories, and digital assets available for purchase.", -} - -const t = (key: string) => translations[key] || key - const decorativeDots = [ { x: '5%', y: '20%', size: 8 }, { x: '10%', y: '75%', size: 6 }, @@ -70,10 +51,10 @@ const pages: Page[] = [ icon: MessageCircle, href: "/q&a", features: [ - { icon: Globe, label: "3 Languages", desc: "EN, UK & PL supported", x: -120, y: -120, size: 88 }, - { icon: Cpu, label: "AI Helper", desc: "Select text for AI explain", x: 120, y: -120, size: 88 }, - { icon: Lightbulb, label: "Smart Cache", desc: "Highlights learned terms", x: -120, y: 120, size: 88 }, - { icon: Search, label: "Tech Filter", desc: "React, Git, JS & more", x: 120, y: 120, size: 88 }, + { icon: Globe, labelKey: "bubbles.qa.languages.label", descKey: "bubbles.qa.languages.desc", x: -120, y: -120, size: 88 }, + { icon: Cpu, labelKey: "bubbles.qa.aiHelper.label", descKey: "bubbles.qa.aiHelper.desc", x: 120, y: -120, size: 88 }, + { icon: Lightbulb, labelKey: "bubbles.qa.smartCache.label", descKey: "bubbles.qa.smartCache.desc", x: -120, y: 120, size: 88 }, + { icon: Search, labelKey: "bubbles.qa.techFilter.label", descKey: "bubbles.qa.techFilter.desc", x: 120, y: 120, size: 88 }, ] }, { @@ -81,10 +62,10 @@ const pages: Page[] = [ icon: Brain, href: "/quizzes", features: [ - { icon: Clock, label: "Smart Timer", desc: "Race against the total time", x: -120, y: -120, size: 88 }, - { icon: Shield, label: "Anti-Cheat", desc: "Focus loss detection", x: 120, y: -120, size: 88 }, - { icon: Save, label: "Auto Sync", desc: "Saves progress post-login", x: -120, y: 120, size: 88 }, - { icon: BarChart3, label: "Tracking", desc: "Best scores & attempts", x: 120, y: 120, size: 88 }, + { icon: Clock, labelKey: "bubbles.quiz.smartTimer.label", descKey: "bubbles.quiz.smartTimer.desc", x: -120, y: -120, size: 88 }, + { icon: Shield, labelKey: "bubbles.quiz.antiCheat.label", descKey: "bubbles.quiz.antiCheat.desc", x: 120, y: -120, size: 88 }, + { icon: Save, labelKey: "bubbles.quiz.autoSync.label", descKey: "bubbles.quiz.autoSync.desc", x: -120, y: 120, size: 88 }, + { icon: BarChart3, labelKey: "bubbles.quiz.tracking.label", descKey: "bubbles.quiz.tracking.desc", x: 120, y: 120, size: 88 }, ] }, { @@ -92,10 +73,10 @@ const pages: Page[] = [ icon: Trophy, href: "/leaderboard", features: [ - { icon: Medal, label: "The Podium", desc: "Top 3 exclusive spotlight", x: -120, y: -120, size: 88 }, - { icon: Globe, label: "Global Rank", desc: "Compete worldwide", x: 120, y: -120, size: 88 }, - { icon: Zap, label: "XP System", desc: "Points for every answer", x: -120, y: 120, size: 88 }, - { icon: Activity, label: "Live Feed", desc: "Real-time rank updates", x: 120, y: 120, size: 88 }, + { icon: Medal, labelKey: "bubbles.leaderboard.podium.label", descKey: "bubbles.leaderboard.podium.desc", x: -120, y: -120, size: 88 }, + { icon: Globe, labelKey: "bubbles.leaderboard.globalRank.label", descKey: "bubbles.leaderboard.globalRank.desc", x: 120, y: -120, size: 88 }, + { icon: Zap, labelKey: "bubbles.leaderboard.xpSystem.label", descKey: "bubbles.leaderboard.xpSystem.desc", x: -120, y: 120, size: 88 }, + { icon: Activity, labelKey: "bubbles.leaderboard.liveFeed.label", descKey: "bubbles.leaderboard.liveFeed.desc", x: 120, y: 120, size: 88 }, ] }, { @@ -103,10 +84,10 @@ const pages: Page[] = [ icon: User, href: "/dashboard", features: [ - { icon: BarChart3, label: "Stats Hub", desc: "Visualize your growth", x: -120, y: -120, size: 88 }, - { icon: History, label: "History", desc: "Track learning streaks", x: 120, y: -120, size: 88 }, - { icon: UserCircle, label: "Identity", desc: "Manage role & profile", x: -120, y: 120, size: 88 }, - { icon: Bell, label: "Reminders", desc: "Finish incomplete quizzes", x: 120, y: 120, size: 88 }, + { icon: BarChart3, labelKey: "bubbles.profile.statsHub.label", descKey: "bubbles.profile.statsHub.desc", x: -120, y: -120, size: 88 }, + { icon: History, labelKey: "bubbles.profile.history.label", descKey: "bubbles.profile.history.desc", x: 120, y: -120, size: 88 }, + { icon: UserCircle, labelKey: "bubbles.profile.identity.label", descKey: "bubbles.profile.identity.desc", x: -120, y: 120, size: 88 }, + { icon: Bell, labelKey: "bubbles.profile.reminders.label", descKey: "bubbles.profile.reminders.desc", x: 120, y: 120, size: 88 }, ] }, { @@ -114,10 +95,10 @@ const pages: Page[] = [ icon: BookOpen, href: "/blog", features: [ - { icon: TrendingUp, label: "Tech Trends", desc: "Stay ahead of the curve", x: -120, y: -120, size: 88 }, - { icon: PenTool, label: "Tutorials", desc: "Step-by-step guides", x: 120, y: -120, size: 88 }, - { icon: BookOpen, label: "Deep Dives", desc: "In-depth analysis", x: -120, y: 120, size: 88 }, - { icon: Users, label: "Community", desc: "Written by developers", x: 120, y: 120, size: 88 }, + { icon: TrendingUp, labelKey: "bubbles.blog.techTrends.label", descKey: "bubbles.blog.techTrends.desc", x: -120, y: -120, size: 88 }, + { icon: PenTool, labelKey: "bubbles.blog.tutorials.label", descKey: "bubbles.blog.tutorials.desc", x: 120, y: -120, size: 88 }, + { icon: BookOpen, labelKey: "bubbles.blog.deepDives.label", descKey: "bubbles.blog.deepDives.desc", x: -120, y: 120, size: 88 }, + { icon: Users, labelKey: "bubbles.blog.community.label", descKey: "bubbles.blog.community.desc", x: 120, y: 120, size: 88 }, ] }, { @@ -125,24 +106,24 @@ const pages: Page[] = [ icon: ShoppingBag, href: "/shop", features: [ - { icon: Sparkles, label: "New Drops", desc: "Regular fresh content", x: -120, y: -120, size: 88 }, - { icon: Tag, label: "Curated", desc: "Dev-focused collections", x: 120, y: -120, size: 88 }, - { icon: CreditCard, label: "Checkout", desc: "Seamless Stripe flow", x: -120, y: 120, size: 88 }, - { icon: Package, label: "Premium", desc: "High-quality material", x: 120, y: 120, size: 88 }, + { icon: Sparkles, labelKey: "bubbles.shop.newDrops.label", descKey: "bubbles.shop.newDrops.desc", x: -120, y: -120, size: 88 }, + { icon: Tag, labelKey: "bubbles.shop.curated.label", descKey: "bubbles.shop.curated.desc", x: 120, y: -120, size: 88 }, + { icon: CreditCard, labelKey: "bubbles.shop.checkout.label", descKey: "bubbles.shop.checkout.desc", x: -120, y: 120, size: 88 }, + { icon: Package, labelKey: "bubbles.shop.premium.label", descKey: "bubbles.shop.premium.desc", x: 120, y: 120, size: 88 }, ] }, ] -function FeatureBubble({ feature, index, href, isMobile }: { feature: Feature; index: number; href: string; isMobile: boolean }) { +function FeatureBubble({ feature, index, href, isMobile, t }: { feature: Feature; index: number; href: string; isMobile: boolean; t: (key: string) => string }) { const Icon = feature.icon const floatDelay = index * 0.5 const floatDuration = 3 + index * 0.3 - + const scaleX = isMobile ? 0.55 : 1 const scaleY = isMobile ? 1.3 : 1 const posX = feature.x * scaleX const posY = feature.y * scaleY - + const shouldReduceMotion = useReducedMotion() return ( @@ -150,10 +131,10 @@ function FeatureBubble({ feature, index, href, isMobile }: { feature: Feature; i className="absolute" style={{ left: '50%', top: '50%' }} initial={{ x: '-50%', y: '-50%', opacity: 0, scale: 0 }} - animate={{ - x: `calc(-50% + ${posX}%)`, + animate={{ + x: `calc(-50% + ${posX}%)`, y: `calc(-50% + ${posY}%)`, - opacity: 1, + opacity: 1, scale: 1, }} exit={{ x: '-50%', y: '-50%', opacity: 0, scale: 0 }} @@ -164,20 +145,20 @@ function FeatureBubble({ feature, index, href, isMobile }: { feature: Feature; i scale: { delay: 0.1 + index * 0.08, duration: 0.5, type: "spring", bounce: 0.4 }, }} > -
-
-
{feature.label}
-
{feature.desc}
+
{t(feature.labelKey)}
+
{t(feature.descKey)}
@@ -275,16 +256,18 @@ function ConnectingLines() { ) } -function TabButton({ - page, - isActive, +function TabButton({ + page, + isActive, onClick, - onKeyDown -}: { - page: Page; - isActive: boolean; - onClick: () => void + onKeyDown, + t +}: { + page: Page; + isActive: boolean; + onClick: () => void onKeyDown: (e: React.KeyboardEvent) => void + t: (key: string) => string }) { return (
diff --git a/frontend/components/about/PricingSection.tsx b/frontend/components/about/PricingSection.tsx index d7490235..1e6382ca 100644 --- a/frontend/components/about/PricingSection.tsx +++ b/frontend/components/about/PricingSection.tsx @@ -3,46 +3,63 @@ import { useState } from "react" import { motion } from "framer-motion" import { Check, Heart, X, Sparkles, Server, ArrowRight } from "lucide-react" import Link from "next/link" -import type { Sponsor } from "@/lib/about/github-sponsors" +import { useTranslations } from "next-intl" +import type { Sponsor } from "@/lib/about/github-sponsors" import { SponsorsWall } from "./SponsorsWall" import { GradientBadge } from "@/components/ui/gradient-badge" import { SectionHeading } from "@/components/ui/section-heading" import { ParticleCanvas } from "@/components/ui/particle-canvas" + interface PricingSectionProps { sponsors?: Sponsor[] } export function PricingSection({ sponsors = [] }: PricingSectionProps) { + const t = useTranslations("about.pricing") const [activeShape, setActiveShape] = useState<"brackets" | "heart" | null>(null) + const juniorFeatures = [ + t("junior.features.unlimited"), + t("junior.features.fullAccess"), + t("junior.features.noCard"), + t("junior.features.noGuilt"), + ] + + const heroFeatures = [ + t("hero.features.servers"), + t("hero.features.coffee"), + t("hero.features.badge"), + t("hero.features.feeling"), + ] + return (
) diff --git a/frontend/components/about/SponsorsWall.tsx b/frontend/components/about/SponsorsWall.tsx index 6ea620c2..8eb1cba7 100644 --- a/frontend/components/about/SponsorsWall.tsx +++ b/frontend/components/about/SponsorsWall.tsx @@ -4,6 +4,7 @@ import { motion } from "framer-motion" import Image from "next/image" import Link from "next/link" import { Plus, Crown, Sparkles } from "lucide-react" +import { useTranslations } from "next-intl" import { cn } from "@/lib/utils" import type { Sponsor } from "@/lib/about/github-sponsors" import { GradientBadge } from "@/components/ui/gradient-badge" @@ -15,15 +16,16 @@ interface SponsorsWallProps { const MAX_SPONSORS = 10 export function SponsorsWall({ sponsors = [] }: SponsorsWallProps) { + const t = useTranslations("about.sponsors") const displaySponsors = sponsors.slice(0, MAX_SPONSORS) return (
- +
- - You? + {t("emptySlot")}
)}
-